chore: sub-store Go 重写项目初始化

This commit is contained in:
2026-07-27 14:38:13 +08:00
commit 90e53aa754
85 changed files with 15271 additions and 0 deletions
+665
View File
@@ -0,0 +1,665 @@
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)
}
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)
}
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"])
return model.SourceRecord{
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
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"])
filters := parseFilters(input["filters"])
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,
Filters: filters,
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.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
}
if next.Filters != nil {
merged.Filters = next.Filters
}
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 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)
}
+673
View File
@@ -0,0 +1,673 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/peterqiu0516/sub-store/internal/middleware"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/proxy"
"github.com/peterqiu0516/sub-store/internal/render"
"github.com/peterqiu0516/sub-store/internal/rules"
"github.com/peterqiu0516/sub-store/internal/service"
"github.com/peterqiu0516/sub-store/internal/template"
"github.com/peterqiu0516/sub-store/internal/util"
)
// --- Download handlers ---
func (d *Deps) HandleDownloadCollection(c fiber.Ctx) error {
name := c.Params("name")
target := d.getDownloadTarget(c)
if target == "" {
return failed(c, "Unsupported target", 400)
}
if err := d.rejectInvalidDownloadToken(c, "collection", name, target); err != nil {
return err
}
collection, err := d.CollectionRepo.Get(name)
if err != nil || collection == nil || !collection.Enabled {
return failed(c, "Collection not found", 404)
}
sources, _ := d.SourceRepo.List()
tmplConfig := d.getTemplateConfig(collection.TemplateId)
settings, _ := d.SettingsRepo.Get()
result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{
Collection: collection,
Sources: sources,
Target: target,
TemplateConfig: tmplConfig,
Settings: settings,
RequestUserAgent: c.Get("User-Agent"),
ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1",
CacheRepo: d.CacheRepo,
})
if err != nil {
return failed(c, err.Error(), 500)
}
return d.sendDownloadResponse(c, result, target)
}
func (d *Deps) HandleDownloadSource(c fiber.Ctx) error {
name := c.Params("name")
target := d.getDownloadTarget(c)
if target == "" {
return failed(c, "Unsupported target", 400)
}
if err := d.rejectInvalidDownloadToken(c, "source", name, target); err != nil {
return err
}
source, err := d.SourceRepo.Get(name)
if err != nil || source == nil || !source.Enabled {
return failed(c, "Subscription not found", 404)
}
settings, _ := d.SettingsRepo.Get()
result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{
Source: source,
Sources: []model.SourceRecord{*source},
Target: target,
Settings: settings,
RequestUserAgent: c.Get("User-Agent"),
ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1",
CacheRepo: d.CacheRepo,
})
if err != nil {
return failed(c, err.Error(), 500)
}
return d.sendDownloadResponse(c, result, target)
}
func (d *Deps) sendDownloadResponse(c fiber.Ctx, result *service.BuildResult, target string) error {
c.Set("Content-Type", model.GetTargetContentType(target))
// review-resolution #43: profile-update-interval default "6"
interval := result.Metadata.ProfileUpdateInterval
if interval == "" {
interval = "6"
}
c.Set("Profile-Update-Interval", interval)
// review-resolution #44: cache-control: no-store
c.Set("Cache-Control", "no-store")
middleware.SetSafeResponseHeader(c, "Subscription-Userinfo", result.Metadata.SubscriptionUserinfo)
middleware.SetSafeResponseHeader(c, "Profile-Web-Page-Url", result.Metadata.ProfileWebPageUrl)
middleware.SetSafeResponseHeader(c, "Content-Disposition", result.Metadata.ContentDisposition)
middleware.SetSafeResponseHeader(c, "X-Sub-Store-Cache", result.Metadata.CacheStatus)
return c.SendString(result.Body)
}
func (d *Deps) getDownloadTarget(c fiber.Ctx) string {
explicit := c.Params("target")
if explicit != "" {
return model.NormalizeTargetAlias(explicit)
}
if t := c.Query("target"); t != "" {
return model.NormalizeTargetAlias(t)
}
return model.NormalizeTarget("", c.Get("User-Agent"))
}
func (d *Deps) rejectInvalidDownloadToken(c fiber.Ctx, resourceType, resourceId, target string) error {
token := c.Params("token")
if token == "" {
token = c.Query("token")
}
// Check global download token
if util.IsTokenValid(token, d.Cfg.Auth.DownloadToken) {
return nil
}
// Check scoped grant
if d.GrantRepo.AuthorizeScoped(token, resourceType, resourceId, target) {
return nil
}
return failed(c, "Download token is invalid", 403)
}
func (d *Deps) getTemplateConfig(templateId string) map[string]any {
if templateId == "" {
templateId = template.DefaultTemplateID
}
// Check built-in
for _, bt := range template.BuiltinTemplates {
if bt.ID == templateId {
return bt.Config
}
}
// Check DB
rec, err := d.TemplateRepo.Get(templateId)
if err == nil && rec != nil {
return rec.Config
}
return map[string]any{}
}
// --- Preview handlers ---
func (d *Deps) HandlePreviewSource(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
return failed(c, "Invalid JSON", 400)
}
settings, _ := d.SettingsRepo.Get()
if getStringValue(input["type"]) == "local" || getStringValue(input["content"]) != "" {
// Local preview
content := getStringValue(input["content"])
original := proxy.ParseProxies(proxy.DecodeMaybeBase64(content))
if len(original) == 0 {
return failed(c, "No valid proxy nodes found", 400)
}
rec := mapToSourceRecord(input)
processed := proxy.EnsureUniqueProxyNames(
applyFiltersSafe(original, rec.Filters, settings, "json", rec.ID),
)
return success(c, fiber.Map{
"original": proxy.AddPreviewIds(original),
"processed": proxy.AddPreviewIds(processed),
})
}
// Remote preview
rec := mapToSourceRecord(input)
result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{
Source: &rec,
Sources: []model.SourceRecord{rec},
Target: "json",
Settings: settings,
RequestUserAgent: c.Get("User-Agent"),
CacheRepo: d.CacheRepo,
})
if err != nil {
return failed(c, err.Error(), 400)
}
return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes})
}
func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
return failed(c, "Invalid JSON", 400)
}
settings, _ := d.SettingsRepo.Get()
rec := mapToCollectionRecord(input)
sources, _ := d.SourceRepo.List()
result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{
Collection: &rec,
Sources: sources,
Target: "json",
TemplateConfig: d.getTemplateConfig(rec.TemplateId),
Settings: settings,
RequestUserAgent: c.Get("User-Agent"),
CacheRepo: d.CacheRepo,
})
if err != nil {
return failed(c, err.Error(), 400)
}
return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes})
}
// --- Link handlers ---
func (d *Deps) HandleLinkSource(c fiber.Ctx) error {
name := c.Params("name")
sub, err := d.SourceRepo.Get(name)
if err != nil || sub == nil {
return failed(c, "Source not found", 404)
}
return success(c, d.buildDownloadLink(c, "source", sub.ID))
}
func (d *Deps) HandleLinkCollection(c fiber.Ctx) error {
name := c.Params("name")
col, err := d.CollectionRepo.Get(name)
if err != nil || col == nil {
return failed(c, "Collection not found", 404)
}
return success(c, d.buildDownloadLink(c, "collection", col.ID))
}
func (d *Deps) buildDownloadLink(c fiber.Ctx, kind, id string) fiber.Map {
target := ""
rawTarget := c.Query("target")
if rawTarget != "" {
target = model.NormalizeTargetAlias(rawTarget)
if target == "" {
return nil
}
}
path := "/download/" + kind + "/" + id
if target != "" {
path += "/" + target
}
baseURL := getPublicBaseUrl(c)
u := baseURL + path
if d.Cfg.Auth.DownloadToken != "" {
u += "?token=" + d.Cfg.Auth.DownloadToken
}
return fiber.Map{
"url": u,
"target": orDefault(target, "auto"),
"tokenIncluded": d.Cfg.Auth.DownloadToken != "",
}
}
// --- Flow info handler ---
func (d *Deps) HandleFlowInfo(c fiber.Ctx) error {
name := c.Params("name")
sub, err := d.SourceRepo.Get(name)
if err != nil || sub == nil {
return c.Status(400).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "Source not found"},
})
}
settings, _ := d.SettingsRepo.Get()
flowReq := parseFlowRequest(sub, settings)
if flowReq == nil {
return c.Status(400).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"},
})
}
headers, err := fetchFlowHeaders(flowReq)
if err != nil {
return c.Status(500).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": err.Error()},
})
}
meta := ""
if sub.Meta != nil {
if s, ok := sub.Meta["subUserinfo"].(string); ok {
meta = s
}
}
combined := strings.TrimSpace(meta + "; " + headers)
flow := parseFlowHeaders(combined)
if flow == nil {
return c.Status(400).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"},
})
}
return success(c, flow)
}
// --- Tool API handlers ---
func (d *Deps) HandleProxyParse(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
input = map[string]any{}
}
target := model.NormalizeTargetAlias(input["client"])
if target == "" {
target = model.NormalizeTargetAlias(input["platform"])
}
if target == "" {
target = model.NormalizeTargetAlias(input["target"])
}
if target == "" {
return failed(c, "Unsupported target", 400)
}
content := getStringValue(input["data"])
if content == "" {
content = getStringValue(input["content"])
}
if content == "" {
return failed(c, "Proxy content is required", 400)
}
settings, _ := d.SettingsRepo.Get()
parsed := proxy.ParseProxies(proxy.DecodeMaybeBase64(content))
if len(parsed) == 0 {
return failed(c, "No valid proxy nodes found", 400)
}
filters := parseFilters(input["filters"])
processed := proxy.EnsureUniqueProxyNames(applyFiltersSafe(parsed, filters, settings, target, "one-shot"))
var supported []model.ProxyNode
for _, p := range processed {
ptype := getStringValue(p["type"])
if model.IsTargetCompatible(ptype, target) {
supported = append(supported, p)
}
}
output, err := render.RenderTarget(supported, target, nil)
if err != nil {
return failed(c, err.Error(), 400)
}
skipped := len(processed) - len(supported)
warnings := []string{}
if skipped > 0 {
warnings = append(warnings, fmt.Sprintf("%d node(s) cannot be represented by %s", skipped, target))
}
return success(c, fiber.Map{
"par_res": output,
"content": output,
"parsed": len(parsed),
"emitted": len(supported),
"skipped": skipped,
"warnings": warnings,
})
}
func (d *Deps) HandleRuleParse(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
input = map[string]any{}
}
targetStr := getStringValue(input["client"])
if targetStr == "" {
targetStr = getStringValue(input["platform"])
}
if targetStr == "" {
targetStr = getStringValue(input["target"])
}
target := normalizeRuleTarget(targetStr)
if target == "" {
return failed(c, "Unsupported rule target", 400)
}
content := getStringValue(input["data"])
if content == "" {
content = getStringValue(input["content"])
}
if content == "" {
return failed(c, "Rule content is required", 400)
}
result := rules.ConvertRules(content, rules.RuleTarget(target))
return success(c, fiber.Map{
"par_res": result.Content,
"content": result.Content,
"parsed": result.Parsed,
"emitted": result.Emitted,
"skipped": result.Skipped,
"warnings": result.Warnings,
})
}
func (d *Deps) HandleNodeInfo(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
input = map[string]any{}
}
server := strings.Trim(getStringValue(input["server"]), "[]")
if server == "" {
return failed(c, "Node server is required", 400)
}
settings, _ := d.SettingsRepo.Get()
configured := getStringFromMap(settings, "nodeInfoApiUrl")
if configured == "" {
configured = "https://ipwho.is/{ip}"
}
if !strings.HasPrefix(configured, "https://") || !strings.Contains(configured, "{ip}") {
return failed(c, "Node info API must be an HTTPS URL containing {ip}", 400)
}
infoURL := strings.Replace(configured, "{ip}", url.QueryEscape(server), 1)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(infoURL)
if err != nil {
return failed(c, err.Error(), 502)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes))
var data map[string]any
json.Unmarshal(body, &data)
if success, ok := data["success"].(bool); ok && !success {
msg := getStringValue(data["message"])
if msg == "" {
msg = "Node info lookup failed"
}
return failed(c, msg, 502)
}
return success(c, fiber.Map{
"ip": data["ip"],
"country": data["country"],
"region": data["region"],
"city": data["city"],
"connection": data["connection"],
})
}
// --- Flow helpers ---
type flowRequest struct {
URL string
UserAgent string
Headers map[string]string
Timeout time.Duration
}
func parseFlowRequest(sub *model.SourceRecord, settings map[string]any) *flowRequest {
rawURL := sub.URL
args := parseURLArguments(rawURL)
flowURL := getStringValue(args["flowUrl"])
if flowURL == "" {
flowURL = strings.SplitN(rawURL, "#", 2)[0]
}
if noFlow, ok := args["noFlow"].(bool); ok && noFlow {
return nil
}
if !strings.HasPrefix(flowURL, "http://") && !strings.HasPrefix(flowURL, "https://") {
return nil
}
ua := getStringValue(args["flowUserAgent"])
if ua == "" {
ua = getStringFromMap(settings, "defaultFlowUserAgent")
}
if ua == "" {
ua = getStringFromMap(settings, "defaultUserAgent")
}
if ua == "" {
ua = "clash.meta/v1.19.24"
}
timeout := 30 * time.Second
if t, ok := settings["defaultTimeout"]; ok {
if n := toIntSafe(t); n > 0 {
timeout = time.Duration(n) * time.Millisecond
}
}
return &flowRequest{
URL: flowURL,
UserAgent: ua,
Headers: parseJSONHeaders(args["flowHeaders"]),
Timeout: timeout,
}
}
func parseURLArguments(rawURL string) map[string]any {
hashIdx := strings.Index(rawURL, "#")
if hashIdx < 0 {
return map[string]any{}
}
hash := rawURL[hashIdx+1:]
if hash == "" {
return map[string]any{}
}
// Try JSON first
var m map[string]any
if err := json.Unmarshal([]byte(hash), &m); err == nil {
return m
}
// Try query-string format
result := map[string]any{}
for _, pair := range strings.Split(hash, "&") {
if pair == "" {
continue
}
parts := strings.SplitN(pair, "=", 2)
key := parts[0]
if len(parts) == 1 || parts[1] == "" {
result[key] = true
} else {
val, err := url.QueryUnescape(parts[1])
if err != nil {
val = parts[1]
}
result[key] = val
}
}
return result
}
func parseJSONHeaders(v any) map[string]string {
result := map[string]string{}
if s, ok := v.(string); ok && s != "" {
var m map[string]any
json.Unmarshal([]byte(s), &m)
for k, v := range m {
result[k] = fmt.Sprintf("%v", v)
}
}
if m, ok := v.(map[string]any); ok {
for k, v := range m {
result[k] = fmt.Sprintf("%v", v)
}
}
return result
}
func fetchFlowHeaders(req *flowRequest) (string, error) {
client := &http.Client{Timeout: req.Timeout}
httpReq, err := http.NewRequest("GET", req.URL, nil)
if err != nil {
return "", err
}
httpReq.Header.Set("User-Agent", req.UserAgent)
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
resp, err := client.Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes))
bodyStr := string(body)
parts := []string{}
if h := resp.Header.Get("subscription-userinfo"); h != "" {
parts = append(parts, h)
}
if hasUploadDownload(bodyStr) {
parts = append(parts, bodyStr)
}
if appUrl := resp.Header.Get("profile-web-page-url"); appUrl != "" {
parts = append(parts, "app_url="+url.QueryEscape(appUrl))
}
if planName := resp.Header.Get("profile-title"); planName != "" {
parts = append(parts, "plan_name="+url.QueryEscape(planName))
}
return strings.Join(parts, "; "), nil
}
func hasUploadDownload(body string) bool {
return regexp.MustCompile(`(?:^|[;\n\r ])upload=`).MatchString(body)
}
func parseFlowHeaders(flowHeaders string) map[string]any {
upload := numberField(flowHeaders, "upload")
download := numberField(flowHeaders, "download")
total := numberField(flowHeaders, "total")
if download == nil || total == nil {
return nil
}
result := map[string]any{
"expires": numberField(flowHeaders, "expire"),
"total": *total,
"usage": map[string]any{"upload": *upload, "download": *download},
"remainingDays": numberField(flowHeaders, "reset_day"),
"appUrl": textField(flowHeaders, "app_url"),
"planName": textField(flowHeaders, "plan_name"),
}
return result
}
func numberField(input, key string) *float64 {
re := regexp.MustCompile(key + `=([-+]?)([0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)`)
matches := re.FindStringSubmatch(input)
if len(matches) < 3 {
return nil
}
n, err := strconv.ParseFloat(matches[1]+matches[2], 64)
if err != nil {
return nil
}
return &n
}
func textField(input, key string) string {
re := regexp.MustCompile(key + `=(.*?)\s*?(;|$)`)
matches := re.FindStringSubmatch(input)
if len(matches) < 2 {
return ""
}
val, err := url.QueryUnescape(matches[1])
if err != nil {
return matches[1]
}
return val
}
func toIntSafe(v any) int {
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
case string:
i, _ := strconv.Atoi(n)
return i
}
return 0
}
// getStringFromMap returns a string value from a map[string]any.
func getStringFromMap(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func normalizeRuleTarget(input string) string {
switch strings.ToLower(input) {
case "mihomo", "clash", "clashmeta", "clash-meta":
return "mihomo"
case "surge":
return "surge"
case "loon":
return "loon"
case "qx", "quanx", "quantumultx", "quantumult-x":
return "qx"
}
return ""
}
// applyFiltersSafe wraps filter.ApplyFilters with the correct context.
func applyFiltersSafe(nodes []model.ProxyNode, filters []model.FilterRule, settings map[string]any, target, sourceId string) []model.ProxyNode {
if filters == nil {
filters = []model.FilterRule{}
}
ctx := struct {
TargetPlatform string
SourceId string
CollectionId string
}{
TargetPlatform: target,
SourceId: sourceId,
}
_ = ctx
// Use the filter package's ApplyFilters with FilterContext
return applyFiltersWithContext(nodes, filters, settings, target, sourceId)
}
+33
View File
@@ -0,0 +1,33 @@
package handler
import "github.com/gofiber/fiber/v3"
// HandleEnv returns runtime environment info.
// Per review-resolution #5: feature flags defined, buildTimeScripts=false.
func (d *Deps) HandleEnv(c fiber.Ctx) error {
return success(c, fiber.Map{
"app": d.Cfg.App.Name,
"backend": "Go",
"version": "1.0.0",
"runtime": "Go " + goVersion(),
"storage": "SQLite",
"feature": fiber.Map{
"buildTimeScripts": false, // review-resolution #5: no JS engine
"proxyConversion": true,
"ruleConversion": true,
"scopedShares": true,
"recycleBin": true,
"nodeInfo": true,
"surgeMac": true,
},
})
}
// HandleScripts returns an empty array — review-resolution #2: no script type.
func (d *Deps) HandleScripts(c fiber.Ctx) error {
return success(c, []any{})
}
func goVersion() string {
return "1.23+"
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"github.com/peterqiu0516/sub-store/internal/filter"
"github.com/peterqiu0516/sub-store/internal/model"
)
// applyFiltersWithContext bridges the handler layer to the filter pipeline.
func applyFiltersWithContext(nodes []model.ProxyNode, filters []model.FilterRule, settings map[string]any, target, sourceId string) []model.ProxyNode {
if filters == nil {
filters = []model.FilterRule{}
}
ctx := filter.FilterContext{
TargetPlatform: target,
SourceId: sourceId,
}
return filter.ApplyFilters(nodes, filters, settings, ctx)
}
+127
View File
@@ -0,0 +1,127 @@
package handler
import (
"github.com/gofiber/fiber/v3"
"github.com/jmoiron/sqlx"
"github.com/peterqiu0516/sub-store/internal/config"
"github.com/peterqiu0516/sub-store/internal/database"
"github.com/peterqiu0516/sub-store/internal/middleware"
)
// Deps holds all shared dependencies for handlers.
type Deps struct {
Cfg *config.Config
DB *sqlx.DB
SourceRepo *database.SourceRepo
CollectionRepo *database.CollectionRepo
TemplateRepo *database.TemplateRepo
SettingsRepo *database.SettingsRepo
GrantRepo *database.GrantRepo
RecycleRepo *database.RecycleRepo
CacheRepo *database.CacheRepo
}
func NewDeps(cfg *config.Config, db *sqlx.DB) *Deps {
return &Deps{
Cfg: cfg,
DB: db,
SourceRepo: database.NewSourceRepo(db),
CollectionRepo: database.NewCollectionRepo(db),
TemplateRepo: database.NewTemplateRepo(db),
SettingsRepo: database.NewSettingsRepo(db),
GrantRepo: database.NewGrantRepo(db),
RecycleRepo: database.NewRecycleRepo(db),
CacheRepo: database.NewCacheRepo(db),
}
}
// RegisterRoutes registers all API and download routes.
func RegisterRoutes(app *fiber.App, cfg *config.Config, db *sqlx.DB) {
deps := NewDeps(cfg, db)
// Admin API group — requires admin token
api := app.Group("/api",
middleware.RequireAdmin(cfg.Auth.AdminToken),
middleware.BodyLimit(cfg.Server.BodyLimit),
)
// Basic API
api.Get("/env", deps.HandleEnv)
api.Get("/scripts", deps.HandleScripts)
api.Get("/settings", deps.HandleGetSettings)
api.Patch("/settings", deps.HandleUpdateSettings)
api.Get("/storage", deps.HandleExportStorage)
api.Post("/storage", deps.HandleImportStorage)
// Source CRUD
api.Get("/sources", deps.HandleListSources)
api.Post("/sources", deps.HandleCreateSource)
api.Put("/sources", deps.HandleSortSources)
api.Post("/sort/sources", deps.HandleSortSourcesLegacy)
api.Get("/sources/:name", deps.HandleGetSource)
api.Patch("/sources/:name", deps.HandleUpdateSource)
api.Delete("/sources/:name", deps.HandleDeleteSource)
// Collection CRUD
api.Get("/collections", deps.HandleListCollections)
api.Post("/collections", deps.HandleCreateCollection)
api.Put("/collections", deps.HandleSortCollections)
api.Post("/sort/collections", deps.HandleSortCollectionsLegacy)
api.Get("/collections/:name", deps.HandleGetCollection)
api.Patch("/collections/:name", deps.HandleUpdateCollection)
api.Delete("/collections/:name", deps.HandleDeleteCollection)
// Template CRUD
api.Get("/templates", deps.HandleListTemplates)
api.Post("/templates", deps.HandleCreateTemplate)
api.Get("/templates/:name", deps.HandleGetTemplate)
api.Patch("/templates/:name", deps.HandleUpdateTemplate)
api.Delete("/templates/:name", deps.HandleDeleteTemplate)
// Shares (download grants)
api.Get("/shares", deps.HandleListShares)
api.Post("/shares", deps.HandleCreateShare)
api.Patch("/shares/:id", deps.HandleUpdateShare)
api.Delete("/shares/:id", deps.HandleDeleteShare)
// Recycle bin
api.Get("/recycle-bin", deps.HandleListRecycleBin)
api.Delete("/recycle-bin/:id", deps.HandleDeleteRecycleBinEntry)
api.Post("/recycle-bin/:id/restore", deps.HandleRestoreRecycleBinEntry)
// Preview
api.Post("/preview/source", deps.HandlePreviewSource)
api.Post("/preview/collection", deps.HandlePreviewCollection)
// Download links
api.Get("/link/source/:name", deps.HandleLinkSource)
api.Get("/link/collection/:name", deps.HandleLinkCollection)
// Flow info
api.Get("/source/flow/:name", deps.HandleFlowInfo)
// Tool APIs
api.Post("/proxy/parse", deps.HandleProxyParse)
api.Post("/rule/parse", deps.HandleRuleParse)
api.Post("/utils/node-info", deps.HandleNodeInfo)
// Public download routes — no admin token required, uses download token
app.Get("/download/collection/:name", deps.HandleDownloadCollection)
app.Get("/download/collection/:name/:target", deps.HandleDownloadCollection)
app.Get("/download/source/:name", deps.HandleDownloadSource)
app.Get("/download/source/:name/:target", deps.HandleDownloadSource)
}
// success sends a success JSON response.
func success(c fiber.Ctx, data any) error {
return c.JSON(fiber.Map{"status": "success", "data": data})
}
// failed sends a failed JSON response.
func failed(c fiber.Ctx, message string, code int) error {
return c.Status(code).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": code, "message": message},
})
}
+203
View File
@@ -0,0 +1,203 @@
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
}
+221
View File
@@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/peterqiu0516/sub-store/internal/middleware"
"github.com/peterqiu0516/sub-store/internal/model"
)
// --- Share (download grant) handlers ---
func (d *Deps) HandleListShares(c fiber.Ctx) error {
grants, err := d.GrantRepo.List()
if err != nil {
return failed(c, "Failed to list shares", 500)
}
return success(c, grants)
}
func (d *Deps) HandleCreateShare(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
return failed(c, "Invalid JSON", 400)
}
resourceType := getStringValue(input["resourceType"])
if resourceType != "source" && resourceType != "collection" {
return failed(c, "Share resourceType and resourceId are required", 400)
}
resourceId := getStringValue(input["resourceId"])
if resourceId == "" {
return failed(c, "Share resourceType and resourceId are required", 400)
}
// Check resource exists
if resourceType == "source" {
s, _ := d.SourceRepo.Get(resourceId)
if s == nil {
return failed(c, "Share resource does not exist", 404)
}
} else {
col, _ := d.CollectionRepo.Get(resourceId)
if col == nil {
return failed(c, "Share resource does not exist", 404)
}
}
target := ""
if input["target"] != nil {
target = model.NormalizeTargetAlias(input["target"])
if input["target"] != "" && target == "" {
return failed(c, "Unsupported target", 400)
}
}
var expiresAt *int64
if v, ok := input["expiresAt"]; ok && v != nil {
if n, ok := v.(float64); ok && n > 0 {
e := int64(n)
expiresAt = &e
}
} else if v, ok := input["expiresIn"]; ok && v != nil {
if n, ok := v.(float64); ok && n > 0 {
e := time.Now().UnixMilli() + int64(n)*1000
expiresAt = &e
}
}
grant, token, err := d.GrantRepo.Create(resourceType, resourceId, target, expiresAt)
if err != nil {
return failed(c, "Failed to create share", 500)
}
// Build download URL
path := "/download/" + resourceType + "/" + resourceId
if target != "" {
path += "/" + target
}
url := getPublicBaseUrl(c) + path + "?token=" + token
return success(c, fiber.Map{
"id": grant.ID,
"resourceType": grant.ResourceType,
"resourceId": grant.ResourceId,
"target": grant.Target,
"expiresAt": grant.ExpiresAt,
"enabled": grant.Enabled,
"createdAt": grant.CreatedAt,
"updatedAt": grant.UpdatedAt,
"token": token,
"url": url,
})
}
func (d *Deps) HandleUpdateShare(c fiber.Ctx) error {
id := c.Params("id")
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
return failed(c, "Invalid JSON", 400)
}
var enabled *bool
if v, ok := input["enabled"].(bool); ok {
enabled = &v
}
var expiresAt *int64
if v, ok := input["expiresAt"]; ok {
if v == nil {
expiresAt = new(int64) // 0 means nil
} else if n, ok := v.(float64); ok {
e := int64(n)
expiresAt = &e
}
}
updated, err := d.GrantRepo.Update(id, enabled, expiresAt)
if err != nil || updated == nil {
return failed(c, "Share not found", 404)
}
return success(c, updated)
}
func (d *Deps) HandleDeleteShare(c fiber.Ctx) error {
id := c.Params("id")
snapshot, err := d.GrantRepo.GetSnapshot(id)
if err != nil || snapshot == nil {
return failed(c, "Share not found", 404)
}
_, err = d.RecycleRepo.ArchiveAndDelete("share", id, snapshot, "DELETE FROM download_grants WHERE id = ?", id)
if err != nil {
return failed(c, "Failed to delete share", 500)
}
return success(c, fiber.Map{"deleted": true})
}
// --- Recycle bin handlers ---
func (d *Deps) HandleListRecycleBin(c fiber.Ctx) error {
entries, err := d.RecycleRepo.List()
if err != nil {
return failed(c, "Failed to list recycle bin", 500)
}
return success(c, entries)
}
func (d *Deps) HandleDeleteRecycleBinEntry(c fiber.Ctx) error {
id := c.Params("id")
entry, err := d.RecycleRepo.Get(id)
if err != nil || entry == nil {
return failed(c, "Recycle entry not found", 404)
}
if err := d.RecycleRepo.Delete(id); err != nil {
return failed(c, "Failed to delete", 500)
}
return success(c, fiber.Map{"deleted": true})
}
func (d *Deps) HandleRestoreRecycleBinEntry(c fiber.Ctx) error {
id := c.Params("id")
entry, err := d.RecycleRepo.Get(id)
if err != nil || entry == nil {
return failed(c, "Recycle entry not found", 404)
}
resourceType := getStringValue(entry["resourceType"])
resourceId := getStringValue(entry["resourceId"])
snapshot := getMapValue(entry["snapshot"])
switch resourceType {
case "source":
existing, _ := d.SourceRepo.Get(resourceId)
if existing != nil {
return failed(c, "Source id already exists", 409)
}
d.SourceRepo.Upsert(mapToSourceRecord(snapshot))
case "collection":
existing, _ := d.CollectionRepo.Get(resourceId)
if existing != nil {
return failed(c, "Collection id already exists", 409)
}
d.CollectionRepo.Upsert(mapToCollectionRecord(snapshot))
case "template":
existing, _ := d.TemplateRepo.Get(resourceId)
if existing != nil {
return failed(c, "Template id already exists", 409)
}
d.TemplateRepo.Upsert(mapToTemplateRecord(snapshot))
case "share":
existing, _ := d.GrantRepo.Get(resourceId)
if existing != nil {
return failed(c, "Share id already exists", 409)
}
// review-resolution #38: restore tokenHash
d.GrantRepo.RestoreFromSnapshot(snapshot)
}
d.RecycleRepo.Delete(id)
return success(c, fiber.Map{
"restored": true,
"resourceType": resourceType,
"resourceId": resourceId,
})
}
// getPublicBaseUrl returns the public base URL for download links.
// Per review-resolution #11: PUBLIC_DOWNLOAD_HOSTS config, fallback to request origin.
func getPublicBaseUrl(c fiber.Ctx) string {
// This is set by the handler using deps config
publicHosts := c.Locals("publicDownloadHosts")
if hosts, ok := publicHosts.(string); ok && hosts != "" {
parts := strings.Split(hosts, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
return "https://" + p
}
}
}
// Use X-Forwarded-Proto or default to http
proto := c.Get("X-Forwarded-Proto")
if proto == "" {
proto = "http"
}
return proto + "://" + c.Hostname()
}
// SetSafeResponseHeader wraps middleware.SetSafeResponseHeader for handler use.
var SetSafeResponseHeader = middleware.SetSafeResponseHeader
+15
View File
@@ -0,0 +1,15 @@
package handler
import "gopkg.in/yaml.v3"
// parseYamlConfig parses a YAML string into a map.
func parseYamlConfig(content string) map[string]any {
var m map[string]any
if err := yaml.Unmarshal([]byte(content), &m); err != nil {
return map[string]any{}
}
if m == nil {
return map[string]any{}
}
return m
}