Module AppStore EnableExplicit ; Constants #AppsDir = "apps/" #TempDir = "tmp/" #ValidPermCount = 4 ; Variables Global Dim _ValidPerms.s(#ValidPermCount - 1) _ValidPerms(0) = "storage" _ValidPerms(1) = "fs.read" _ValidPerms(2) = "fs.write" _ValidPerms(3) = "notify" ;- Private declarations Declare _IsValidAppID(AppID.s) Declare _IsValidPermission(Perm.s) Declare _IsValidFilePath(Path.s) Declare.s _BundleDir(UserID, AppID.s) Declare _EnsureDir(Path.s) Declare _DeleteDirRecursive(Path.s) Declare _WipeBundle(UserID, AppID.s) Declare _WipeStorage(UserID, AppID.s) Declare _ExtractZip(ZipFile.s, DestDir.s) Declare.s _ReadManifestFromZip(ZipFile.s) Declare.s _ValidateManifest(JSON.s, *OutAppID.String, *OutPerms.String) ;- Public procedures Procedure Init() If FileSize(#AppsDir) <> -2 : CreateDirectory(#AppsDir) : EndIf If FileSize(#TempDir) <> -2 : CreateDirectory(#TempDir) : EndIf EndProcedure ; GET /api/apps/list Procedure HandleList(*Request) Protected Username.s = Auth::GetSessionUser(*Request) If Username = "" General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized") ProcedureReturn EndIf General::RespondJSON(*Request, Database::AppList(Database::FindUser(Username))) EndProcedure ; POST /api/apps/install body: url= Procedure HandleInstall(*Request) Protected UserID, HiddenDir, Home, AppsRoot Protected Username.s, URL.s, TempZip.s, ManifestJSON.s, ErrMsg.s, AppID.s, BundleDir.s, Perms.s Protected OutAppID.String, OutPerms.String Username = Auth::GetSessionUser(*Request) If Username = "" General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized") ProcedureReturn EndIf UserID = Database::FindUser(Username) URL = General::GetPostField(*Request, "url") If URL = "" General::RespondJSON(*Request, ~"{\"error\":\"Missing url\"}") ProcedureReturn EndIf If Left(URL, 7) <> "http://" And Left(URL, 8) <> "https://" General::RespondJSON(*Request, ~"{\"error\":\"URL must begin with http:// or https://\"}") ProcedureReturn EndIf TempZip = #TempDir + "pkg_" + UserID + "_" + ElapsedMilliseconds() + ".zip" If Not ReceiveHTTPFile(URL, TempZip) General::RespondJSON(*Request, ~"{\"error\":\"Failed to download package from URL\"}") ProcedureReturn EndIf ; Read and validate manifest before touching anything ManifestJSON = _ReadManifestFromZip(TempZip) If ManifestJSON = "" DeleteFile(TempZip) General::RespondJSON(*Request, ~"{\"error\":\"manifest.json not found or unreadable in package\"}") ProcedureReturn EndIf ErrMsg = _ValidateManifest(ManifestJSON, @OutAppID, @OutPerms) If ErrMsg <> "" DeleteFile(TempZip) General::RespondJSON(*Request, ~"{\"error\":\"" + ReplaceString(ErrMsg, ~"\"", ~"\\\"") + ~"\"}") ProcedureReturn EndIf AppID = OutAppID\s Perms = OutPerms\s If Database::AppExists(UserID, AppID) _WipeBundle(UserID, AppID) Database::AppUninstall(UserID, AppID) EndIf BundleDir = _BundleDir(UserID, AppID) If Not _EnsureDir(BundleDir) DeleteFile(TempZip) General::RespondJSON(*Request, ~"{\"error\":\"Could not create app directory\"}") ProcedureReturn EndIf If Not _ExtractZip(TempZip, BundleDir) DeleteFile(TempZip) _WipeBundle(UserID, AppID) General::RespondJSON(*Request, ~"{\"error\":\"Failed to extract package\"}") ProcedureReturn EndIf DeleteFile(TempZip) If Not Database::AppInstall(UserID, AppID, ManifestJSON, Perms) _WipeBundle(UserID, AppID) General::RespondJSON(*Request, ~"{\"error\":\"Failed to register app in database\"}") ProcedureReturn EndIf HiddenDir = Database::FSResolve(UserID, "/.apps/" + AppID) If HiddenDir = 0 AppsRoot = Database::FSResolve(UserID, "/.apps") If AppsRoot = 0 Home = Database::FSGetOrCreateHome(UserID) AppsRoot = Database::FSMkdir(UserID, Home, ".apps") EndIf If AppsRoot > 0 Database::FSMkdir(UserID, AppsRoot, AppID) EndIf EndIf General::RespondJSON(*Request, ~"{\"success\":true,\"app_id\":\"" + ReplaceString(AppID, ~"\"", ~"\\\"") + ~"\"}") EndProcedure ; POST /api/apps/uninstall body: app_id=... [&keep_data=1] Procedure HandleUninstall(*Request) Protected UserID, KeepData Protected Username.s, AppID.s Username = Auth::GetSessionUser(*Request) If Username = "" General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized") ProcedureReturn EndIf UserID = Database::FindUser(Username) AppID = General::GetPostField(*Request, "app_id") KeepData = Val(General::GetPostField(*Request, "keep_data")) If AppID = "" Or Not _IsValidAppID(AppID) General::RespondJSON(*Request, ~"{\"error\":\"Invalid or missing app_id\"}") ProcedureReturn EndIf If Not Database::AppExists(UserID, AppID) General::RespondJSON(*Request, ~"{\"error\":\"App not installed\"}", "404 Not Found") ProcedureReturn EndIf Database::AppUninstall(UserID, AppID) _WipeBundle(UserID, AppID) If Not KeepData : _WipeStorage(UserID, AppID) : EndIf General::RespondJSON(*Request, ~"{\"success\":true}") EndProcedure ; GET /api/apps/{app_id}/{filepath} Procedure HandleServeFile(*Request, AppID.s, FilePath.s) Protected UserID Protected Username.s, FullPath.s Username = Auth::GetSessionUser(*Request) If Username = "" General::RespondJSON(*Request, ~"{\"error\":\"Unauthorized\"}", "401 Unauthorized") ProcedureReturn EndIf If Not _IsValidAppID(AppID) General::RespondJSON(*Request, ~"{\"error\":\"Bad Request\"}", "400 Bad Request") ProcedureReturn EndIf If Not _IsValidFilePath(FilePath) General::RespondJSON(*Request, ~"{\"error\":\"Forbidden\"}", "403 Forbidden") ProcedureReturn EndIf UserID = Database::FindUser(Username) If Not Database::AppExists(UserID, AppID) General::RespondJSON(*Request, ~"{\"error\":\"Not found\"}", "404 Not Found") ProcedureReturn EndIf FullPath = _BundleDir(UserID, AppID) + FilePath If FileSize(FullPath) < 0 General::RespondJSON(*Request, ~"{\"error\":\"Not found\"}", "404 Not Found") ProcedureReturn EndIf If Not FastCGI::RespondFile(*Request, FullPath) General::RespondJSON(*Request, ~"{\"error\":\"Read error\"}", "500 Internal Server Error") EndIf EndProcedure ;- Private procedures Procedure _IsValidAppID(AppID.s) Protected Length, i Protected c.s Length = Len(AppID) If Length = 0 Or Length > 64 : ProcedureReturn #False : EndIf For i = 1 To Length c = Mid(AppID, i, 1) If Not ((c >= "a" And c <= "z") Or (c >= "A" And c <= "Z") Or (c >= "0" And c <= "9") Or c = "." Or c = "-" Or c = "_") ProcedureReturn #False EndIf Next ProcedureReturn #True EndProcedure Procedure _IsValidPermission(Perm.s) Protected i For i = 0 To #ValidPermCount - 1 If _ValidPerms(i) = Perm : ProcedureReturn #True : EndIf Next ProcedureReturn #False EndProcedure Procedure _IsValidFilePath(Path.s) If Path = "" : ProcedureReturn #False : EndIf If Left(Path, 1) = "/" Or Left(Path, 1) = "\" : ProcedureReturn #False : EndIf If FindString(Path, "..") : ProcedureReturn #False : EndIf If FindString(Path, "//") Or FindString(Path, "\\") : ProcedureReturn #False : EndIf ProcedureReturn #True EndProcedure Procedure.s _BundleDir(UserID, AppID.s) ProcedureReturn #AppsDir + UserID + "/" + AppID + "/" EndProcedure Procedure _EnsureDir(Path.s) Protected Parts, i Protected Current.s, Seg.s, Probe.s Path = ReplaceString(Path, "\", "/") If Right(Path, 1) <> "/" : Path + "/" : EndIf Parts = CountString(Path, "/") For i = 1 To Parts Seg = StringField(Path, i, "/") If Seg = "" : Continue : EndIf Current + Seg + "/" Probe = Left(Current, Len(Current) - 1) If FileSize(Probe) <> -2 If Not CreateDirectory(Probe) : ProcedureReturn #False : EndIf EndIf Next ProcedureReturn #True EndProcedure ; Built-in DeleteDirectory with #PB_FileSystem_Recursive handles the walk for us Procedure _DeleteDirRecursive(Path.s) If Right(Path, 1) = "/" : Path = Left(Path, Len(Path) - 1) : EndIf DeleteDirectory(Path, "", #PB_FileSystem_Recursive) EndProcedure Procedure _WipeBundle(UserID, AppID.s) _DeleteDirRecursive(_BundleDir(UserID, AppID)) EndProcedure Procedure _WipeStorage(UserID, AppID.s) Protected NodeID Protected VPath.s = "/.apps/" + AppID NodeID = Database::FSResolve(UserID, VPath) If NodeID > 0 : Database::FSDelete(NodeID) : EndIf EndProcedure Procedure _ExtractZip(ZipFile.s, DestDir.s) Protected OK, LastSlash, Pos Protected EntryName.s If Not OpenPack(0, ZipFile, #PB_PackerPlugin_Zip) : ProcedureReturn #False : EndIf If Not ExaminePack(0) : ClosePack(0) : ProcedureReturn #False : EndIf OK = #True While NextPackEntry(0) EntryName = PackEntryName(0) If Not _IsValidFilePath(EntryName) : OK = #False : Break : EndIf ; Directory entry — just ensure it exists If Right(EntryName, 1) = "/" _EnsureDir(DestDir + EntryName) Continue EndIf ; Ensure parent directory exists LastSlash = 0 Pos = FindString(EntryName, "/") While Pos > 0 LastSlash = Pos Pos = FindString(EntryName, "/", Pos + 1) Wend If LastSlash > 0 : _EnsureDir(DestDir + Left(EntryName, LastSlash)) : EndIf If Not UncompressPackFile(0, DestDir + EntryName, EntryName) OK = #False : Break EndIf Wend ClosePack(0) ProcedureReturn OK EndProcedure Procedure.s _ReadManifestFromZip(ZipFile.s) Protected Found, Size, FileID, *Buf Protected TempPath.s, Result.s If Not OpenPack(0, ZipFile, #PB_PackerPlugin_Zip) : ProcedureReturn "" : EndIf TempPath = #TempDir + "manifest_" + ElapsedMilliseconds() + ".json" If ExaminePack(0) While NextPackEntry(0) If PackEntryName(0) = "manifest.json" Found = Bool(UncompressPackFile(0, TempPath, "manifest.json")) Break EndIf Wend EndIf ClosePack(0) If Not Found : ProcedureReturn "" : EndIf Size = FileSize(TempPath) If Size > 0 And Size < 65536 ; sanity cap: 64 KB FileID = ReadFile(#PB_Any, TempPath, #PB_File_SharedRead) If FileID *Buf = AllocateMemory(Size + 1) If *Buf ReadData(FileID, *Buf, Size) PokeB(*Buf + Size, 0) Result = PeekS(*Buf, -1, #PB_UTF8) FreeMemory(*Buf) EndIf CloseFile(FileID) EndIf EndIf DeleteFile(TempPath) ProcedureReturn Result EndProcedure Procedure.s _ValidateManifest(JSON.s, *OutAppID.String, *OutPerms.String) Protected Root, PermsNode, PermCount, i Protected AppID.s, Name_.s, Entry.s, PermsJSON.s, Perm.s If Not ParseJSON(0, JSON) : ProcedureReturn "Invalid JSON in manifest" : EndIf Root = JSONValue(0) AppID = GetJSONString(GetJSONMember(Root, "id")) Name_ = GetJSONString(GetJSONMember(Root, "name")) Entry = GetJSONString(GetJSONMember(Root, "entry")) If AppID = "" Or Name_ = "" Or Entry = "" FreeJSON(0) ProcedureReturn "manifest.json missing required field(s): id, name, entry" EndIf If Not _IsValidAppID(AppID) FreeJSON(0) ProcedureReturn "Invalid app id '" + AppID + "' (use reverse-domain format)" EndIf If Not _IsValidFilePath(Entry) FreeJSON(0) ProcedureReturn "Invalid entry path" EndIf PermsNode = GetJSONMember(Root, "permissions") PermCount = JSONArraySize(PermsNode) PermsJSON = ~"[\"storage\"" For i = 0 To PermCount - 1 Perm = GetJSONString(GetJSONElement(PermsNode, i)) If Perm = "" Or Perm = "storage" : Continue : EndIf If Not _IsValidPermission(Perm) FreeJSON(0) ProcedureReturn "Unknown permission '" + Perm + "'" EndIf PermsJSON + ~",\"" + Perm + ~"\"" Next PermsJSON + "]" *OutAppID\s = AppID *OutPerms\s = PermsJSON FreeJSON(0) ProcedureReturn "" EndProcedure EndModule ; IDE Options = PureBasic 6.30 (Windows - x64) ; CursorPosition = 402 ; Folding = BAg ; EnableXP ; DPIAware