408 lines
12 KiB
Plaintext
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 |