chore: sub-store Go 重写项目初始化
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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+"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user