| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "archive/zip" |
| 5 | "bytes" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "fmt" |
| 9 | "mime" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/config" |
| 17 | "reasonix/internal/pluginpkg" |
| 18 | ) |
| 19 | |
| 20 | // Plugin themes are .reasonix-theme packs contributed by ENABLED installed |
| 21 | // plugins (Manifest v1 contributes.themes globs). They are read-only: they are |
| 22 | // never copied into the user theme library, never staged, and never mutated — |
| 23 | // every read goes straight to the ZIP inside the plugin root. The external id |
| 24 | // is plugin:<pluginName>:<themeID>; the pack manifest's own id continues to |
| 25 | // obey themePackIDRe and the plugin: prefix is added/stripped only at this |
| 26 | // seam. |
| 27 | |
| 28 | const ( |
| 29 | themeKindPlugin = "plugin" |
| 30 | pluginThemeIDPrefix = "plugin:" |
| 31 | ) |
| 32 | |
| 33 | // isPluginThemeID reports whether an external theme id carries the plugin |
| 34 | // prefix. It is intentionally prefix-only (cheap, allocation-free) so the |
| 35 | // fallback contract can recognize plugin pointers even when the remainder is |
| 36 | // malformed; parsePluginThemeID does the strict validation. |
| 37 | func isPluginThemeID(id string) bool { |
| 38 | return strings.HasPrefix(strings.TrimSpace(id), pluginThemeIDPrefix) |
| 39 | } |
| 40 | |
| 41 | // parsePluginThemeID splits plugin:<pluginName>:<themeID>. Plugin names cannot |
| 42 | // contain ":" (pluginpkg.validName) and theme ids cannot either |
| 43 | // (themePackIDRe), so a single Cut on the first colon is unambiguous. |
| 44 | func parsePluginThemeID(id string) (pluginName, themeID string, ok bool) { |
| 45 | rest, found := strings.CutPrefix(strings.TrimSpace(id), pluginThemeIDPrefix) |
| 46 | if !found { |
| 47 | return "", "", false |
| 48 | } |
| 49 | pluginName, themeID, found = strings.Cut(rest, ":") |
| 50 | if !found { |
| 51 | return "", "", false |
| 52 | } |
| 53 | if !pluginpkg.IsValidName(pluginName) || !themePackIDRe.MatchString(themeID) { |
| 54 | return "", "", false |
| 55 | } |
| 56 | return pluginName, themeID, true |
| 57 | } |
| 58 | |
| 59 | // pluginTheme is one validated theme pack discovered inside an enabled plugin. |
| 60 | type pluginTheme struct { |
| 61 | id string // plugin:<pluginName>:<themeID> |
| 62 | pluginName string // installed plugin name (for view badging) |
| 63 | themeID string // the pack manifest's own id |
| 64 | path string // absolute path of the .reasonix-theme ZIP |
| 65 | manifest *ThemePackManifest |
| 66 | digests map[string]string // lowercase scene image name -> content digest |
| 67 | warnings []string // non-fatal discovery issues of the same plugin |
| 68 | } |
| 69 | |
| 70 | // discoverPluginThemes resolves the contributes.themes globs of every ENABLED |
| 71 | // installed plugin and validates each matched pack with the same schema v2 |
| 72 | // validator and ZIP container rules the user-theme import path uses. Invalid |
| 73 | // files are skipped and reported through the returned warnings — never fatal. |
| 74 | // Disabled plugins are skipped by pluginpkg.LoadInstalled; uninstalling a |
| 75 | // plugin simply makes its themes disappear from the result. |
| 76 | func discoverPluginThemes() ([]pluginTheme, []string) { |
| 77 | pkgs, warnings := pluginpkg.LoadInstalled(config.ReasonixHomeDir()) |
| 78 | var out []pluginTheme |
| 79 | seen := map[string]bool{} |
| 80 | for i := range pkgs { |
| 81 | pluginName := pkgs[i].Installed.Name |
| 82 | pluginWarningsStart := len(warnings) |
| 83 | // Parse-level theme issues (missing paths, unmatched globs) computed by |
| 84 | // pluginpkg ride along too; other capability warnings stay with the |
| 85 | // plugin views that already show them. |
| 86 | for _, w := range pkgs[i].Warnings { |
| 87 | if strings.HasPrefix(w, "theme") { |
| 88 | warnings = append(warnings, fmt.Sprintf("plugin %s: %s", pluginName, w)) |
| 89 | } |
| 90 | } |
| 91 | var mine []pluginTheme |
| 92 | for _, ref := range pkgs[i].Package.Inventory().Themes { |
| 93 | m, images, err := loadPluginThemeZip(ref.Path) |
| 94 | if err != nil { |
| 95 | warnings = append(warnings, fmt.Sprintf("plugin %s: theme %s skipped: %v", pluginName, filepath.Base(ref.Path), err)) |
| 96 | continue |
| 97 | } |
| 98 | id := pluginThemeIDPrefix + pluginName + ":" + m.ID |
| 99 | if seen[id] { |
| 100 | warnings = append(warnings, fmt.Sprintf("plugin %s: theme %s skipped: duplicate theme id %q", pluginName, filepath.Base(ref.Path), m.ID)) |
| 101 | continue |
| 102 | } |
| 103 | seen[id] = true |
| 104 | digests := make(map[string]string, len(images)) |
| 105 | for key, data := range images { |
| 106 | digests[key] = themeDataDigest(data) |
| 107 | } |
| 108 | mine = append(mine, pluginTheme{ |
| 109 | id: id, |
| 110 | pluginName: pluginName, |
| 111 | themeID: m.ID, |
| 112 | path: ref.Path, |
| 113 | manifest: m, |
| 114 | digests: digests, |
| 115 | }) |
| 116 | } |
| 117 | // Surface the plugin's skipped-file warnings on each of its surviving |
| 118 | // views (the same per-item pattern PluginView.Warnings uses). |
| 119 | for j := range mine { |
| 120 | mine[j].warnings = append([]string(nil), warnings[pluginWarningsStart:]...) |
| 121 | } |
| 122 | out = append(out, mine...) |
| 123 | } |
| 124 | return out, warnings |
| 125 | } |
| 126 | |
| 127 | // findPluginTheme resolves one enabled plugin's contributed theme, or nil when |
| 128 | // the plugin is missing, disabled, uninstalled, or the file became invalid. |
| 129 | func findPluginTheme(pluginName, themeID string) *pluginTheme { |
| 130 | themes, _ := discoverPluginThemes() |
| 131 | for i := range themes { |
| 132 | if themes[i].pluginName == pluginName && themes[i].themeID == themeID { |
| 133 | return &themes[i] |
| 134 | } |
| 135 | } |
| 136 | return nil |
| 137 | } |
| 138 | |
| 139 | // loadPluginThemeZip opens a contributed theme pack read-only and returns the |
| 140 | // validated manifest plus the raw bytes of its declared scene images. The ZIP |
| 141 | // container rules are the shared ones from scanThemeZipEntries; nothing is |
| 142 | // extracted to disk. |
| 143 | func loadPluginThemeZip(path string) (*ThemePackManifest, map[string][]byte, error) { |
| 144 | info, err := os.Lstat(path) |
| 145 | if err != nil { |
| 146 | return nil, nil, err |
| 147 | } |
| 148 | if info.Mode()&os.ModeSymlink != 0 { |
| 149 | return nil, nil, fmt.Errorf("theme package must not be a symlink") |
| 150 | } |
| 151 | if !info.Mode().IsRegular() { |
| 152 | return nil, nil, fmt.Errorf("theme package must be a regular file") |
| 153 | } |
| 154 | if info.Size() > themePackMaxZipBytes { |
| 155 | return nil, nil, fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes) |
| 156 | } |
| 157 | |
| 158 | f, err := os.Open(path) |
| 159 | if err != nil { |
| 160 | return nil, nil, err |
| 161 | } |
| 162 | defer f.Close() |
| 163 | |
| 164 | // Re-check size after open (TOCTOU). |
| 165 | fi, err := f.Stat() |
| 166 | if err != nil { |
| 167 | return nil, nil, err |
| 168 | } |
| 169 | if fi.Size() > themePackMaxZipBytes { |
| 170 | return nil, nil, fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes) |
| 171 | } |
| 172 | |
| 173 | zr, err := zip.NewReader(f, fi.Size()) |
| 174 | if err != nil { |
| 175 | return nil, nil, fmt.Errorf("invalid theme ZIP: %w", err) |
| 176 | } |
| 177 | manifestEntry, imageEntries, err := scanThemeZipEntries(zr) |
| 178 | if err != nil { |
| 179 | return nil, nil, err |
| 180 | } |
| 181 | raw, err := readZipFileLimited(manifestEntry, themePackMaxManifest) |
| 182 | if err != nil { |
| 183 | return nil, nil, err |
| 184 | } |
| 185 | m, err := parseThemePackManifest(raw) |
| 186 | if err != nil { |
| 187 | return nil, nil, err |
| 188 | } |
| 189 | if err := checkThemeZipImages(m, imageEntries); err != nil { |
| 190 | return nil, nil, err |
| 191 | } |
| 192 | images := make(map[string][]byte, len(imageEntries)) |
| 193 | for key, zf := range imageEntries { |
| 194 | data, err := readZipFileLimited(zf, themePackMaxImageBytes) |
| 195 | if err != nil { |
| 196 | return nil, nil, err |
| 197 | } |
| 198 | images[key] = data |
| 199 | } |
| 200 | return m, images, nil |
| 201 | } |
| 202 | |
| 203 | // pluginThemeView renders the frontend-safe view of a plugin theme. Kind is |
| 204 | // "plugin", the plugin name rides along for badging, and the pack is marked |
| 205 | // read-only (Builtin=false, no save/delete/rename paths accept the id). |
| 206 | func pluginThemeView(pt pluginTheme, active bool) ThemePackView { |
| 207 | m := pt.manifest |
| 208 | bgURL := "" |
| 209 | if m.Background != nil { |
| 210 | bgURL = pluginThemeBackgroundURL(pt, m.Background.Image) |
| 211 | } |
| 212 | taskURL := "" |
| 213 | if m.TaskBackground != nil { |
| 214 | taskURL = pluginThemeBackgroundURL(pt, m.TaskBackground.Image) |
| 215 | } |
| 216 | v := manifestToView(m, themeKindPlugin, active, bgURL, "", taskURL) |
| 217 | // The external id carries the plugin: prefix; the manifest's own id stays |
| 218 | // governed by themePackIDRe inside the pack. |
| 219 | v.ID = pt.id |
| 220 | v.PluginName = pt.pluginName |
| 221 | v.Warnings = append([]string(nil), pt.warnings...) |
| 222 | return v |
| 223 | } |
| 224 | |
| 225 | // pluginThemeBackgroundURL builds the content-addressed asset URL for a scene |
| 226 | // image that stays inside the plugin ZIP. The digest was computed when the |
| 227 | // theme was discovered; the serve path re-verifies it on every request. |
| 228 | func pluginThemeBackgroundURL(pt pluginTheme, imageName string) string { |
| 229 | if imageName == "" { |
| 230 | return "" |
| 231 | } |
| 232 | digest := pt.digests[strings.ToLower(imageName)] |
| 233 | if digest == "" { |
| 234 | return "" |
| 235 | } |
| 236 | return themeAssetURLPrefix + pt.id + "/" + digest + "/" + filepath.Base(imageName) |
| 237 | } |
| 238 | |
| 239 | // themeDataDigest is the in-memory counterpart of themeFileDigest: the same |
| 240 | // truncated SHA-256 content identity used by every theme asset URL. |
| 241 | func themeDataDigest(data []byte) string { |
| 242 | sum := sha256.Sum256(data) |
| 243 | return hex.EncodeToString(sum[:])[:16] |
| 244 | } |
| 245 | |
| 246 | // servePluginThemeAsset serves a scene image straight out of the plugin ZIP. |
| 247 | // It mirrors serveOfficialThemeAsset: the theme must still resolve from an |
| 248 | // enabled plugin, the filename must be manifest-declared, the URL digest is |
| 249 | // re-verified against the current bytes, and the MIME sniff must agree with |
| 250 | // the declared extension. |
| 251 | func servePluginThemeAsset(w http.ResponseWriter, r *http.Request, pluginName, themeID, digest, filename string) { |
| 252 | pt := findPluginTheme(pluginName, themeID) |
| 253 | if pt == nil { |
| 254 | http.NotFound(w, r) |
| 255 | return |
| 256 | } |
| 257 | declared := pt.manifest.Background != nil && pt.manifest.Background.Image == filename || |
| 258 | pt.manifest.TaskBackground != nil && pt.manifest.TaskBackground.Image == filename |
| 259 | if !declared { |
| 260 | http.NotFound(w, r) |
| 261 | return |
| 262 | } |
| 263 | _, images, err := loadPluginThemeZip(pt.path) |
| 264 | if err != nil { |
| 265 | http.NotFound(w, r) |
| 266 | return |
| 267 | } |
| 268 | data := images[strings.ToLower(filename)] |
| 269 | if len(data) == 0 { |
| 270 | http.NotFound(w, r) |
| 271 | return |
| 272 | } |
| 273 | // Re-validate file identity against the digest embedded in the URL. |
| 274 | if !strings.EqualFold(themeDataDigest(data), digest) { |
| 275 | http.NotFound(w, r) |
| 276 | return |
| 277 | } |
| 278 | head := data |
| 279 | if len(head) > 512 { |
| 280 | head = head[:512] |
| 281 | } |
| 282 | if sniffThemeImageMIME(head, filename) != themeImageMIMEFromName(filename) { |
| 283 | http.NotFound(w, r) |
| 284 | return |
| 285 | } |
| 286 | w.Header().Set("Content-Type", themeImageMIMEFromName(filename)) |
| 287 | w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename})) |
| 288 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 289 | w.Header().Set("Cache-Control", "private, max-age=3600") |
| 290 | http.ServeContent(w, r, filename, time.Time{}, bytes.NewReader(data)) |
| 291 | } |
| 292 | |
| 293 | // errPluginThemeReadOnly is the shared guard message for bound methods that |
| 294 | // must never mutate a plugin theme. |
| 295 | func errPluginThemeReadOnly(id, op string) error { |
| 296 | return fmt.Errorf("plugin theme %q is read-only and cannot be %s; it is managed by its plugin", strings.TrimSpace(id), op) |
| 297 | } |
| 298 |