eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package 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
26import (
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.
41var (
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.
49const 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".
54func (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.
81func (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.
96func (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".
128func (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.
141func (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.
177func (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.
215func (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