Переглянути джерело

Allow AGI scripts via any module from their folder

Backend .agi scripts were only reachable by users with permission to a module named exactly like the script's folder. Apps that register several modules from one folder (Office/ -> Docs, Sheets, Slides) could not reach their shared backend scripts.

Modules now record the folder they were registered from (ModuleInfo.SourceFolder, never serialized to JSON so a module cannot claim another app's folder). CheckUserAccessToScript grants access if the user can use a module named after the folder or any module registered from it. The gateway gets a ModuleFolderResolver for this, and ModuleRegisterParser now receives the source folder.

Init scripts run through the new Gateway.RunWebAppInitScript, which replaces RunScript, so the module activator also records the source folder. Subservices set SourceFolder on launch. Scripts outside the scope are now rejected. Added tests and updated CLAUDE.md.
Toby Chui 22 годин тому
батько
коміт
eed1bac06e

+ 6 - 0
CLAUDE.md

@@ -90,6 +90,12 @@ front-end assets (HTML/JS/CSS) plus an optional `init.agi` and any backend
   loaded-module list; `RegisterModuleFromAGI` is the hook `init.agi` drives, and
   module visibility is filtered per user by `GetModuleListJSONForUser` (users
   only see modules they have permission for).
+- **Backend script permission:** a request for `<Folder>/…/x.agi` is allowed
+  when the user may access a module named `<Folder>` *or any module registered
+  from that folder* (`ModuleInfo.SourceFolder`, set from the `init.agi` path;
+  `static.CheckUserAccessToScript`). So one folder may register several modules
+  with other names (`Office/` → Docs, Sheets, Slides), and any of them unlocks
+  the folder's shared backend scripts.
 - **Scope reminder:** `init.agi` runs with **system scope** (registration /
   system functions only — don't call user or file functions there); backend
   `.agi` scripts invoked from the front end run with the **invoking user's**

+ 1 - 0
src/agi.go

@@ -63,6 +63,7 @@ func AGIInit() {
 		LoadedModule:          moduleHandler.GetModuleNameList(),
 		ReservedTables:        []string{"auth", "permisson", "register", "desktop"},
 		ModuleRegisterParser:  moduleHandler.RegisterModuleFromAGI,
+		ModuleFolderResolver:  moduleHandler.GetModulesFromSourceFolder,
 		ExtIconRegisterParser: moduleHandler.RegisterExtIcon,
 		ModuleListProvider:    moduleHandler.GetModuleListJSONForUser,
 		PackageManager:        packageManager,

+ 23 - 28
src/mod/agi/agi.go

@@ -68,9 +68,10 @@ type AgiSysInfo struct {
 	UserHandler           *user.UserHandler
 	ReservedTables        []string
 	PackageManager        *apt.AptPackageManager
-	ModuleRegisterParser  func(string) error
-	ModuleListProvider    func(username string) string //Returns JSON of accessible modules for a user
-	ExtIconRegisterParser func(ext, iconPath string)   //Called when registerExtensionIcon() fires in an init.agi
+	ModuleRegisterParser  func(sourceFolder string, jsonModuleConfig string) error //sourceFolder is the web app folder the init.agi lives in, e.g. "web/Office"
+	ModuleFolderResolver  func(folder string) []string                             //Returns the names of the modules registered from a source folder
+	ModuleListProvider    func(username string) string                             //Returns JSON of accessible modules for a user
+	ExtIconRegisterParser func(ext, iconPath string)                               //Called when registerExtensionIcon() fires in an init.agi
 	FileSystemRender      *metadata.RenderHandler
 	IotManager            *iot.Manager
 	ShareManager          *share.Manager
@@ -135,7 +136,7 @@ func (g *Gateway) RegisterNightlyOperations() {
 						continue
 					}
 
-					if static.CheckUserAccessToScript(userinfo, scriptFile, "") {
+					if static.CheckUserAccessToScript(userinfo, filepath.Join("./web", scriptFile), "./web", g.Option.ModuleFolderResolver) {
 						//This user can access the module that provide this script.
 						//Execute this script on his account.
 						logger.PrintAndLog("Agi", "[AGI_Nightly] WIP ("+scriptFile+")", nil)
@@ -152,18 +153,7 @@ func (g *Gateway) RegisterNightlyOperations() {
 func (g *Gateway) InitiateAllWebAppModules() {
 	startupScripts, _ := filepath.Glob(filepath.ToSlash(filepath.Clean(g.Option.StartupRoot)) + "/*/init.agi")
 	for _, script := range startupScripts {
-		scriptContentByte, _ := os.ReadFile(script)
-		scriptContent := string(scriptContentByte)
-		logger.PrintAndLog("Agi", "[AGI] Gateway script loaded ("+script+")", nil)
-		//Create a new vm for this request
-		vm := otto.New()
-
-		//Only allow non user based operations
-		g.injectStandardLibs(vm, script, "./web/")
-		g.injectAppdataLibFunctions(&static.AgiLibInjectionPayload{
-			VM: vm,
-		})
-		_, err := vm.Run(scriptContent)
+		err := g.RunWebAppInitScript(script)
 		if err != nil {
 			logger.PrintAndLog("Agi", "[AGI] Load Failed: "+script+". Skipping.", nil)
 			logger.PrintAndLog("Agi", fmt.Sprint(err), nil)
@@ -172,20 +162,25 @@ func (g *Gateway) InitiateAllWebAppModules() {
 	}
 }
 
-func (g *Gateway) RunScript(script string) error {
-	//Create a new vm for this request
-	vm := otto.New()
-
-	//Only allow non user based operations
-	g.injectStandardLibs(vm, "", "./web/")
-
-	_, err := vm.Run(script)
+// Run a web app's init.agi (e.g. "web/Office/init.agi") with system scope only.
+// The script path is what ties the modules it registers to their source folder.
+func (g *Gateway) RunWebAppInitScript(script string) error {
+	scriptContentByte, err := os.ReadFile(script)
 	if err != nil {
-		logger.PrintAndLog("Agi", fmt.Sprint("[AGI] Script Execution Failed: ", err.Error()), nil)
 		return err
 	}
+	scriptContent := string(scriptContentByte)
+	logger.PrintAndLog("Agi", "[AGI] Gateway script loaded ("+script+")", nil)
+	//Create a new vm for this request
+	vm := otto.New()
 
-	return nil
+	//Only allow non user based operations
+	g.injectStandardLibs(vm, script, "./web/")
+	g.injectAppdataLibFunctions(&static.AgiLibInjectionPayload{
+		VM: vm,
+	})
+	_, err = vm.Run(scriptContent)
+	return err
 }
 
 func (g *Gateway) RaiseError(err error) {
@@ -260,8 +255,8 @@ func (g *Gateway) InterfaceHandler(w http.ResponseWriter, r *http.Request, thisu
 	}
 
 	//Check for user permission on this module
-	moduleName := static.GetScriptRoot(scriptFile, scriptScope)
-	if !thisuser.GetModuleAccessPermission(moduleName) {
+	if !static.CheckUserAccessToScript(thisuser, scriptFile, scriptScope, g.Option.ModuleFolderResolver) {
+		moduleName := static.GetScriptRoot(scriptFile, scriptScope)
 		w.WriteHeader(http.StatusForbidden)
 		if g.Option.BuildVersion == "development" {
 			w.Write([]byte("403 Forbidden: User do not have permission to access " + moduleName))

+ 3 - 1
src/mod/agi/agi.system.go

@@ -279,8 +279,10 @@ func (g *Gateway) injectStandardLibs(vm *otto.Otto, scriptFile string, scriptSco
 
 		//Get the module directory from the script file path
 		//For example: ./web/Label Maker/init.agi -> Label Maker
+		sourceFolder := ""
 		if scriptFile != "" && scriptScope != "" {
 			moduleDir := static.GetScriptRoot(scriptFile, scriptScope)
+			sourceFolder = static.ScriptSourceFolder(scriptFile, scriptScope)
 
 			//Convert relative paths to absolute paths for IconPath, StartDir, and LaunchFWDir
 			pathFields := []string{"IconPath", "StartDir", "LaunchFWDir", "LaunchEmb"}
@@ -307,7 +309,7 @@ func (g *Gateway) injectStandardLibs(vm *otto.Otto, scriptFile string, scriptSco
 		}
 
 		//Try to decode it to a module Info
-		err = g.Option.ModuleRegisterParser(jsonModuleConfig)
+		err = g.Option.ModuleRegisterParser(sourceFolder, jsonModuleConfig)
 		if err != nil {
 			g.RaiseError(err)
 			reply, _ := vm.ToValue(false)

+ 1 - 1
src/mod/agi/handler.go

@@ -44,7 +44,7 @@ func (g *Gateway) HandleAgiExecutionRequestWithToken(w http.ResponseWriter, r *h
 	}
 
 	scriptScope := ""
-	allowAccess := static.CheckUserAccessToScript(targetUser, script, scriptScope)
+	allowAccess := static.CheckUserAccessToScript(targetUser, filepath.Join("./web", script), "./web", g.Option.ModuleFolderResolver)
 	if !allowAccess {
 		w.WriteHeader(http.StatusUnauthorized)
 		w.Write([]byte("401 - Unauthorized (Permission Denied)"))

+ 39 - 5
src/mod/agi/static/static.go

@@ -95,13 +95,47 @@ func RealpathToVirtualpath(fsh *filesystem.FileSystemHandler, path string, u *us
 	return fsh.FileSystemAbstraction.RealPathToVirtualPath(path, u.Username)
 }
 
-// Check if the user can access this script file
-func CheckUserAccessToScript(thisuser *user.User, scriptFile string, scriptScope string) bool {
-	moduleName := GetScriptRoot(scriptFile, scriptScope)
-	if !thisuser.GetModuleAccessPermission(moduleName) {
+// Check if the user can access this script file. Access is granted by a module
+// named after the script's folder, or by any module registered from that folder
+// (e.g. Office/ registers Docs, Sheets and Slides). modulesInFolder returns the
+// names of the modules registered from a source folder and may be nil.
+func CheckUserAccessToScript(thisuser *user.User, scriptFile string, scriptScope string, modulesInFolder func(folder string) []string) bool {
+	return canAccessScriptFolder(thisuser.GetModuleAccessPermission, scriptFile, scriptScope, modulesInFolder)
+}
+
+func canAccessScriptFolder(hasModuleAccess func(moduleName string) bool, scriptFile string, scriptScope string, modulesInFolder func(folder string) []string) bool {
+	folderName := GetScriptRoot(scriptFile, scriptScope)
+	if folderName == "" {
+		//The script is not inside the scope (path escape)
+		return false
+	}
+
+	if hasModuleAccess(folderName) {
+		return true
+	}
+
+	if modulesInFolder == nil {
 		return false
 	}
-	return true
+
+	for _, moduleName := range modulesInFolder(ScriptSourceFolder(scriptFile, scriptScope)) {
+		if hasModuleAccess(moduleName) {
+			return true
+		}
+	}
+	return false
+}
+
+// Return the folder a script belongs to, relative to the startup directory,
+// e.g. ("./web/Office/common/backend/document.agi", "./web/") -> "web/Office".
+// This is the key modules are registered under (ModuleInfo.SourceFolder).
+// Return an empty string if the script is not inside the scope.
+func ScriptSourceFolder(scriptFile string, scriptScope string) string {
+	folderName := GetScriptRoot(scriptFile, scriptScope)
+	if folderName == "" {
+		return ""
+	}
+	return filepath.ToSlash(filepath.Join(scriptScope, folderName))
 }
 
 // validate the given path is a script from webroot

+ 67 - 0
src/mod/agi/static/static_test.go

@@ -71,3 +71,70 @@ func TestRunCleanup_PassesClosureThrough(t *testing.T) {
 		t.Error("the registered closure was not the one passed to RunCleanup")
 	}
 }
+
+// A script is reachable through the module named after its folder or through
+// any module registered from that folder, so apps whose module names differ from
+// their folder (Office -> Docs / Sheets / Slides) still reach their own backend.
+func TestCanAccessScriptFolder(t *testing.T) {
+	officeModules := func(folder string) []string {
+		if folder == "web/Office" {
+			return []string{"Docs", "Sheets", "Slides"}
+		}
+		return nil
+	}
+
+	tests := []struct {
+		name            string
+		allowed         []string
+		scriptFile      string
+		scriptScope     string
+		modulesInFolder func(string) []string
+		want            bool
+	}{
+		{name: "module registered from the folder", allowed: []string{"Slides"}, scriptFile: "web/Office/common/backend/document.agi", scriptScope: "web", modulesInFolder: officeModules, want: true},
+		{name: "scope with leading dot and trailing slash", allowed: []string{"Docs"}, scriptFile: "./web/Office/common/backend/document.agi", scriptScope: "./web/", modulesInFolder: officeModules, want: true},
+		{name: "module named after the folder", allowed: []string{"Photo"}, scriptFile: "web/Photo/backend/list.agi", scriptScope: "web", modulesInFolder: officeModules, want: true},
+		{name: "no module of the folder allowed", allowed: []string{"Photo"}, scriptFile: "web/Office/common/backend/document.agi", scriptScope: "web", modulesInFolder: officeModules, want: false},
+		{name: "modules of another folder do not count", allowed: []string{"Slides"}, scriptFile: "web/Mail/backend/send.agi", scriptScope: "web", modulesInFolder: officeModules, want: false},
+		{name: "same folder name in another scope", allowed: []string{"Slides"}, scriptFile: "subservice/Office/run.agi", scriptScope: "subservice", modulesInFolder: officeModules, want: false},
+		{name: "nil resolver falls back to folder name", allowed: []string{"Slides"}, scriptFile: "web/Office/common/backend/document.agi", scriptScope: "web", modulesInFolder: nil, want: false},
+		{name: "script outside the scope", allowed: []string{""}, scriptFile: "system/ao.agi", scriptScope: "web", modulesInFolder: officeModules, want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			hasModuleAccess := func(moduleName string) bool {
+				for _, allowed := range tt.allowed {
+					if allowed == moduleName {
+						return true
+					}
+				}
+				return false
+			}
+
+			got := canAccessScriptFolder(hasModuleAccess, tt.scriptFile, tt.scriptScope, tt.modulesInFolder)
+			if got != tt.want {
+				t.Errorf("canAccessScriptFolder(%q, %q) = %v, want %v", tt.scriptFile, tt.scriptScope, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestScriptSourceFolder(t *testing.T) {
+	tests := []struct {
+		scriptFile  string
+		scriptScope string
+		want        string
+	}{
+		{scriptFile: "web/Office/init.agi", scriptScope: "./web/", want: "web/Office"},
+		{scriptFile: "./web/Label Maker/backend/print.agi", scriptScope: "web", want: "web/Label Maker"},
+		{scriptFile: "subservice/demo/run.agi", scriptScope: "subservice", want: "subservice/demo"},
+		{scriptFile: "system/ao.agi", scriptScope: "web", want: ""},
+	}
+
+	for _, tt := range tests {
+		if got := ScriptSourceFolder(tt.scriptFile, tt.scriptScope); got != tt.want {
+			t.Errorf("ScriptSourceFolder(%q, %q) = %q, want %q", tt.scriptFile, tt.scriptScope, got, tt.want)
+		}
+	}
+}

+ 3 - 9
src/mod/modules/installer.go

@@ -157,16 +157,10 @@ func (m *ModuleHandler) ActivateModuleByRoot(moduleFolder string, gateway *agi.G
 	thisModuleEstimataedRoot := filepath.Join("./web/", filepath.Base(moduleFolder))
 	if utils.FileExists(thisModuleEstimataedRoot) {
 		if utils.FileExists(filepath.Join(thisModuleEstimataedRoot, "init.agi")) {
-			//Load this as an module
-			startDef, err := os.ReadFile(filepath.Join(thisModuleEstimataedRoot, "init.agi"))
-			if err != nil {
-				logger.PrintAndLog("Modules", "*Module Activator* Failed to read init.agi from "+filepath.Base(moduleFolder), nil)
-				return errors.New("Failed to read init.agi from " + filepath.Base(moduleFolder))
-			}
-
-			//Execute the init script using AGI
+			//Load this as an module. Run it by path so the registered modules
+			//are tied to this folder for AGI script permission checks
 			logger.PrintAndLog("Modules", fmt.Sprint("Starting module: ", filepath.Base(moduleFolder)), nil)
-			err = gateway.RunScript(string(startDef))
+			err := gateway.RunWebAppInitScript(filepath.ToSlash(filepath.Join(thisModuleEstimataedRoot, "init.agi")))
 			if err != nil {
 				logger.PrintAndLog("Modules", "*Module Activator* "+filepath.Base(moduleFolder)+" Starting failed"+err.Error(), nil)
 				return errors.New(filepath.Base(moduleFolder) + " Starting failed: " + err.Error())

+ 28 - 2
src/mod/modules/module.go

@@ -3,6 +3,7 @@ package modules
 import (
 	"encoding/json"
 	"net/http"
+	"path/filepath"
 	"sort"
 	"strings"
 
@@ -25,6 +26,12 @@ type ModuleInfo struct {
 	InitEmbSize  []int    //Embedded mode init size. [0] => Width, [1] => Height
 	SupportedExt []string //Supported File Extensions. e.g. ".mp3", ".flac", ".wav"
 
+	//Folder this module was registered from, relative to the startup directory,
+	//e.g. "web/Office" or "subservice/demo". AGI scripts in this folder are
+	//accessible to users with permission to this module. Never read from or
+	//written to JSON, so a module cannot claim another app's folder.
+	SourceFolder string `json:"-"`
+
 	//Hidden properties
 	allowReload bool //Allow module reload by user
 }
@@ -94,8 +101,9 @@ func (m *ModuleHandler) RegisterModuleFromJSON(jsonstring string, allowReload bo
 	return nil
 }
 
-// Register a module from AGI script
-func (m *ModuleHandler) RegisterModuleFromAGI(jsonstring string) error {
+// Register a module from AGI script. sourceFolder is the folder of the init.agi
+// that registered it (e.g. "web/Office"), empty if unknown.
+func (m *ModuleHandler) RegisterModuleFromAGI(sourceFolder string, jsonstring string) error {
 	var thisModuleInfo ModuleInfo
 	err := json.Unmarshal([]byte(jsonstring), &thisModuleInfo)
 	if err != nil {
@@ -104,6 +112,7 @@ func (m *ModuleHandler) RegisterModuleFromAGI(jsonstring string) error {
 
 	//AGI interface loaded module must allow runtime reload
 	thisModuleInfo.allowReload = true
+	thisModuleInfo.SourceFolder = sourceFolder
 	m.RegisterModule(thisModuleInfo)
 	return nil
 }
@@ -119,6 +128,23 @@ func (m *ModuleHandler) DeregisterModule(moduleName string) {
 	m.LoadedModule = newLoadedModuleList
 }
 
+// Get the names of the modules registered from the given source folder,
+// e.g. "web/Office" -> Docs, Sheets, Slides. The folder is matched ignoring
+// case, like module names in permission checks.
+func (m *ModuleHandler) GetModulesFromSourceFolder(folder string) []string {
+	result := []string{}
+	if folder == "" {
+		return result
+	}
+	folder = filepath.ToSlash(filepath.Clean(folder))
+	for _, module := range m.LoadedModule {
+		if module.SourceFolder != "" && strings.EqualFold(module.SourceFolder, folder) {
+			result = append(result, module.Name)
+		}
+	}
+	return result
+}
+
 // Get a list of module names
 func (m *ModuleHandler) GetModuleNameList() []string {
 	result := []string{}

+ 62 - 0
src/mod/modules/modules_test.go

@@ -276,3 +276,65 @@ func TestOnModuleUninstall_Hook(t *testing.T) {
 	}
 	_ = called // hook may not be invoked by DeregisterModule
 }
+
+// Modules registered from an init.agi remember their folder, so the AGI gateway
+// can let users of any of them run the folder's backend scripts.
+func TestGetModulesFromSourceFolder(t *testing.T) {
+	mh := &ModuleHandler{LoadedModule: []*ModuleInfo{}}
+	for _, reg := range []struct{ folder, name string }{
+		{"web/Office", "Docs"},
+		{"web/Office", "Sheets"},
+		{"web/Office", "Slides"},
+		{"web/Photo", "Photo"},
+		{"", "Unknown Origin"},
+	} {
+		if err := mh.RegisterModuleFromAGI(reg.folder, `{"Name":"`+reg.name+`","Group":"Office"}`); err != nil {
+			t.Fatalf("RegisterModuleFromAGI(%q) unexpected error: %v", reg.name, err)
+		}
+	}
+
+	tests := []struct {
+		name   string
+		folder string
+		want   []string
+	}{
+		{name: "all modules of a shared folder", folder: "web/Office", want: []string{"Docs", "Sheets", "Slides"}},
+		{name: "folder matched ignoring case", folder: "web/office", want: []string{"Docs", "Sheets", "Slides"}},
+		{name: "unclean path", folder: "./web/Photo/", want: []string{"Photo"}},
+		{name: "unknown folder", folder: "web/Mail", want: []string{}},
+		{name: "empty folder never matches unknown-origin modules", folder: "", want: []string{}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := mh.GetModulesFromSourceFolder(tt.folder)
+			if strings.Join(got, ",") != strings.Join(tt.want, ",") {
+				t.Errorf("GetModulesFromSourceFolder(%q) = %v, want %v", tt.folder, got, tt.want)
+			}
+		})
+	}
+
+	mh.DeregisterModule("Slides")
+	if got := mh.GetModulesFromSourceFolder("web/Office"); strings.Join(got, ",") != "Docs,Sheets" {
+		t.Errorf("after DeregisterModule(Slides) got %v, want [Docs Sheets]", got)
+	}
+}
+
+// SourceFolder is server-side only: a module's JSON can neither set nor expose it.
+func TestSourceFolderNotInJSON(t *testing.T) {
+	mh := &ModuleHandler{LoadedModule: []*ModuleInfo{}}
+	if err := mh.RegisterModuleFromAGI("web/Notes", `{"Name":"Notes","SourceFolder":"web/Office"}`); err != nil {
+		t.Fatalf("RegisterModuleFromAGI unexpected error: %v", err)
+	}
+	if got := mh.LoadedModule[0].SourceFolder; got != "web/Notes" {
+		t.Errorf("SourceFolder = %q, want %q", got, "web/Notes")
+	}
+
+	js, err := json.Marshal(mh.LoadedModule[0])
+	if err != nil {
+		t.Fatalf("json.Marshal unexpected error: %v", err)
+	}
+	if strings.Contains(string(js), "SourceFolder") {
+		t.Errorf("SourceFolder leaked into module JSON: %s", js)
+	}
+}

+ 1 - 0
src/mod/subservice/subservice.go

@@ -314,6 +314,7 @@ func (sr *SubServiceRouter) Launch(servicePath string, startupMode bool) error {
 	sr.RunningSubService = append(sr.RunningSubService, thisSubService)
 
 	//Append this module into the loaded module list
+	thisModuleInfo.SourceFolder = filepath.ToSlash(filepath.Clean(servicePath))
 	sr.moduleHandler.LoadedModule = append(sr.moduleHandler.LoadedModule, &thisModuleInfo)
 
 	return nil