204 lines
6.0 KiB
Go
204 lines
6.0 KiB
Go
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
|
|
}
|