| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | |
| 13 | "reasonix/internal/config" |
| 14 | "reasonix/internal/fileutil" |
| 15 | ) |
| 16 | |
| 17 | func themeStatePath() string { |
| 18 | return filepath.Join(config.MemoryUserDir(), themeStateFileName) |
| 19 | } |
| 20 | |
| 21 | func themesRootDir() string { |
| 22 | return filepath.Join(config.MemoryUserDir(), themeDirName) |
| 23 | } |
| 24 | |
| 25 | func themeDir(id string) string { |
| 26 | return filepath.Join(themesRootDir(), id) |
| 27 | } |
| 28 | |
| 29 | func themeManifestPath(id string) string { |
| 30 | return filepath.Join(themeDir(id), themePackManifestName) |
| 31 | } |
| 32 | |
| 33 | func loadThemeDesktopState() ThemeDesktopState { |
| 34 | path := themeStatePath() |
| 35 | data, err := os.ReadFile(path) |
| 36 | if err != nil { |
| 37 | return ThemeDesktopState{SchemaVersion: themeStateSchemaVer} |
| 38 | } |
| 39 | var st ThemeDesktopState |
| 40 | if err := json.Unmarshal(data, &st); err != nil { |
| 41 | return ThemeDesktopState{SchemaVersion: themeStateSchemaVer} |
| 42 | } |
| 43 | if st.SchemaVersion == 0 { |
| 44 | st.SchemaVersion = themeStateSchemaVerV1 |
| 45 | } |
| 46 | st.ActiveThemeID = strings.TrimSpace(st.ActiveThemeID) |
| 47 | return st |
| 48 | } |
| 49 | |
| 50 | func saveThemeDesktopState(st ThemeDesktopState) error { |
| 51 | st.SchemaVersion = themeStateSchemaVer |
| 52 | st.ActiveThemeID = strings.TrimSpace(st.ActiveThemeID) |
| 53 | // Hard rule for v2: never persist base style ids as the active pack. |
| 54 | if isBuiltinThemeID(st.ActiveThemeID) { |
| 55 | st.ActiveThemeID = "" |
| 56 | } |
| 57 | dir := filepath.Dir(themeStatePath()) |
| 58 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 59 | return err |
| 60 | } |
| 61 | data, err := json.MarshalIndent(st, "", " ") |
| 62 | if err != nil { |
| 63 | return err |
| 64 | } |
| 65 | return writeFileAtomic(themeStatePath(), data, 0o644) |
| 66 | } |
| 67 | |
| 68 | func writeFileAtomic(path string, data []byte, mode os.FileMode) error { |
| 69 | // Use the shared Windows-safe atomic writer (AV/indexer lock retries + |
| 70 | // cross-device fallback) instead of a bare os.Rename. |
| 71 | return fileutil.AtomicWriteFile(path, data, mode) |
| 72 | } |
| 73 | |
| 74 | func loadUserThemeManifest(id string) (*ThemePackManifest, error) { |
| 75 | id = strings.TrimSpace(id) |
| 76 | if !themePackIDRe.MatchString(id) { |
| 77 | return nil, fmt.Errorf("invalid theme id") |
| 78 | } |
| 79 | if isReservedThemeID(id) { |
| 80 | return nil, fmt.Errorf("built-in theme %q has no user directory", id) |
| 81 | } |
| 82 | data, err := os.ReadFile(themeManifestPath(id)) |
| 83 | if err != nil { |
| 84 | return nil, err |
| 85 | } |
| 86 | return parseThemePackManifest(data) |
| 87 | } |
| 88 | |
| 89 | func listUserThemeIDs() ([]string, error) { |
| 90 | root := themesRootDir() |
| 91 | entries, err := os.ReadDir(root) |
| 92 | if err != nil { |
| 93 | if os.IsNotExist(err) { |
| 94 | return nil, nil |
| 95 | } |
| 96 | return nil, err |
| 97 | } |
| 98 | var ids []string |
| 99 | for _, e := range entries { |
| 100 | if !e.IsDir() { |
| 101 | continue |
| 102 | } |
| 103 | id := e.Name() |
| 104 | if !themePackIDRe.MatchString(id) || isReservedThemeID(id) { |
| 105 | continue |
| 106 | } |
| 107 | if _, err := os.Stat(themeManifestPath(id)); err != nil { |
| 108 | continue |
| 109 | } |
| 110 | ids = append(ids, id) |
| 111 | } |
| 112 | return ids, nil |
| 113 | } |
| 114 | |
| 115 | func userThemeExists(id string) bool { |
| 116 | _, err := os.Stat(themeManifestPath(id)) |
| 117 | return err == nil |
| 118 | } |
| 119 | |
| 120 | // publishThemeDir atomically replaces themes/<id> with the prepared staging directory. |
| 121 | // stagingDir must already contain a validated theme.json and optional scene images. |
| 122 | func publishThemeDir(id, stagingDir string, replace bool) error { |
| 123 | id = strings.TrimSpace(id) |
| 124 | if !themePackIDRe.MatchString(id) { |
| 125 | return fmt.Errorf("invalid theme id") |
| 126 | } |
| 127 | if isReservedThemeID(id) { |
| 128 | return fmt.Errorf("built-in themes cannot be overwritten") |
| 129 | } |
| 130 | dest := themeDir(id) |
| 131 | if userThemeExists(id) && !replace { |
| 132 | return fmt.Errorf("theme %q already exists (set replace to overwrite)", id) |
| 133 | } |
| 134 | root := themesRootDir() |
| 135 | if err := os.MkdirAll(root, 0o755); err != nil { |
| 136 | return err |
| 137 | } |
| 138 | // Final rename target: themes/<id> |
| 139 | // Strategy: write to themes/.staging-<id>-* then rename over destination. |
| 140 | parentStaging, err := os.MkdirTemp(root, ".staging-"+id+"-") |
| 141 | if err != nil { |
| 142 | return err |
| 143 | } |
| 144 | cleanupStaging := true |
| 145 | defer func() { |
| 146 | if cleanupStaging { |
| 147 | _ = os.RemoveAll(parentStaging) |
| 148 | } |
| 149 | }() |
| 150 | |
| 151 | // Copy staging contents into parentStaging (re-validate containment). |
| 152 | if err := copyThemeTree(stagingDir, parentStaging); err != nil { |
| 153 | return err |
| 154 | } |
| 155 | // Verify manifest still parses after copy. |
| 156 | data, err := os.ReadFile(filepath.Join(parentStaging, themePackManifestName)) |
| 157 | if err != nil { |
| 158 | return err |
| 159 | } |
| 160 | m, err := parseThemePackManifest(data) |
| 161 | if err != nil { |
| 162 | return err |
| 163 | } |
| 164 | if m.ID != id { |
| 165 | return fmt.Errorf("theme id mismatch: manifest %q vs directory %q", m.ID, id) |
| 166 | } |
| 167 | if m.Background != nil && m.Background.Image != "" { |
| 168 | imgPath := filepath.Join(parentStaging, m.Background.Image) |
| 169 | if err := validateThemeImageFile(imgPath); err != nil { |
| 170 | return err |
| 171 | } |
| 172 | } |
| 173 | if m.TaskBackground != nil && m.TaskBackground.Image != "" { |
| 174 | imgPath := filepath.Join(parentStaging, m.TaskBackground.Image) |
| 175 | if err := validateThemeImageFile(imgPath); err != nil { |
| 176 | return err |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // Replace destination atomically: rename old aside, rename new in, remove old. |
| 181 | backup := "" |
| 182 | if _, err := os.Stat(dest); err == nil { |
| 183 | backup = dest + ".bak-" + randomThemeSuffix() |
| 184 | if err := os.Rename(dest, backup); err != nil { |
| 185 | return err |
| 186 | } |
| 187 | } |
| 188 | if err := os.Rename(parentStaging, dest); err != nil { |
| 189 | if backup != "" { |
| 190 | _ = os.Rename(backup, dest) |
| 191 | } |
| 192 | return err |
| 193 | } |
| 194 | cleanupStaging = false |
| 195 | if backup != "" { |
| 196 | _ = os.RemoveAll(backup) |
| 197 | } |
| 198 | return nil |
| 199 | } |
| 200 | |
| 201 | func randomThemeSuffix() string { |
| 202 | // Short non-crypto suffix for backup dir names. |
| 203 | return fmt.Sprintf("%d", os.Getpid()) |
| 204 | } |
| 205 | |
| 206 | func copyThemeTree(src, dst string) error { |
| 207 | srcAbs, err := filepath.Abs(src) |
| 208 | if err != nil { |
| 209 | return err |
| 210 | } |
| 211 | return filepath.Walk(srcAbs, func(path string, info os.FileInfo, err error) error { |
| 212 | if err != nil { |
| 213 | return err |
| 214 | } |
| 215 | rel, err := filepath.Rel(srcAbs, path) |
| 216 | if err != nil { |
| 217 | return err |
| 218 | } |
| 219 | if rel == "." { |
| 220 | return nil |
| 221 | } |
| 222 | // Only allow root-level files (theme.json + up to two scene images). |
| 223 | if strings.Contains(rel, string(os.PathSeparator)) || strings.Contains(rel, "/") || strings.Contains(rel, "\\") { |
| 224 | return fmt.Errorf("theme package may only contain root-level files, found %q", rel) |
| 225 | } |
| 226 | if info.Mode()&os.ModeSymlink != 0 { |
| 227 | return fmt.Errorf("theme package must not contain symlinks") |
| 228 | } |
| 229 | if info.IsDir() { |
| 230 | return fmt.Errorf("theme package must not contain subdirectories") |
| 231 | } |
| 232 | target := filepath.Join(dst, rel) |
| 233 | // Ensure target stays inside dst. |
| 234 | if !pathIsInside(dst, target) { |
| 235 | return fmt.Errorf("theme path escapes destination") |
| 236 | } |
| 237 | return copyFileLimited(path, target, themePackMaxImageBytes+themePackMaxManifest) |
| 238 | }) |
| 239 | } |
| 240 | |
| 241 | func pathIsInside(root, target string) bool { |
| 242 | rootAbs, err := filepath.Abs(root) |
| 243 | if err != nil { |
| 244 | return false |
| 245 | } |
| 246 | targetAbs, err := filepath.Abs(target) |
| 247 | if err != nil { |
| 248 | return false |
| 249 | } |
| 250 | rel, err := filepath.Rel(rootAbs, targetAbs) |
| 251 | if err != nil { |
| 252 | return false |
| 253 | } |
| 254 | return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) |
| 255 | } |
| 256 | |
| 257 | func copyFileLimited(src, dst string, maxBytes int64) error { |
| 258 | in, err := os.Open(src) |
| 259 | if err != nil { |
| 260 | return err |
| 261 | } |
| 262 | defer in.Close() |
| 263 | info, err := in.Stat() |
| 264 | if err != nil { |
| 265 | return err |
| 266 | } |
| 267 | if info.Size() > maxBytes { |
| 268 | return fmt.Errorf("file too large: %s", filepath.Base(src)) |
| 269 | } |
| 270 | out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) |
| 271 | if err != nil { |
| 272 | return err |
| 273 | } |
| 274 | defer out.Close() |
| 275 | written, err := io.Copy(out, io.LimitReader(in, maxBytes+1)) |
| 276 | if err != nil { |
| 277 | return err |
| 278 | } |
| 279 | if written > maxBytes { |
| 280 | return fmt.Errorf("file too large: %s", filepath.Base(src)) |
| 281 | } |
| 282 | return out.Sync() |
| 283 | } |
| 284 | |
| 285 | func deleteUserTheme(id string) error { |
| 286 | id = strings.TrimSpace(id) |
| 287 | if !themePackIDRe.MatchString(id) { |
| 288 | return fmt.Errorf("invalid theme id") |
| 289 | } |
| 290 | if isReservedThemeID(id) { |
| 291 | return fmt.Errorf("built-in themes cannot be deleted") |
| 292 | } |
| 293 | dest := themeDir(id) |
| 294 | if !pathIsInside(themesRootDir(), dest) { |
| 295 | return fmt.Errorf("refusing to delete path outside themes root") |
| 296 | } |
| 297 | if _, err := os.Stat(dest); err != nil { |
| 298 | if os.IsNotExist(err) { |
| 299 | return fmt.Errorf("theme %q not found", id) |
| 300 | } |
| 301 | return err |
| 302 | } |
| 303 | return os.RemoveAll(dest) |
| 304 | } |
| 305 | |
| 306 | // resolveActiveThemeID returns a loadable official/user/plugin theme id, or |
| 307 | // empty. Base style ids are never active packs under schema v2. A plugin: |
| 308 | // pointer resolves only while its plugin is installed AND enabled; when it |
| 309 | // does not resolve, callers fall back to the base style but MUST preserve the |
| 310 | // pointer (reinstalling the plugin restores the theme). |
| 311 | func resolveActiveThemeID(st ThemeDesktopState) string { |
| 312 | id := strings.TrimSpace(st.ActiveThemeID) |
| 313 | if id == "" || isBuiltinThemeID(id) { |
| 314 | return "" |
| 315 | } |
| 316 | if isPluginThemeID(id) { |
| 317 | pluginName, themeID, ok := parsePluginThemeID(id) |
| 318 | if !ok { |
| 319 | return "" |
| 320 | } |
| 321 | if pt := findPluginTheme(pluginName, themeID); pt != nil { |
| 322 | return pt.id |
| 323 | } |
| 324 | return "" |
| 325 | } |
| 326 | if isOfficialThemeID(id) { |
| 327 | return id |
| 328 | } |
| 329 | if userThemeExists(id) { |
| 330 | // Quick re-validate; corrupt themes fall back to none (caller falls to base style). |
| 331 | if _, err := loadUserThemeManifest(id); err == nil { |
| 332 | return id |
| 333 | } |
| 334 | } |
| 335 | return "" |
| 336 | } |
| 337 | |
| 338 | func themeFileDigest(path string) (string, error) { |
| 339 | f, err := os.Open(path) |
| 340 | if err != nil { |
| 341 | return "", err |
| 342 | } |
| 343 | defer f.Close() |
| 344 | h := sha256.New() |
| 345 | if _, err := io.Copy(h, io.LimitReader(f, themePackMaxImageBytes+1)); err != nil { |
| 346 | return "", err |
| 347 | } |
| 348 | return hex.EncodeToString(h.Sum(nil))[:16], nil |
| 349 | } |
| 350 | |
| 351 | type themeStagingImage struct { |
| 352 | path string |
| 353 | bytes []byte |
| 354 | } |
| 355 | |
| 356 | func writeThemeStaging(m *ThemePackManifest, imagePath string, imageBytes []byte, taskImages ...themeStagingImage) (staging string, err error) { |
| 357 | if err := validateThemePackManifest(m); err != nil { |
| 358 | return "", err |
| 359 | } |
| 360 | staging, err = os.MkdirTemp("", "reasonix-theme-stage-*") |
| 361 | if err != nil { |
| 362 | return "", err |
| 363 | } |
| 364 | cleanup := true |
| 365 | defer func() { |
| 366 | if cleanup { |
| 367 | _ = os.RemoveAll(staging) |
| 368 | } |
| 369 | }() |
| 370 | |
| 371 | writeImage := func(imageName, imagePath string, imageBytes []byte, label string) error { |
| 372 | var data []byte |
| 373 | switch { |
| 374 | case len(imageBytes) > 0: |
| 375 | data = imageBytes |
| 376 | case imagePath != "": |
| 377 | data, err = os.ReadFile(imagePath) |
| 378 | if err != nil { |
| 379 | return fmt.Errorf("read %s image: %w", label, err) |
| 380 | } |
| 381 | default: |
| 382 | return fmt.Errorf("%s image data is required", label) |
| 383 | } |
| 384 | if int64(len(data)) > themePackMaxImageBytes { |
| 385 | return fmt.Errorf("%s image exceeds %d bytes", label, themePackMaxImageBytes) |
| 386 | } |
| 387 | imgDest := filepath.Join(staging, imageName) |
| 388 | if err := os.WriteFile(imgDest, data, 0o644); err != nil { |
| 389 | return err |
| 390 | } |
| 391 | if err := validateThemeImageFile(imgDest); err != nil { |
| 392 | return err |
| 393 | } |
| 394 | return nil |
| 395 | } |
| 396 | |
| 397 | // Place scene images first so names and image content are re-validated. |
| 398 | if m.Background != nil && m.Background.Image != "" { |
| 399 | if err := writeImage(m.Background.Image, imagePath, imageBytes, "home background"); err != nil { |
| 400 | return "", err |
| 401 | } |
| 402 | } |
| 403 | if m.TaskBackground != nil && m.TaskBackground.Image != "" { |
| 404 | var task themeStagingImage |
| 405 | if len(taskImages) > 0 { |
| 406 | task = taskImages[0] |
| 407 | } |
| 408 | if err := writeImage(m.TaskBackground.Image, task.path, task.bytes, "task background"); err != nil { |
| 409 | return "", err |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | raw, err := json.MarshalIndent(m, "", " ") |
| 414 | if err != nil { |
| 415 | return "", err |
| 416 | } |
| 417 | if len(raw) > themePackMaxManifest { |
| 418 | return "", fmt.Errorf("theme manifest exceeds %d bytes", themePackMaxManifest) |
| 419 | } |
| 420 | if err := os.WriteFile(filepath.Join(staging, themePackManifestName), raw, 0o644); err != nil { |
| 421 | return "", err |
| 422 | } |
| 423 | cleanup = false |
| 424 | return staging, nil |
| 425 | } |
| 426 | |
| 427 | func resolveThemeImageAbs(id, imageName string) (string, error) { |
| 428 | id = strings.TrimSpace(id) |
| 429 | imageName = filepath.Base(strings.TrimSpace(imageName)) |
| 430 | if !themePackIDRe.MatchString(id) || isReservedThemeID(id) { |
| 431 | return "", fmt.Errorf("invalid theme id") |
| 432 | } |
| 433 | if !themePackImageRe.MatchString(imageName) { |
| 434 | return "", fmt.Errorf("invalid image name") |
| 435 | } |
| 436 | root := themeDir(id) |
| 437 | abs := filepath.Join(root, imageName) |
| 438 | if !pathIsInside(root, abs) { |
| 439 | return "", fmt.Errorf("image path escapes theme directory") |
| 440 | } |
| 441 | // Resolve symlinks and re-check containment (TOCTOU defense on serve). |
| 442 | resolved, err := filepath.EvalSymlinks(abs) |
| 443 | if err != nil { |
| 444 | // If the file doesn't exist yet, still return the intended path after containment check. |
| 445 | if os.IsNotExist(err) { |
| 446 | return abs, nil |
| 447 | } |
| 448 | return "", err |
| 449 | } |
| 450 | rootResolved, err := filepath.EvalSymlinks(root) |
| 451 | if err != nil { |
| 452 | rootResolved = root |
| 453 | } |
| 454 | if !pathIsInside(rootResolved, resolved) { |
| 455 | return "", fmt.Errorf("image path escapes theme directory") |
| 456 | } |
| 457 | return resolved, nil |
| 458 | } |
| 459 |