package handler import ( "encoding/json" "strings" "github.com/gofiber/fiber/v3" "github.com/peterqiu0516/sub-store/internal/model" "github.com/peterqiu0516/sub-store/internal/template" "github.com/peterqiu0516/sub-store/internal/util" ) func getBuiltinTemplateIDs() map[string]bool { return template.BuiltinTemplateIDs } // --- Source handlers --- func (d *Deps) HandleListSources(c fiber.Ctx) error { sources, err := d.SourceRepo.List() if err != nil { return failed(c, "Failed to list sources", 500) } return success(c, sources) } func (d *Deps) HandleCreateSource(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } if err := validateSourcePayload(input); err != nil { return failed(c, err.Error(), 400) } id := getStringValue(input["id"]) if id == "" { id = getStringValue(input["name"]) } if err := validateRecordId(id, "Source"); err != nil { return failed(c, err.Error(), 400) } existing, _ := d.SourceRepo.Get(id) if existing != nil { return failed(c, "Source id already exists", 409) } rec := mapToSourceRecord(input) if err := validateSource(rec); err != nil { return failed(c, err.Error(), 400) } result, err := d.SourceRepo.Upsert(rec) if err != nil { return failed(c, "Failed to create source", 500) } // Trigger background egress probe for the new source's nodes go d.probeSourceEgressBackground(rec) return success(c, result) } func (d *Deps) HandleGetSource(c fiber.Ctx) error { rec, err := d.SourceRepo.Get(c.Params("name")) if err != nil || rec == nil { return failed(c, "Source not found", 404) } return success(c, rec) } func (d *Deps) HandleUpdateSource(c fiber.Ctx) error { name := c.Params("name") existing, err := d.SourceRepo.Get(name) if err != nil || existing == nil { return failed(c, "Source not found", 404) } var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } merged := mergeSource(*existing, mapToSourceRecord(input)) if err := validateSource(merged); err != nil { return failed(c, err.Error(), 400) } result, err := d.SourceRepo.Upsert(merged) if err != nil { return failed(c, "Failed to update source", 500) } // Trigger background egress probe for the updated source's nodes go d.probeSourceEgressBackground(merged) return success(c, result) } func (d *Deps) HandleDeleteSource(c fiber.Ctx) error { name := c.Params("name") existing, err := d.SourceRepo.Get(name) if err != nil || existing == nil { return failed(c, "Source not found", 404) } // Check references collections, _ := d.CollectionRepo.List() var references []string for _, col := range collections { for _, sid := range col.SourceIds { if sid == existing.ID { references = append(references, col.ID) break } } } if len(references) > 0 { return failed(c, "Source is used by collections: "+strings.Join(references, ", "), 409) } // Archive and delete — review-resolution #29 snapshot := sourceToMap(*existing) _, err = d.RecycleRepo.ArchiveAndDelete("source", existing.ID, snapshot, "DELETE FROM sources WHERE id = ?", existing.ID) if err != nil { return failed(c, "Failed to delete source", 500) } return success(c, fiber.Map{"deleted": true, "references": []string{}}) } func (d *Deps) HandleSortSources(c fiber.Ctx) error { var input []map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } ids := extractIds(input) if err := d.SourceRepo.Sort(ids); err != nil { return failed(c, "Failed to sort sources", 500) } sources, _ := d.SourceRepo.List() return success(c, sources) } func (d *Deps) HandleSortSourcesLegacy(c fiber.Ctx) error { return d.HandleSortSources(c) } // --- Collection handlers --- func (d *Deps) HandleListCollections(c fiber.Ctx) error { collections, err := d.CollectionRepo.List() if err != nil { return failed(c, "Failed to list collections", 500) } return success(c, collections) } func (d *Deps) HandleCreateCollection(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } id := getStringValue(input["id"]) if id == "" { id = getStringValue(input["name"]) } if err := validateRecordId(id, "Collection"); err != nil { return failed(c, err.Error(), 400) } existing, _ := d.CollectionRepo.Get(id) if existing != nil { return failed(c, "Collection id already exists", 409) } rec := mapToCollectionRecord(input) if err := d.validateCollection(rec); err != nil { return failed(c, err.Error(), 400) } result, err := d.CollectionRepo.Upsert(rec) if err != nil { return failed(c, "Failed to create collection", 500) } return success(c, result) } func (d *Deps) HandleGetCollection(c fiber.Ctx) error { rec, err := d.CollectionRepo.Get(c.Params("name")) if err != nil || rec == nil { return failed(c, "Collection not found", 404) } return success(c, rec) } func (d *Deps) HandleUpdateCollection(c fiber.Ctx) error { name := c.Params("name") existing, err := d.CollectionRepo.Get(name) if err != nil || existing == nil { return failed(c, "Collection not found", 404) } var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } merged := mergeCollection(*existing, mapToCollectionRecord(input)) if err := d.validateCollection(merged); err != nil { return failed(c, err.Error(), 400) } result, err := d.CollectionRepo.Upsert(merged) if err != nil { return failed(c, "Failed to update collection", 500) } return success(c, result) } func (d *Deps) HandleDeleteCollection(c fiber.Ctx) error { name := c.Params("name") existing, err := d.CollectionRepo.Get(name) if err != nil || existing == nil { return failed(c, "Collection not found", 404) } snapshot := collectionToMap(*existing) _, err = d.RecycleRepo.ArchiveAndDelete("collection", existing.ID, snapshot, "DELETE FROM collections WHERE id = ?", existing.ID) if err != nil { return failed(c, "Failed to delete collection", 500) } return success(c, fiber.Map{"deleted": true}) } func (d *Deps) HandleSortCollections(c fiber.Ctx) error { var input []map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } ids := extractIds(input) if err := d.CollectionRepo.Sort(ids); err != nil { return failed(c, "Failed to sort collections", 500) } collections, _ := d.CollectionRepo.List() return success(c, collections) } func (d *Deps) HandleSortCollectionsLegacy(c fiber.Ctx) error { return d.HandleSortCollections(c) } // --- Template handlers --- func (d *Deps) HandleListTemplates(c fiber.Ctx) error { // Merge built-in + DB templates dbTemplates, err := d.TemplateRepo.List() if err != nil { dbTemplates = []model.TemplateRecord{} } result := make([]fiber.Map, 0, len(template.BuiltinTemplates)+len(dbTemplates)) for _, bt := range template.BuiltinTemplates { result = append(result, fiber.Map{ "id": bt.ID, "name": bt.Name, "target": bt.Target, "config": bt.Config, "readonly": true, }) } for _, t := range dbTemplates { if !template.BuiltinTemplateIDs[t.ID] { result = append(result, fiber.Map{ "id": t.ID, "name": t.Name, "target": t.Target, "config": t.Config, "readonly": false, }) } } return success(c, result) } func (d *Deps) HandleGetTemplate(c fiber.Ctx) error { name := c.Params("name") // Check built-in first for _, bt := range template.BuiltinTemplates { if bt.ID == name { return success(c, fiber.Map{ "id": bt.ID, "name": bt.Name, "target": bt.Target, "config": bt.Config, "readonly": true, }) } } rec, err := d.TemplateRepo.Get(name) if err != nil || rec == nil { return failed(c, "Template not found", 404) } return success(c, fiber.Map{ "id": rec.ID, "name": rec.Name, "target": rec.Target, "config": rec.Config, "readonly": false, }) } func (d *Deps) HandleCreateTemplate(c fiber.Ctx) error { input, _ := parseJsonOrText(c) id := getStringValue(input["id"]) if id == "" { id = getStringValue(input["name"]) } if id == "" { return failed(c, "Template name is required", 400) } if template.BuiltinTemplateIDs[id] { return failed(c, "Built-in templates cannot be modified", 400) } rec := mapToTemplateRecord(input) result, err := d.TemplateRepo.Upsert(rec) if err != nil { return failed(c, err.Error(), 400) } return success(c, fiber.Map{ "id": result.ID, "name": result.Name, "target": result.Target, "config": result.Config, "readonly": false, }) } func (d *Deps) HandleUpdateTemplate(c fiber.Ctx) error { name := c.Params("name") if template.BuiltinTemplateIDs[name] { return failed(c, "Built-in templates cannot be modified", 400) } existing, err := d.TemplateRepo.Get(name) if err != nil || existing == nil { return failed(c, "Template not found", 404) } input, _ := parseJsonOrText(c) input["id"] = name rec := mapToTemplateRecord(input) result, err := d.TemplateRepo.Upsert(rec) if err != nil { return failed(c, err.Error(), 400) } return success(c, fiber.Map{ "id": result.ID, "name": result.Name, "target": result.Target, "config": result.Config, "readonly": false, }) } func (d *Deps) HandleDeleteTemplate(c fiber.Ctx) error { name := c.Params("name") if template.BuiltinTemplateIDs[name] { return failed(c, "Built-in templates cannot be deleted", 400) } existing, err := d.TemplateRepo.Get(name) if err != nil || existing == nil { return failed(c, "Template not found", 404) } snapshot := templateToMap(*existing) _, err = d.RecycleRepo.ArchiveAndDelete("template", existing.ID, snapshot, "DELETE FROM templates WHERE id = ?", existing.ID) if err != nil { return failed(c, "Failed to delete template", 500) } return success(c, fiber.Map{"deleted": true}) } // --- Validation helpers --- func validateRecordId(id, label string) error { if id == "" { return errWrap(label + " id is required") } for _, c := range id { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { return errWrap(label + " id must use 1-64 lowercase letters, numbers, underscores, or hyphens") } } if len(id) > 64 { return errWrap(label + " id must use 1-64 lowercase letters, numbers, underscores, or hyphens") } return nil } func validateSourcePayload(input map[string]any) error { if t, ok := input["type"]; ok { if t != "remote" && t != "local" { return errWrap("Source type must be remote or local") } } return nil } func validateSource(rec model.SourceRecord) error { if err := validateRecordId(rec.ID, "Source"); err != nil { return err } if rec.Type == "local" { if rec.Content == "" { return errWrap("Local source content is required") } return nil } urls := splitLines(rec.URL) if len(urls) == 0 { return errWrap("Remote source URL is required") } for _, u := range urls { if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { return errWrap("Remote source URLs must use http or https") } } return nil } func (d *Deps) validateCollection(rec model.CollectionRecord) error { if err := validateRecordId(rec.ID, "Collection"); err != nil { return err } if len(rec.SourceIds) > 0 { sources, _ := d.SourceRepo.List() sourceIdSet := make(map[string]bool, len(sources)) for _, s := range sources { sourceIdSet[s.ID] = true } for _, sid := range rec.SourceIds { if !sourceIdSet[sid] { return errWrap("Collection references missing sources: " + sid) } } } if rec.TemplateId != "" { // Check built-in if !template.BuiltinTemplateIDs[rec.TemplateId] { t, _ := d.TemplateRepo.Get(rec.TemplateId) if t == nil { return errWrap("Collection references missing template: " + rec.TemplateId) } } } return nil } // --- Mapping helpers --- func mapToSourceRecord(input map[string]any) model.SourceRecord { id := getStringValue(input["id"]) if id == "" { id = getStringValue(input["name"]) } id = util.ToId(id) sourceType := "remote" if getStringValue(input["type"]) == "local" { sourceType = "local" } enabled := true if e, ok := input["enabled"].(bool); ok && !e { enabled = false } filters := parseFilters(input["filters"]) meta := getMapValue(input["meta"]) alias := getStringValue(input["alias"]) return model.SourceRecord{ ID: id, Name: orDefault(getStringValue(input["name"]), id), Alias: alias, Type: sourceType, URL: getStringValue(input["url"]), Content: getStringValue(input["content"]), Enabled: enabled, Filters: filters, Meta: meta, } } func mapToCollectionRecord(input map[string]any) model.CollectionRecord { id := getStringValue(input["id"]) if id == "" { id = getStringValue(input["name"]) } id = util.ToId(id) sourceIds := getStringArray(input["sourceIds"]) renameEnabled := false if v, ok := input["renameEnabled"].(bool); ok { renameEnabled = v } // Parse rename options (all default to true when renameEnabled is on) var renameOptions *model.RenameOptions if opts, ok := input["renameOptions"].(map[string]any); ok { ro := model.RenameOptions{ Flag: true, Alias: true, Country: true, City: true, Index: true, } if v, ok := opts["flag"].(bool); ok { ro.Flag = v } if v, ok := opts["alias"].(bool); ok { ro.Alias = v } if v, ok := opts["country"].(bool); ok { ro.Country = v } if v, ok := opts["city"].(bool); ok { ro.City = v } if v, ok := opts["index"].(bool); ok { ro.Index = v } renameOptions = &ro } templateId := getStringValue(input["templateId"]) if templateId == "" || templateId == "default" { templateId = template.DefaultTemplateID } ignoreFailed := true if v, ok := input["ignoreFailed"].(bool); ok { ignoreFailed = v } enabled := true if e, ok := input["enabled"].(bool); ok && !e { enabled = false } meta := getMapValue(input["meta"]) return model.CollectionRecord{ ID: id, Name: orDefault(getStringValue(input["name"]), id), SourceIds: sourceIds, RenameEnabled: renameEnabled, RenameOptions: renameOptions, TemplateId: templateId, IgnoreFailed: ignoreFailed, Enabled: enabled, Meta: meta, } } func mapToTemplateRecord(input map[string]any) model.TemplateRecord { id := getStringValue(input["id"]) if id == "" { id = getStringValue(input["name"]) } id = util.ToId(id) target := model.NormalizeTargetAlias(input["target"]) if target == "" { target = "mihomo" } config := getMapValue(input["config"]) if len(config) == 0 { if content, ok := input["content"].(string); ok && content != "" { // Try to parse content as YAML or JSON config = parseTemplateConfig(content) } } config = template.NormalizeMihomoTemplateConfig(config) return model.TemplateRecord{ ID: id, Name: orDefault(getStringValue(input["name"]), id), Target: target, Config: config, } } func mergeSource(existing model.SourceRecord, next model.SourceRecord) model.SourceRecord { merged := existing if next.Name != "" { merged.Name = next.Name } if next.Alias != "" { merged.Alias = next.Alias } if next.Type != "" { merged.Type = next.Type } if next.URL != "" { merged.URL = next.URL } if next.Content != "" { merged.Content = next.Content } merged.Enabled = next.Enabled if next.Filters != nil { merged.Filters = next.Filters } if next.Meta != nil { merged.Meta = util.MergeDeep(existing.Meta, next.Meta) } return merged } func mergeCollection(existing model.CollectionRecord, next model.CollectionRecord) model.CollectionRecord { merged := existing if next.Name != "" { merged.Name = next.Name } if next.SourceIds != nil { merged.SourceIds = next.SourceIds } merged.RenameEnabled = next.RenameEnabled if next.RenameOptions != nil { merged.RenameOptions = next.RenameOptions } if next.TemplateId != "" { merged.TemplateId = next.TemplateId } merged.IgnoreFailed = next.IgnoreFailed merged.Enabled = next.Enabled if next.Meta != nil { merged.Meta = util.MergeDeep(existing.Meta, next.Meta) } return merged } // --- Utility helpers --- type appError struct{ msg string } func (e *appError) Error() string { return e.msg } func errWrap(msg string) error { return &appError{msg: msg} } func getStringValue(v any) string { if s, ok := v.(string); ok { return s } return "" } func orDefault(s, def string) string { if s != "" { return s } return def } func getMapValue(v any) map[string]any { if m, ok := v.(map[string]any); ok { return m } return map[string]any{} } func getBoolValue(v any) bool { if b, ok := v.(bool); ok { return b } return false } func getStringArray(v any) []string { if arr, ok := v.([]any); ok { result := make([]string, 0, len(arr)) for _, item := range arr { if s, ok := item.(string); ok && s != "" { result = append(result, s) } } return result } return []string{} } func parseFilters(v any) []model.FilterRule { if arr, ok := v.([]any); ok { data, _ := json.Marshal(arr) var rules []model.FilterRule json.Unmarshal(data, &rules) if rules == nil { rules = []model.FilterRule{} } return rules } return []model.FilterRule{} } func extractIds(input []map[string]any) []string { ids := make([]string, 0, len(input)) for _, item := range input { id := getStringValue(item["id"]) if id == "" { id = getStringValue(item["name"]) } if id != "" { ids = append(ids, id) } } return ids } func splitLines(s string) []string { var result []string for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(line) if line != "" { result = append(result, line) } } return result } func sourceToMap(s model.SourceRecord) map[string]any { data, _ := json.Marshal(s) var m map[string]any json.Unmarshal(data, &m) return m } func collectionToMap(c model.CollectionRecord) map[string]any { data, _ := json.Marshal(c) var m map[string]any json.Unmarshal(data, &m) return m } func templateToMap(t model.TemplateRecord) map[string]any { data, _ := json.Marshal(t) var m map[string]any json.Unmarshal(data, &m) return m } func parseTemplateConfig(content string) map[string]any { // Try JSON first var m map[string]any if err := json.Unmarshal([]byte(content), &m); err == nil { return m } // Try YAML // We'll import yaml.v3 inline return parseYamlConfig(content) }