| 1 | package main |
| 2 | |
| 3 | // Custom mini-apps: serve app modules that are NOT compiled into the binary. |
| 4 | // Two sources feed the same URL space: |
| 5 | // - the DB (custom_apps): modules installed from the admin console — the |
| 6 | // authoritative store; |
| 7 | // - LCHAT_APPS_DIR: an operator-pointed directory of *.js files — a dev |
| 8 | // tool for iterating on an app without touching the console. |
| 9 | // Each app is served at /static/apps-custom/<name>.js; the client discovers |
| 10 | // them via the /api/apps manifest at boot and dynamic-imports each one (see |
| 11 | // shell.js). On a name collision the DB wins — what the admin installed is |
| 12 | // what members run; the disk file is shadowed until the DB copy is deleted. |
| 13 | // |
| 14 | // The URL sits exactly ONE path segment below /static/ on purpose: a custom |
| 15 | // app's relative `import { defineApp } from "../apps.js"` then resolves to |
| 16 | // /static/apps.js, so a custom app is byte-for-byte the same shape as a |
| 17 | // compiled-in app — same imports, same framework, no build step. |
| 18 | // |
| 19 | // Trust model: app code is CODE the admin chose to ship, not user content — |
| 20 | // the compiled-in apps are served in the clear the same way, so this adds no |
| 21 | // new exposure class. The manifest and the modules are served ungated, like |
| 22 | // the static shell itself: they reveal only app names, comparable to the |
| 23 | // plaintext instance branding (group name / server avatar). Installing and |
| 24 | // deleting require the admin token, exactly like the other admin routes. |
| 25 | |
| 26 | import ( |
| 27 | "encoding/json" |
| 28 | "net/http" |
| 29 | "os" |
| 30 | "path/filepath" |
| 31 | "regexp" |
| 32 | "sort" |
| 33 | "strings" |
| 34 | ) |
| 35 | |
| 36 | // customAppName accepts only lowercase letters, digits, and dashes — the app |
| 37 | // id an admin installs under. customAppFile is the same rule with the .js |
| 38 | // suffix, for URL path segments and LCHAT_APPS_DIR entries. Anything else |
| 39 | // 404s, which is also the path-traversal guard: a name that matches can't |
| 40 | // escape the directory. |
| 41 | var ( |
| 42 | customAppName = regexp.MustCompile(`^[a-z0-9-]+$`) |
| 43 | customAppFile = regexp.MustCompile(`^[a-z0-9-]+\.js$`) |
| 44 | ) |
| 45 | |
| 46 | // maxCustomAppBytes caps one app's source. Mini-apps are single-file ES |
| 47 | // modules — the largest compiled-in one is ~100 KB — so 1 MB is generous |
| 48 | // while keeping the admin endpoint from being abused as bulk storage. |
| 49 | const maxCustomAppBytes = 1 << 20 |
| 50 | |
| 51 | // customAppNames lists the available app names (without ".js"), sorted and |
| 52 | // deduped: DB-installed apps plus LCHAT_APPS_DIR files. An unset dir or an |
| 53 | // unreadable one is just "no sideloaded apps". |
| 54 | func (s *Server) customAppNames() []string { |
| 55 | seen := map[string]bool{} |
| 56 | if apps, err := s.db.listCustomApps(); err == nil { |
| 57 | for _, a := range apps { |
| 58 | seen[a.Name] = true |
| 59 | } |
| 60 | } |
| 61 | if s.appsDir != "" { |
| 62 | if entries, err := os.ReadDir(s.appsDir); err == nil { |
| 63 | for _, e := range entries { |
| 64 | if e.Type().IsRegular() && customAppFile.MatchString(e.Name()) { |
| 65 | seen[strings.TrimSuffix(e.Name(), ".js")] = true |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | names := []string{} |
| 71 | for name := range seen { |
| 72 | names = append(names, name) |
| 73 | } |
| 74 | sort.Strings(names) |
| 75 | return names |
| 76 | } |
| 77 | |
| 78 | // getCustomApps serves the /api/apps manifest the client loads at boot. Always |
| 79 | // a list (empty when nothing is installed), never an error — a client must be |
| 80 | // able to treat "no custom apps" and "feature unused" identically. |
| 81 | func (s *Server) getCustomApps(w http.ResponseWriter, r *http.Request) { |
| 82 | apps := []map[string]string{} |
| 83 | for _, name := range s.customAppNames() { |
| 84 | apps = append(apps, map[string]string{ |
| 85 | "name": name, |
| 86 | "url": "/static/apps-custom/" + name + ".js", |
| 87 | }) |
| 88 | } |
| 89 | writeJSON(w, http.StatusOK, map[string]any{"apps": apps}) |
| 90 | } |
| 91 | |
| 92 | // serveCustomApp serves one custom module: the DB copy when installed (the |
| 93 | // admin console is authoritative), else the LCHAT_APPS_DIR file. The name is |
| 94 | // validated against the same strict pattern the manifest uses — everything |
| 95 | // else 404s, so the handler can never read outside appsDir. |
| 96 | func (s *Server) serveCustomApp(w http.ResponseWriter, r *http.Request) { |
| 97 | name := r.PathValue("name") |
| 98 | if !customAppFile.MatchString(name) { |
| 99 | http.NotFound(w, r) |
| 100 | return |
| 101 | } |
| 102 | src, ok, err := s.db.getCustomApp(strings.TrimSuffix(name, ".js")) |
| 103 | if err != nil { |
| 104 | httpError(w, http.StatusInternalServerError, "db error") |
| 105 | return |
| 106 | } |
| 107 | if !ok { |
| 108 | if s.appsDir == "" { |
| 109 | http.NotFound(w, r) |
| 110 | return |
| 111 | } |
| 112 | b, err := os.ReadFile(filepath.Join(s.appsDir, name)) |
| 113 | if err != nil { |
| 114 | http.NotFound(w, r) |
| 115 | return |
| 116 | } |
| 117 | src = string(b) |
| 118 | } |
| 119 | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") |
| 120 | w.Write([]byte(src)) |
| 121 | } |
| 122 | |
| 123 | // broadcastAppsChanged tells every connected client the installed app set |
| 124 | // changed (members:nil → everyone). Modules are dynamic-imported once at boot, |
| 125 | // so clients respond by reloading — see the apps_changed handler in shell.js. |
| 126 | // kind is "created" / "updated" / "removed", letting the client pick a more |
| 127 | // specific reload prompt than a blanket "New app available". |
| 128 | func (s *Server) broadcastAppsChanged(kind string) { |
| 129 | out, _ := json.Marshal(map[string]any{"type": "apps_changed", "kind": kind}) |
| 130 | s.hub.broadcast <- outbound{data: out} |
| 131 | } |
| 132 | |
| 133 | // ---- admin API (requireAdmin, like every other admin route) -------------- |
| 134 | |
| 135 | // listCustomAppsHandler returns the apps' metadata for the admin console — |
| 136 | // name, version, size, updated. DB-installed apps are the manageable ones; |
| 137 | // LCHAT_APPS_DIR sideloads are listed too (marked on_disk, so the console |
| 138 | // shows what members actually get) but read-only: the console can't delete a |
| 139 | // file. A disk file shadowed by a same-named DB copy is omitted — the DB copy |
| 140 | // is what serves. |
| 141 | func (s *Server) listCustomAppsHandler(w http.ResponseWriter, r *http.Request) { |
| 142 | if !s.requireAdmin(w, r) { |
| 143 | return |
| 144 | } |
| 145 | apps, err := s.db.listCustomApps() |
| 146 | if err != nil { |
| 147 | httpError(w, http.StatusInternalServerError, "db error") |
| 148 | return |
| 149 | } |
| 150 | inDB := map[string]bool{} |
| 151 | for _, a := range apps { |
| 152 | inDB[a.Name] = true |
| 153 | } |
| 154 | if s.appsDir != "" { |
| 155 | if entries, err := os.ReadDir(s.appsDir); err == nil { |
| 156 | for _, e := range entries { |
| 157 | if !e.Type().IsRegular() || !customAppFile.MatchString(e.Name()) { |
| 158 | continue |
| 159 | } |
| 160 | name := strings.TrimSuffix(e.Name(), ".js") |
| 161 | if inDB[name] { |
| 162 | continue |
| 163 | } |
| 164 | info, err := e.Info() |
| 165 | if err != nil { |
| 166 | continue |
| 167 | } |
| 168 | apps = append(apps, CustomApp{Name: name, Size: int(info.Size()), UpdatedAt: info.ModTime().Unix(), OnDisk: true}) |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name }) |
| 173 | writeJSON(w, http.StatusOK, map[string]any{"apps": apps}) |
| 174 | } |
| 175 | |
| 176 | // putCustomAppHandler installs or updates an app from raw JS source. |
| 177 | func (s *Server) putCustomAppHandler(w http.ResponseWriter, r *http.Request) { |
| 178 | if !s.requireAdmin(w, r) { |
| 179 | return |
| 180 | } |
| 181 | name := r.PathValue("name") |
| 182 | if !customAppName.MatchString(name) { |
| 183 | httpError(w, http.StatusBadRequest, "app name must be lowercase letters, digits, and dashes") |
| 184 | return |
| 185 | } |
| 186 | var body struct { |
| 187 | Source string `json:"source"` |
| 188 | } |
| 189 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCustomAppBytes)).Decode(&body) != nil { |
| 190 | httpError(w, http.StatusBadRequest, "bad request") |
| 191 | return |
| 192 | } |
| 193 | if strings.TrimSpace(body.Source) == "" { |
| 194 | httpError(w, http.StatusBadRequest, "missing source") |
| 195 | return |
| 196 | } |
| 197 | _, existed, err := s.db.getCustomApp(name) |
| 198 | if err != nil { |
| 199 | httpError(w, http.StatusInternalServerError, "db error") |
| 200 | return |
| 201 | } |
| 202 | if err := s.db.putCustomApp(name, body.Source); err != nil { |
| 203 | httpError(w, http.StatusInternalServerError, "db error") |
| 204 | return |
| 205 | } |
| 206 | kind := "created" |
| 207 | if existed { |
| 208 | kind = "updated" |
| 209 | } |
| 210 | s.broadcastAppsChanged(kind) |
| 211 | writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": "/static/apps-custom/" + name + ".js"}) |
| 212 | } |
| 213 | |
| 214 | // deleteCustomAppHandler uninstalls an app. |
| 215 | func (s *Server) deleteCustomAppHandler(w http.ResponseWriter, r *http.Request) { |
| 216 | if !s.requireAdmin(w, r) { |
| 217 | return |
| 218 | } |
| 219 | name := r.PathValue("name") |
| 220 | done, err := s.db.deleteCustomApp(name) |
| 221 | if err != nil { |
| 222 | httpError(w, http.StatusInternalServerError, "db error") |
| 223 | return |
| 224 | } |
| 225 | if !done { |
| 226 | httpError(w, http.StatusNotFound, "no such app") |
| 227 | return |
| 228 | } |
| 229 | s.broadcastAppsChanged("removed") |
| 230 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 231 | } |
| 232 | |