package handler import ( "encoding/json" "github.com/gofiber/fiber/v3" ) // defaultSettings returns the default settings map. func defaultSettings() map[string]any { return map[string]any{ "defaultUserAgent": "clash.meta/v1.19.24", "defaultFlowUserAgent": "clash.meta/v1.19.24", "defaultTimeout": "30000", "backendRequestConcurrency": "3", "backendRequestConcurrencyWaitTime": "100", "remoteCacheTtl": "300", "remoteCacheStaleOnError": true, "nodeInfoApiUrl": "https://ipwho.is/{ip}", "theme": map[string]any{ "auto": true, "name": "light", "dark": "dark", "light": "light", }, "appearanceSetting": map[string]any{ "isSimpleMode": true, "isLeftRight": false, "isDefaultIcon": false, "isIconColor": false, "isShowIcon": true, "isSimpleShowRemark": false, "isEditorCommon": false, "manualSubscriptionsDisplayMode": "collapsed", "editorGroupingMode": "always", "isSimpleReicon": false, "isSubItemMenuFold": true, "showFloatingRefreshButton": false, "showFloatingAddButton": false, "createItemPosition": "bottom", "displayPreviewInWebPage": true, "subProgressStyle": "hidden", "listPageViewMode": "single-column", "listPageViewModeInWideScreenNarrowMode": "single-column", "useNarrowModeOnWideScreen": false, }, "appName": "Sub-Store", } } // mergeSettings merges default + stored settings. // Per review-resolution #4: top-level keys from stored override default; // theme and appearanceSetting are shallow-merged (stored child keys override default child keys). func mergeSettings(base, stored map[string]any) map[string]any { result := make(map[string]any, len(base)) for k, v := range base { result[k] = v } for k, v := range stored { if v != nil { result[k] = v } } // Shallow merge for theme and appearanceSetting result["theme"] = shallowMergeObject(base["theme"], stored["theme"]) result["appearanceSetting"] = shallowMergeObject(base["appearanceSetting"], stored["appearanceSetting"]) return result } func shallowMergeObject(base, overlay any) map[string]any { result := map[string]any{} if b, ok := base.(map[string]any); ok { for k, v := range b { result[k] = v } } if o, ok := overlay.(map[string]any); ok { for k, v := range o { if v != nil { result[k] = v } } } return result } // HandleGetSettings returns merged settings. func (d *Deps) HandleGetSettings(c fiber.Ctx) error { stored, err := d.SettingsRepo.Get() if err != nil { return failed(c, "Failed to read settings", 500) } return success(c, mergeSettings(defaultSettings(), stored)) } // HandleUpdateSettings patches settings. func (d *Deps) HandleUpdateSettings(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { input = map[string]any{} } merged, err := d.SettingsRepo.Update(input) if err != nil { return failed(c, "Failed to update settings", 500) } return success(c, mergeSettings(defaultSettings(), merged)) } // HandleExportStorage exports all data. // Per review-resolution #31: exclude built-in templates. func (d *Deps) HandleExportStorage(c fiber.Ctx) error { sources, _ := d.SourceRepo.List() collections, _ := d.CollectionRepo.List() templates, _ := d.TemplateRepo.List() settings, _ := d.SettingsRepo.Get() // Filter out built-in templates exportedTemplates := make([]any, 0, len(templates)) builtinIDs := getBuiltinTemplateIDs() for _, t := range templates { if !builtinIDs[t.ID] { exportedTemplates = append(exportedTemplates, t) } } // Actually filter properly — already done above payload := fiber.Map{ "version": 1, "exportedAt": "now", "settings": settings, "sources": sources, "collections": collections, "templates": exportedTemplates, } c.Set("Content-Type", "application/json; charset=utf-8") c.Set("Content-Disposition", `attachment; filename="sub-store-backup.json"`) return c.JSON(payload) } // HandleImportStorage imports data. // Per review-resolution #30: import order = settings → sources → templates → collections. func (d *Deps) HandleImportStorage(c fiber.Ctx) error { input, err := parseJsonOrText(c) if err != nil { return failed(c, "Invalid import payload", 400) } // Import settings first if settings, ok := input["settings"].(map[string]any); ok { d.SettingsRepo.Update(settings) } // Import sources if sources, ok := input["sources"].([]any); ok { for _, s := range sources { if record, ok := s.(map[string]any); ok { d.SourceRepo.Upsert(mapToSourceRecord(record)) } } } // Import templates (before collections, since collections reference templates) builtinIDs := getBuiltinTemplateIDs() if templates, ok := input["templates"].([]any); ok { for _, t := range templates { if record, ok := t.(map[string]any); ok { id := getStringValue(record["id"]) if id != "" && !builtinIDs[id] { d.TemplateRepo.Upsert(mapToTemplateRecord(record)) } } } } // Import collections last if collections, ok := input["collections"].([]any); ok { for _, col := range collections { if record, ok := col.(map[string]any); ok { d.CollectionRepo.Upsert(mapToCollectionRecord(record)) } } } return success(c, fiber.Map{"restored": true}) } // parseJsonOrText parses body as JSON, or wraps text as {content: text}. // Per review-resolution #9: JSON first, plain text fallback. func parseJsonOrText(c fiber.Ctx) (map[string]any, error) { body := c.Body() var result map[string]any if err := json.Unmarshal(body, &result); err == nil { return result, nil } // Try as string text := string(body) if text == "" { return map[string]any{}, nil } return map[string]any{"content": text}, nil }