KUMOS/Server/Includes/AppStore.pbi
2026-05-02 15:49:06 +02:00

408 lines
12 KiB
Plaintext

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=<zip 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