package main // Custom mini-apps: serve app modules that are NOT compiled into the binary. // Two sources feed the same URL space: // - the DB (custom_apps): modules installed from the admin console — the // authoritative store; // - LCHAT_APPS_DIR: an operator-pointed directory of *.js files — a dev // tool for iterating on an app without touching the console. // Each app is served at /static/apps-custom/.js; the client discovers // them via the /api/apps manifest at boot and dynamic-imports each one (see // shell.js). On a name collision the DB wins — what the admin installed is // what members run; the disk file is shadowed until the DB copy is deleted. // // The URL sits exactly ONE path segment below /static/ on purpose: a custom // app's relative `import { defineApp } from "../apps.js"` then resolves to // /static/apps.js, so a custom app is byte-for-byte the same shape as a // compiled-in app — same imports, same framework, no build step. // // Trust model: app code is CODE the admin chose to ship, not user content — // the compiled-in apps are served in the clear the same way, so this adds no // new exposure class. The manifest and the modules are served ungated, like // the static shell itself: they reveal only app names, comparable to the // plaintext instance branding (group name / server avatar). Installing and // deleting require the admin token, exactly like the other admin routes. import ( "encoding/json" "net/http" "os" "path/filepath" "regexp" "sort" "strings" ) // customAppName accepts only lowercase letters, digits, and dashes — the app // id an admin installs under. customAppFile is the same rule with the .js // suffix, for URL path segments and LCHAT_APPS_DIR entries. Anything else // 404s, which is also the path-traversal guard: a name that matches can't // escape the directory. var ( customAppName = regexp.MustCompile(`^[a-z0-9-]+$`) customAppFile = regexp.MustCompile(`^[a-z0-9-]+\.js$`) ) // maxCustomAppBytes caps one app's source. Mini-apps are single-file ES // modules — the largest compiled-in one is ~100 KB — so 1 MB is generous // while keeping the admin endpoint from being abused as bulk storage. const maxCustomAppBytes = 1 << 20 // customAppNames lists the available app names (without ".js"), sorted and // deduped: DB-installed apps plus LCHAT_APPS_DIR files. An unset dir or an // unreadable one is just "no sideloaded apps". func (s *Server) customAppNames() []string { seen := map[string]bool{} if apps, err := s.db.listCustomApps(); err == nil { for _, a := range apps { seen[a.Name] = true } } if s.appsDir != "" { if entries, err := os.ReadDir(s.appsDir); err == nil { for _, e := range entries { if e.Type().IsRegular() && customAppFile.MatchString(e.Name()) { seen[strings.TrimSuffix(e.Name(), ".js")] = true } } } } names := []string{} for name := range seen { names = append(names, name) } sort.Strings(names) return names } // getCustomApps serves the /api/apps manifest the client loads at boot. Always // a list (empty when nothing is installed), never an error — a client must be // able to treat "no custom apps" and "feature unused" identically. func (s *Server) getCustomApps(w http.ResponseWriter, r *http.Request) { apps := []map[string]string{} for _, name := range s.customAppNames() { apps = append(apps, map[string]string{ "name": name, "url": "/static/apps-custom/" + name + ".js", }) } writeJSON(w, http.StatusOK, map[string]any{"apps": apps}) } // serveCustomApp serves one custom module: the DB copy when installed (the // admin console is authoritative), else the LCHAT_APPS_DIR file. The name is // validated against the same strict pattern the manifest uses — everything // else 404s, so the handler can never read outside appsDir. func (s *Server) serveCustomApp(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !customAppFile.MatchString(name) { http.NotFound(w, r) return } src, ok, err := s.db.getCustomApp(strings.TrimSuffix(name, ".js")) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !ok { if s.appsDir == "" { http.NotFound(w, r) return } b, err := os.ReadFile(filepath.Join(s.appsDir, name)) if err != nil { http.NotFound(w, r) return } src = string(b) } w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Write([]byte(src)) } // broadcastAppsChanged tells every connected client the installed app set // changed (members:nil → everyone). Modules are dynamic-imported once at boot, // so clients respond by reloading — see the apps_changed handler in shell.js. // kind is "created" / "updated" / "removed", letting the client pick a more // specific reload prompt than a blanket "New app available". func (s *Server) broadcastAppsChanged(kind string) { out, _ := json.Marshal(map[string]any{"type": "apps_changed", "kind": kind}) s.hub.broadcast <- outbound{data: out} } // ---- admin API (requireAdmin, like every other admin route) -------------- // listCustomAppsHandler returns the apps' metadata for the admin console — // name, version, size, updated. DB-installed apps are the manageable ones; // LCHAT_APPS_DIR sideloads are listed too (marked on_disk, so the console // shows what members actually get) but read-only: the console can't delete a // file. A disk file shadowed by a same-named DB copy is omitted — the DB copy // is what serves. func (s *Server) listCustomAppsHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } apps, err := s.db.listCustomApps() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } inDB := map[string]bool{} for _, a := range apps { inDB[a.Name] = true } if s.appsDir != "" { if entries, err := os.ReadDir(s.appsDir); err == nil { for _, e := range entries { if !e.Type().IsRegular() || !customAppFile.MatchString(e.Name()) { continue } name := strings.TrimSuffix(e.Name(), ".js") if inDB[name] { continue } info, err := e.Info() if err != nil { continue } apps = append(apps, CustomApp{Name: name, Size: int(info.Size()), UpdatedAt: info.ModTime().Unix(), OnDisk: true}) } } } sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name }) writeJSON(w, http.StatusOK, map[string]any{"apps": apps}) } // putCustomAppHandler installs or updates an app from raw JS source. func (s *Server) putCustomAppHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } name := r.PathValue("name") if !customAppName.MatchString(name) { httpError(w, http.StatusBadRequest, "app name must be lowercase letters, digits, and dashes") return } var body struct { Source string `json:"source"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCustomAppBytes)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } if strings.TrimSpace(body.Source) == "" { httpError(w, http.StatusBadRequest, "missing source") return } _, existed, err := s.db.getCustomApp(name) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if err := s.db.putCustomApp(name, body.Source); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } kind := "created" if existed { kind = "updated" } s.broadcastAppsChanged(kind) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": "/static/apps-custom/" + name + ".js"}) } // deleteCustomAppHandler uninstalls an app. func (s *Server) deleteCustomAppHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } name := r.PathValue("name") done, err := s.db.deleteCustomApp(name) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !done { httpError(w, http.StatusNotFound, "no such app") return } s.broadcastAppsChanged("removed") writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }