Files
sub-store/internal/handler/crud.go
T
rogee c73c2b55cd refactor: rename reads cached egress geo, fallback name-only (no DNS)
RenameCollectionNodes now reads geo info from cached egress-probe fields (country, countryCode, city, flag) on each node instead of doing per-node DNS lookups via DetectGeoWithServer.

When no cached egress data is available (probe not yet run), it falls back to name-based regex only (DetectGeo, no DNS) so the UI is never blocked. The background egress probe (triggered on source create/update) populates the cache asynchronously.

Also: runEgressProbe now stores countryCode and flag emoji from ipwho.is response. egressCacheKey and addCachedEgressInfo updated to include the new fields.

Reverted previous name-first hack on DetectGeoWithServer and DNS cache on geoip.LookupHost — no longer needed since rename doesn't call them.

Collection preview with rename: 30s -> 52ms (with cache miss, name-based fallback).
2026-07-30 19:02:25 +08:00

716 lines
18 KiB
Go

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)
}