refactor: simplify auth and subscription links

This commit is contained in:
2026-07-28 10:32:21 +08:00
parent b7a7cd9c71
commit fc8c23beee
41 changed files with 739 additions and 1468 deletions
-241
View File
@@ -1,241 +0,0 @@
package database
import (
"database/sql"
"time"
"github.com/jmoiron/sqlx"
"github.com/google/uuid"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
)
type GrantRepo struct {
db *sqlx.DB
}
func NewGrantRepo(db *sqlx.DB) *GrantRepo {
return &GrantRepo{db: db}
}
type grantRow struct {
ID string `db:"id"`
TokenHash string `db:"token_hash"`
ResourceType string `db:"resource_type"`
ResourceID string `db:"resource_id"`
Target string `db:"target"`
ExpiresAt *int64 `db:"expires_at"`
Enabled int `db:"enabled"`
CreatedAt int64 `db:"created_at"`
UpdatedAt int64 `db:"updated_at"`
}
// CreateGrant creates a new download grant, returning the grant record and the plaintext token.
func (r *GrantRepo) Create(resourceType, resourceID, target string, expiresAt *int64) (model.DownloadGrantRecord, string, error) {
now := time.Now().UnixMilli()
id := uuid.New().String()
token, err := util.RandomToken()
if err != nil {
return model.DownloadGrantRecord{}, "", err
}
tokenHash := util.SHA256Hex(token)
_, err = r.db.Exec(
`INSERT INTO download_grants (id, token_hash, resource_type, resource_id, target, expires_at, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
id, tokenHash, resourceType, resourceID, target, expiresAt, now, now,
)
if err != nil {
return model.DownloadGrantRecord{}, "", err
}
rec := model.DownloadGrantRecord{
ID: id,
ResourceType: resourceType,
ResourceId: resourceID,
Target: target,
ExpiresAt: expiresAt,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
return rec, token, nil
}
func (r *GrantRepo) List() ([]model.DownloadGrantRecord, error) {
var rows []grantRow
if err := r.db.Select(&rows, "SELECT * FROM download_grants ORDER BY created_at DESC"); err != nil {
return nil, err
}
result := make([]model.DownloadGrantRecord, 0, len(rows))
for _, row := range rows {
result = append(result, grantFromRow(row))
}
return result, nil
}
func (r *GrantRepo) Get(id string) (*model.DownloadGrantRecord, error) {
var row grantRow
if err := r.db.Get(&row, "SELECT * FROM download_grants WHERE id = ?", id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
rec := grantFromRow(row)
return &rec, nil
}
// GetSnapshot returns the grant record with the token hash for snapshot/restore.
func (r *GrantRepo) GetSnapshot(id string) (map[string]any, error) {
var row grantRow
if err := r.db.Get(&row, "SELECT * FROM download_grants WHERE id = ?", id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
snapshot := map[string]any{
"id": row.ID,
"tokenHash": row.TokenHash,
"resourceType": row.ResourceType,
"resourceId": row.ResourceID,
"target": row.Target,
"expiresAt": row.ExpiresAt,
"enabled": row.Enabled != 0,
"createdAt": row.CreatedAt,
"updatedAt": row.UpdatedAt,
}
return snapshot, nil
}
func (r *GrantRepo) Update(id string, enabled *bool, expiresAt *int64) (*model.DownloadGrantRecord, error) {
existing, err := r.Get(id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, nil
}
now := time.Now().UnixMilli()
if enabled != nil {
existing.Enabled = *enabled
}
if expiresAt != nil {
existing.ExpiresAt = expiresAt
}
// If expiresAt is explicitly set to 0, treat as nil (never expire)
if expiresAt != nil && *expiresAt == 0 {
existing.ExpiresAt = nil
}
_, err = r.db.Exec(
"UPDATE download_grants SET enabled = ?, expires_at = ?, updated_at = ? WHERE id = ?",
boolToInt(existing.Enabled), existing.ExpiresAt, now, id,
)
if err != nil {
return nil, err
}
return existing, nil
}
func (r *GrantRepo) Delete(id string) error {
_, err := r.db.Exec("DELETE FROM download_grants WHERE id = ?", id)
return err
}
// AuthorizeScoped checks if a token is valid for a scoped download.
func (r *GrantRepo) AuthorizeScoped(token, resourceType, resourceID, target string) bool {
if token == "" {
return false
}
tokenHash := util.SHA256Hex(token)
var row grantRow
err := r.db.Get(&row,
`SELECT * FROM download_grants WHERE token_hash = ? AND enabled = 1 AND resource_type = ? AND resource_id = ? LIMIT 1`,
tokenHash, resourceType, resourceID,
)
if err != nil {
return false
}
if row.ExpiresAt != nil && *row.ExpiresAt <= time.Now().UnixMilli() {
return false
}
restrictedTarget := model.NormalizeTargetAlias(row.Target)
return restrictedTarget == "" || restrictedTarget == target
}
// RestoreFromSnapshot inserts a grant from a recycled snapshot.
// Per review-resolution #38: restores tokenHash to download_grants table.
func (r *GrantRepo) RestoreFromSnapshot(snapshot map[string]any) error {
now := time.Now().UnixMilli()
id := getString(snapshot, "id")
tokenHash := getString(snapshot, "tokenHash")
resourceType := getString(snapshot, "resourceType")
resourceID := getString(snapshot, "resourceId")
if resourceType != "collection" {
resourceType = "source"
}
target := getString(snapshot, "target")
enabled := true
if e, ok := snapshot["enabled"].(bool); ok && !e {
enabled = false
}
var expiresAt *int64
if e, ok := snapshot["expiresAt"]; ok && e != nil {
if n, ok := e.(float64); ok && n > 0 {
v := int64(n)
expiresAt = &v
}
}
createdAt := getInt64(snapshot, "createdAt")
if createdAt == 0 {
createdAt = now
}
_, err := r.db.Exec(
`INSERT INTO download_grants (id, token_hash, resource_type, resource_id, target, expires_at, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, tokenHash, resourceType, resourceID, target, expiresAt, boolToInt(enabled), createdAt, now,
)
return err
}
func grantFromRow(row grantRow) model.DownloadGrantRecord {
resourceType := "source"
if row.ResourceType == "collection" {
resourceType = "collection"
}
return model.DownloadGrantRecord{
ID: row.ID,
ResourceType: resourceType,
ResourceId: row.ResourceID,
Target: row.Target,
ExpiresAt: row.ExpiresAt,
Enabled: row.Enabled != 0,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func getString(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func getInt64(m map[string]any, key string) int64 {
if v, ok := m[key]; ok {
switch n := v.(type) {
case int64:
return n
case float64:
return int64(n)
}
}
return 0
}
@@ -1,25 +1,7 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS download_grants (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection')),
resource_id TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
expires_at INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_download_grants_token_hash
ON download_grants(token_hash);
CREATE INDEX IF NOT EXISTS idx_download_grants_resource
ON download_grants(resource_type, resource_id);
CREATE TABLE IF NOT EXISTS recycle_bin (
id TEXT PRIMARY KEY,
resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection', 'template', 'share')),
resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection', 'template')),
resource_id TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
deleted_at INTEGER NOT NULL
-265
View File
@@ -212,271 +212,6 @@ func TestTemplateDelete(t *testing.T) {
}
}
// --- GrantRepo: Get, GetSnapshot, Update, Delete, RestoreFromSnapshot ---
func TestGrantGet(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
got, err := repo.Get(rec.ID)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if got == nil {
t.Fatal("expected non-nil grant")
}
if got.ResourceType != "source" {
t.Errorf("expected resourceType source, got %s", got.ResourceType)
}
if got.ResourceId != "src1" {
t.Errorf("expected resourceId src1, got %s", got.ResourceId)
}
}
func TestGrantGet_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
got, err := repo.Get("nonexistent")
if err != nil {
t.Fatalf("expected nil error for not found, got %v", err)
}
if got != nil {
t.Error("expected nil for not found")
}
}
func TestGrantGetSnapshot(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
snap, err := repo.GetSnapshot(rec.ID)
if err != nil {
t.Fatalf("getSnapshot failed: %v", err)
}
if snap == nil {
t.Fatal("expected non-nil snapshot")
}
if snap["id"] != rec.ID {
t.Errorf("expected id %s, got %v", rec.ID, snap["id"])
}
if snap["resourceType"] != "source" {
t.Errorf("expected resourceType source, got %v", snap["resourceType"])
}
if snap["enabled"] != true {
t.Errorf("expected enabled true, got %v", snap["enabled"])
}
if snap["tokenHash"] == "" {
t.Error("expected non-empty tokenHash")
}
}
func TestGrantGetSnapshot_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
snap, err := repo.GetSnapshot("nonexistent")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if snap != nil {
t.Error("expected nil snapshot for not found")
}
}
func TestGrantUpdate(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
// Disable
enabled := false
updated, err := repo.Update(rec.ID, &enabled, nil)
if err != nil {
t.Fatalf("update failed: %v", err)
}
if updated.Enabled {
t.Error("expected enabled false after update")
}
// Set expiry
exp := int64(9999999999000)
updated, err = repo.Update(rec.ID, nil, &exp)
if err != nil {
t.Fatalf("update expiry failed: %v", err)
}
if updated.ExpiresAt == nil || *updated.ExpiresAt != exp {
t.Errorf("expected expiresAt %d, got %v", exp, updated.ExpiresAt)
}
// Set expiry to 0 -> treated as nil (never expire)
zero := int64(0)
updated, err = repo.Update(rec.ID, nil, &zero)
if err != nil {
t.Fatalf("update expiry zero failed: %v", err)
}
if updated.ExpiresAt != nil {
t.Errorf("expected nil expiresAt for 0, got %v", updated.ExpiresAt)
}
}
func TestGrantUpdate_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
updated, err := repo.Update("nonexistent", nil, nil)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if updated != nil {
t.Error("expected nil for not found")
}
}
func TestGrantDelete(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
err := repo.Delete(rec.ID)
if err != nil {
t.Fatalf("delete failed: %v", err)
}
got, _ := repo.Get(rec.ID)
if got != nil {
t.Error("expected nil after delete")
}
}
func TestGrantRestoreFromSnapshot(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
snapshot := map[string]any{
"id": "restored-grant",
"tokenHash": "abc123",
"resourceType": "collection",
"resourceId": "col1",
"target": "surge",
"enabled": true,
"createdAt": float64(1000),
}
err := repo.RestoreFromSnapshot(snapshot)
if err != nil {
t.Fatalf("restore failed: %v", err)
}
got, _ := repo.Get("restored-grant")
if got == nil {
t.Fatal("expected restored grant")
}
if got.ResourceType != "collection" {
t.Errorf("expected resourceType collection, got %s", got.ResourceType)
}
if got.Target != "surge" {
t.Errorf("expected target surge, got %s", got.Target)
}
}
func TestGrantRestoreFromSnapshot_DefaultsAndExpiry(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
// resourceType not "collection" -> defaults to "source"; enabled false; expiresAt positive
snapshot := map[string]any{
"id": "grant-defaults",
"tokenHash": "hash2",
"resourceType": "source",
"resourceId": "src1",
"target": "",
"enabled": false,
"expiresAt": float64(5000),
}
err := repo.RestoreFromSnapshot(snapshot)
if err != nil {
t.Fatalf("restore failed: %v", err)
}
got, _ := repo.Get("grant-defaults")
if got == nil {
t.Fatal("expected restored grant")
}
if got.ResourceType != "source" {
t.Errorf("expected source, got %s", got.ResourceType)
}
if got.Enabled {
t.Error("expected enabled false")
}
if got.ExpiresAt == nil || *got.ExpiresAt != 5000 {
t.Errorf("expected expiresAt 5000, got %v", got.ExpiresAt)
}
}
func TestGrantRestoreFromSnapshot_NoCreatedAt(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
snapshot := map[string]any{
"id": "grant-no-created",
"tokenHash": "hash3",
"resourceType": "source",
"resourceId": "src1",
}
err := repo.RestoreFromSnapshot(snapshot)
if err != nil {
t.Fatalf("restore failed: %v", err)
}
got, _ := repo.Get("grant-no-created")
if got == nil {
t.Fatal("expected restored grant")
}
if got.CreatedAt == 0 {
t.Error("expected non-zero createdAt (defaulted to now)")
}
}
func TestGrantAuthorizeScoped_EmptyToken(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
if repo.AuthorizeScoped("", "source", "src1", "mihomo") {
t.Error("expected false for empty token")
}
}
func TestGrantAuthorizeScoped_ExpiredGrant(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
exp := time.Now().UnixMilli() - 1000
rec, token, _ := repo.Create("source", "src1", "mihomo", &exp)
_ = rec
if repo.AuthorizeScoped(token, "source", "src1", "mihomo") {
t.Error("expected false for expired grant")
}
}
func TestGrantAuthorizeScoped_UnrestrictedTarget(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
// target = "" means unrestricted
_, token, _ := repo.Create("source", "src1", "", nil)
if !repo.AuthorizeScoped(token, "source", "src1", "surge") {
t.Error("expected true for unrestricted target")
}
if !repo.AuthorizeScoped(token, "source", "src1", "mihomo") {
t.Error("expected true for unrestricted target (mihomo)")
}
}
// --- RecycleRepo.parseSnapshot edge cases (via List/Get) ---
func TestParseSnapshot_Empty(t *testing.T) {
+1 -43
View File
@@ -178,7 +178,7 @@ func TestSettingsCRUD(t *testing.T) {
// Update
updated, err := repo.Update(map[string]any{
"theme": map[string]any{"name": "dark"},
"theme": map[string]any{"name": "dark"},
"appName": "MyStore",
})
if err != nil {
@@ -198,48 +198,6 @@ func TestSettingsCRUD(t *testing.T) {
}
}
func TestGrantCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
// Create
grant, token, err := repo.Create("source", "test-src", "mihomo", nil)
if err != nil {
t.Fatalf("create failed: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
if grant.ID == "" {
t.Error("expected non-empty grant id")
}
// List
list, err := repo.List()
if err != nil {
t.Fatalf("list failed: %v", err)
}
if len(list) != 1 {
t.Errorf("expected 1 grant, got %d", len(list))
}
// Authorize with correct token
if !repo.AuthorizeScoped(token, "source", "test-src", "mihomo") {
t.Error("expected authorization to succeed")
}
// Authorize with wrong resource
if repo.AuthorizeScoped(token, "collection", "test-src", "mihomo") {
t.Error("expected authorization to fail for wrong resource type")
}
// Authorize with wrong target
if repo.AuthorizeScoped(token, "source", "test-src", "surge") {
t.Error("expected authorization to fail for wrong target")
}
}
func TestCacheCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
+71 -34
View File
@@ -126,10 +126,6 @@ func (d *Deps) rejectInvalidDownloadToken(c fiber.Ctx, resourceType, resourceId,
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)
}
@@ -190,7 +186,12 @@ func (d *Deps) HandlePreviewSource(c fiber.Ctx) error {
if err != nil {
return failed(c, err.Error(), 400)
}
return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes})
return success(c, fiber.Map{
"body": result.Body,
"nodes": result.Nodes,
"originalCount": result.OriginalNodes,
"processed": previewNodesFromBody(result.Body),
})
}
func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error {
@@ -213,7 +214,22 @@ func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error {
if err != nil {
return failed(c, err.Error(), 400)
}
return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes})
return success(c, fiber.Map{
"body": result.Body,
"nodes": result.Nodes,
"originalCount": result.OriginalNodes,
"processed": previewNodesFromBody(result.Body),
})
}
func previewNodesFromBody(body string) []model.ProxyNode {
var payload struct {
Proxies []model.ProxyNode `json:"proxies"`
}
if err := json.Unmarshal([]byte(body), &payload); err != nil {
return nil
}
return proxy.AddPreviewIds(payload.Proxies)
}
// --- Link handlers ---
@@ -245,19 +261,19 @@ func (d *Deps) buildDownloadLink(c fiber.Ctx, kind, id string) fiber.Map {
return nil
}
}
path := "/download/" + kind + "/" + id
if target != "" {
path += "/" + target
}
baseURL := getPublicBaseUrl(c)
u := baseURL + path
path := "/" + kind + "s/" + id
query := ""
if d.Cfg.Auth.DownloadToken != "" {
u += "?token=" + d.Cfg.Auth.DownloadToken
path += "/" + url.PathEscape(d.Cfg.Auth.DownloadToken)
}
if target != "" {
query = "?target=" + url.QueryEscape(target)
}
return fiber.Map{
"url": u,
"target": orDefault(target, "auto"),
"tokenIncluded": d.Cfg.Auth.DownloadToken != "",
"url": baseURL + path + query,
"target": orDefault(target, "auto"),
"tokenIncluded": d.Cfg.Auth.DownloadToken != "",
}
}
@@ -352,11 +368,11 @@ func (d *Deps) HandleProxyParse(c fiber.Ctx) error {
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,
"par_res": output,
"content": output,
"parsed": len(parsed),
"emitted": len(supported),
"skipped": skipped,
"warnings": warnings,
})
}
@@ -386,15 +402,36 @@ func (d *Deps) HandleRuleParse(c fiber.Ctx) error {
}
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,
"par_res": result.Content,
"content": result.Content,
"parsed": result.Parsed,
"emitted": result.Emitted,
"skipped": result.Skipped,
"warnings": result.Warnings,
})
}
func (d *Deps) HandleProxyURI(c fiber.Ctx) error {
var input model.ProxyNode
if err := json.Unmarshal(c.Body(), &input); err != nil {
return failed(c, "Invalid JSON", 400)
}
if getStringValue(input["name"]) == "" {
input["name"] = getStringValue(input["remarks"])
}
if getStringValue(input["server"]) == "" {
input["server"] = getStringValue(input["address"])
}
if getStringValue(input["network"]) == "" {
input["network"] = getStringValue(input["transport"])
}
uri := render.ToProxyUri(input)
if uri == "" {
return failed(c, "Unsupported proxy node", 400)
}
return success(c, fiber.Map{"uri": uri})
}
func (d *Deps) HandleNodeInfo(c fiber.Ctx) error {
var input map[string]any
if err := json.Unmarshal(c.Body(), &input); err != nil {
@@ -430,10 +467,10 @@ func (d *Deps) HandleNodeInfo(c fiber.Ctx) error {
return failed(c, msg, 502)
}
return success(c, fiber.Map{
"ip": data["ip"],
"country": data["country"],
"region": data["region"],
"city": data["city"],
"ip": data["ip"],
"country": data["country"],
"region": data["region"],
"city": data["city"],
"connection": data["connection"],
})
}
@@ -581,12 +618,12 @@ func parseFlowHeaders(flowHeaders string) map[string]any {
return nil
}
result := map[string]any{
"expires": numberField(flowHeaders, "expire"),
"total": *total,
"usage": map[string]any{"upload": *upload, "download": *download},
"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"),
"appUrl": textField(flowHeaders, "app_url"),
"planName": textField(flowHeaders, "plan_name"),
}
return result
}
-1
View File
@@ -15,7 +15,6 @@ func (d *Deps) HandleEnv(c fiber.Ctx) error {
"buildTimeScripts": false, // review-resolution #5: no JS engine
"proxyConversion": true,
"ruleConversion": true,
"scopedShares": true,
"recycleBin": true,
"nodeInfo": true,
"surgeMac": true,
+90 -223
View File
@@ -1,8 +1,10 @@
package handler
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
@@ -41,8 +43,8 @@ func newTestDeps(t *testing.T) *Deps {
db := newTestDB(t)
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
App: config.AppConfig{Name: "Sub-Store", Version: "1.0.0"},
Auth: config.AuthConfig{AdminToken: "admin-tok", DownloadToken: "dl-tok"},
App: config.AppConfig{Name: "Sub-Store", Version: "1.0.0"},
Auth: config.AuthConfig{AdminToken: "admin-tok", DownloadToken: "dl-tok"},
Server: config.ServerConfig{BodyLimit: 4 * 1024 * 1024},
}
return NewDeps(cfg, db)
@@ -87,11 +89,6 @@ func registerHandlers(app *fiber.App, deps *Deps) {
app.Patch("/api/templates/:name", deps.HandleUpdateTemplate)
app.Delete("/api/templates/:name", deps.HandleDeleteTemplate)
app.Get("/api/shares", deps.HandleListShares)
app.Post("/api/shares", deps.HandleCreateShare)
app.Patch("/api/shares/:id", deps.HandleUpdateShare)
app.Delete("/api/shares/:id", deps.HandleDeleteShare)
app.Get("/api/recycle-bin", deps.HandleListRecycleBin)
app.Delete("/api/recycle-bin/:id", deps.HandleDeleteRecycleBinEntry)
app.Post("/api/recycle-bin/:id/restore", deps.HandleRestoreRecycleBinEntry)
@@ -106,12 +103,11 @@ func registerHandlers(app *fiber.App, deps *Deps) {
app.Post("/api/proxy/parse", deps.HandleProxyParse)
app.Post("/api/rule/parse", deps.HandleRuleParse)
app.Post("/api/utils/proxy-uri", deps.HandleProxyURI)
app.Post("/api/utils/node-info", deps.HandleNodeInfo)
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)
app.Get("/sources/:name/:token", deps.HandleDownloadSource)
app.Get("/collections/:name/:token", deps.HandleDownloadCollection)
}
// doRequest performs a request against the app and returns status code + body.
@@ -181,19 +177,19 @@ func TestGetBuiltinTemplateIDs(t *testing.T) {
func TestValidateRecordId(t *testing.T) {
cases := []struct {
id string
label string
id string
label string
wantErr bool
}{
{"valid-id", "Source", false},
{"valid_id_123", "Source", false},
{"abc", "Collection", false},
{"", "Source", true},
{"UPPER", "Source", true}, // uppercase not allowed
{"has space", "Source", true}, // space not allowed
{"has/slash", "Source", true}, // slash not allowed
{"with.dot", "Source", true}, // dot not allowed
{strings.Repeat("a", 65), "Source", true}, // too long
{"UPPER", "Source", true}, // uppercase not allowed
{"has space", "Source", true}, // space not allowed
{"has/slash", "Source", true}, // slash not allowed
{"with.dot", "Source", true}, // dot not allowed
{strings.Repeat("a", 65), "Source", true}, // too long
{strings.Repeat("a", 64), "Source", false}, // max length
}
for _, tc := range cases {
@@ -301,13 +297,13 @@ func TestMapToSourceRecord(t *testing.T) {
func TestMapToCollectionRecord(t *testing.T) {
in := map[string]any{
"name": "My Col",
"sourceIds": []any{"s1", "s2", "", 123},
"templateId": "default",
"ignoreFailed": false,
"enabled": false,
"meta": map[string]any{"k": "v"},
"filters": []any{map[string]any{"type": "rename"}},
"name": "My Col",
"sourceIds": []any{"s1", "s2", "", 123},
"templateId": "default",
"ignoreFailed": false,
"enabled": false,
"meta": map[string]any{"k": "v"},
"filters": []any{map[string]any{"type": "rename"}},
}
rec := mapToCollectionRecord(in)
if rec.ID == "" {
@@ -687,19 +683,19 @@ func TestToIntSafe(t *testing.T) {
func TestNormalizeRuleTarget(t *testing.T) {
cases := map[string]string{
"mihomo": "mihomo",
"Clash": "mihomo",
"clashmeta": "mihomo",
"clash-meta": "mihomo",
"surge": "surge",
"SURGE": "surge",
"loon": "loon",
"qx": "qx",
"quanx": "qx",
"quantumultx": "qx",
"quantumult-x": "qx",
"unknown": "",
"": "",
"mihomo": "mihomo",
"Clash": "mihomo",
"clashmeta": "mihomo",
"clash-meta": "mihomo",
"surge": "surge",
"SURGE": "surge",
"loon": "loon",
"qx": "qx",
"quanx": "qx",
"quantumultx": "qx",
"quantumult-x": "qx",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeRuleTarget(in); got != want {
@@ -834,7 +830,7 @@ func TestParseJSONHeaders(t *testing.T) {
func TestParseFlowRequest(t *testing.T) {
settings := map[string]any{
"defaultFlowUserAgent": "custom-flow-ua",
"defaultTimeout": "5000",
"defaultTimeout": "5000",
}
// valid remote URL
sub := &model.SourceRecord{ID: "s1", Type: "remote", URL: "https://x.com/sub#flowUrl=https://f.com&flowUserAgent=myUA"}
@@ -1378,106 +1374,6 @@ func TestHandleCreateTemplateUpsertError(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Share (download grant) handlers
// ---------------------------------------------------------------------------
func TestShareHandlers(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// seed a source
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
// List empty
code, _ := doRequest(t, app, "GET", "/api/shares", "", nil)
assertStatus(t, "ListShares empty", code, 200)
// Create - bad JSON
code, _ = doRequest(t, app, "POST", "/api/shares", "not json", nil)
assertStatus(t, "CreateShare bad json", code, 400)
// Create - bad resourceType
code, _ = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"bogus","resourceId":"s1"}`, nil)
assertStatus(t, "CreateShare bad type", code, 400)
// Create - missing resourceId
code, _ = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source"}`, nil)
assertStatus(t, "CreateShare no id", code, 400)
// Create - resource doesn't exist
code, _ = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"nope"}`, nil)
assertStatus(t, "CreateShare no resource", code, 404)
// Create - success with source
code, body := doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"s1"}`, nil)
assertStatus(t, "CreateShare", code, 200)
d := body["data"].(map[string]any)
shareID := d["id"].(string)
if d["token"] == "" {
t.Error("expected non-empty token")
}
if d["url"] == "" {
t.Error("expected non-empty url")
}
// Create - success with collection (seed one)
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, body = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"collection","resourceId":"c1"}`, nil)
assertStatus(t, "CreateShare collection", code, 200)
// Create - with target + expiresIn
code, body = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"s1","target":"mihomo","expiresIn":3600}`, nil)
assertStatus(t, "CreateShare target+expiresIn", code, 200)
// Create - with expiresAt
code, body = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"s1","expiresAt":9999999999999}`, nil)
assertStatus(t, "CreateShare expiresAt", code, 200)
// Create - with unsupported target
code, _ = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"s1","target":"bogus"}`, nil)
assertStatus(t, "CreateShare bad target", code, 400)
// List - has items
code, body = doRequest(t, app, "GET", "/api/shares", "", nil)
assertStatus(t, "ListShares", code, 200)
data := body["data"].([]any)
if len(data) < 3 {
t.Errorf("ListShares len = %d, want >= 3", len(data))
}
// Update - not found
code, _ = doRequest(t, app, "PATCH", "/api/shares/nope", `{"enabled":false}`, nil)
assertStatus(t, "UpdateShare not found", code, 404)
// Update - bad JSON
code, _ = doRequest(t, app, "PATCH", "/api/shares/"+shareID, "not json", nil)
assertStatus(t, "UpdateShare bad json", code, 400)
// Update - success (disable)
code, body = doRequest(t, app, "PATCH", "/api/shares/"+shareID, `{"enabled":false}`, nil)
assertStatus(t, "UpdateShare disable", code, 200)
// Update - success (expiresAt nil)
code, body = doRequest(t, app, "PATCH", "/api/shares/"+shareID, `{"expiresAt":null}`, nil)
assertStatus(t, "UpdateShare expiresAt null", code, 200)
// Update - success (expiresAt number)
code, body = doRequest(t, app, "PATCH", "/api/shares/"+shareID, `{"expiresAt":1234567890}`, nil)
assertStatus(t, "UpdateShare expiresAt number", code, 200)
// Delete - not found
code, _ = doRequest(t, app, "DELETE", "/api/shares/nope", "", nil)
assertStatus(t, "DeleteShare not found", code, 404)
// Delete - success
code, body = doRequest(t, app, "DELETE", "/api/shares/"+shareID, "", nil)
assertStatus(t, "DeleteShare", code, 200)
d = body["data"].(map[string]any)
if d["deleted"] != true {
t.Errorf("deleted = %v", d["deleted"])
}
}
// ---------------------------------------------------------------------------
// Recycle bin handlers
// ---------------------------------------------------------------------------
@@ -1578,55 +1474,6 @@ func TestRestoreCollectionAndTemplate(t *testing.T) {
code, _ = doRequest(t, app, "POST", "/api/recycle-bin/"+tmplEntryID+"/restore", "", nil)
assertStatus(t, "RestoreTemplate", code, 200)
// restore share: create share, delete it (via handler to archive), then restore
deps.SourceRepo.Upsert(model.SourceRecord{ID: "src1", Name: "Src1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
_, body = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"src1"}`, nil)
shareID := body["data"].(map[string]any)["id"].(string)
doRequest(t, app, "DELETE", "/api/shares/"+shareID, "", nil)
code, body = doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
data = body["data"].([]any)
var shareEntryID string
for _, e := range data {
em := e.(map[string]any)
if em["resourceType"] == "share" {
shareEntryID = em["id"].(string)
break
}
}
if shareEntryID == "" {
t.Fatal("expected share recycle entry")
}
code, _ = doRequest(t, app, "POST", "/api/recycle-bin/"+shareEntryID+"/restore", "", nil)
assertStatus(t, "RestoreShare", code, 200)
// restore share conflict: restore again after it exists (recreate via another cycle)
// Actually test the conflict path by archiving a share whose resource id already exists
deps.SourceRepo.Upsert(model.SourceRecord{ID: "src2", Name: "Src2", Type: "local", Content: "ss://y", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
_, body = doRequest(t, app, "POST", "/api/shares", `{"resourceType":"source","resourceId":"src2"}`, nil)
shareID2 := body["data"].(map[string]any)["id"].(string)
doRequest(t, app, "DELETE", "/api/shares/"+shareID2, "", nil)
// manually re-insert a grant with same id to force conflict
snap, _ := deps.GrantRepo.GetSnapshot(shareID2)
deps.GrantRepo.RestoreFromSnapshot(snap)
code, body = doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
data = body["data"].([]any)
var shareEntry2ID string
for _, e := range data {
em := e.(map[string]any)
if em["resourceType"] == "share" {
if s, _ := deps.RecycleRepo.Get(em["id"].(string)); s != nil && s["resourceId"] == shareID2 {
shareEntry2ID = em["id"].(string)
break
}
}
}
if shareEntry2ID != "" {
code, _ = doRequest(t, app, "POST", "/api/recycle-bin/"+shareEntry2ID+"/restore", "", nil)
// Share restore may return 200 (no conflict) or 409 (conflict if grant still exists)
if code != 200 && code != 409 {
t.Errorf("RestoreShare conflict: status = %d, want 200 or 409", code)
}
}
}
func TestRestoreCollectionConflict(t *testing.T) {
@@ -1685,7 +1532,7 @@ func TestRestoreUnknownType(t *testing.T) {
deps.RecycleRepo.ArchiveAndDelete("source", "u1", sourceToMap(model.SourceRecord{ID: "u1", Name: "U1"}), "DELETE FROM sources WHERE id = ?", "u1")
// Manually update the resource_type to "unknown" for testing
deps.DB.Exec("UPDATE recycle_bin SET resource_type = 'unknown' WHERE resource_id = 'u1'")
code, body := doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
assertStatus(t, "ListRecycleBin unknown", code, 200)
data := body["data"].([]any)
@@ -1724,8 +1571,8 @@ func TestLinkHandlers(t *testing.T) {
code, body := doRequest(t, app, "GET", "/api/link/source/s1", "", nil)
assertStatus(t, "LinkSource", code, 200)
d := body["data"].(map[string]any)
if d["url"] == "" {
t.Error("expected non-empty url")
if !strings.HasSuffix(d["url"].(string), "/sources/s1/dl-tok") {
t.Errorf("url = %v, want /sources/s1/dl-tok suffix", d["url"])
}
if d["tokenIncluded"] != true {
t.Errorf("tokenIncluded = %v", d["tokenIncluded"])
@@ -1735,8 +1582,8 @@ func TestLinkHandlers(t *testing.T) {
code, body = doRequest(t, app, "GET", "/api/link/source/s1?target=mihomo", "", nil)
assertStatus(t, "LinkSource target", code, 200)
d = body["data"].(map[string]any)
if d["url"] == "" {
t.Error("expected non-empty url with target")
if !strings.HasSuffix(d["url"].(string), "/sources/s1/dl-tok?target=mihomo") {
t.Errorf("url = %v, want /sources/s1/dl-tok?target=mihomo suffix", d["url"])
}
// Link source - bad target returns nil url (buildDownloadLink returns nil, success wraps nil)
@@ -1750,6 +1597,10 @@ func TestLinkHandlers(t *testing.T) {
// Link collection - found
code, body = doRequest(t, app, "GET", "/api/link/collection/c1", "", nil)
assertStatus(t, "LinkCollection", code, 200)
d = body["data"].(map[string]any)
if !strings.HasSuffix(d["url"].(string), "/collections/c1/dl-tok") {
t.Errorf("url = %v, want /collections/c1/dl-tok suffix", d["url"])
}
// Link collection - not found
code, _ = doRequest(t, app, "GET", "/api/link/collection/nope", "", nil)
@@ -1923,27 +1774,24 @@ func TestGetTemplateConfig(t *testing.T) {
func TestHandleDownloadSourceNotFound(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// no token + source not found -> handler writes 403 then continues to 404
// (rejectInvalidDownloadToken calls failed() which returns nil, so handler continues)
code, _ := doRequest(t, app, "GET", "/download/source/nope/mihomo", "", nil)
code, _ := doRequest(t, app, "GET", "/sources/nope/bad-token?target=mihomo", "", nil)
assertStatus(t, "DownloadSource no token", code, 404)
// with token, source not found
code, _ = doRequest(t, app, "GET", "/download/source/nope/mihomo?token=dl-tok", "", nil)
code, _ = doRequest(t, app, "GET", "/sources/nope/dl-tok?target=mihomo", "", nil)
assertStatus(t, "DownloadSource not found", code, 404)
// bad target
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
code, _ = doRequest(t, app, "GET", "/download/source/s1/bogus?token=dl-tok", "", nil)
code, _ = doRequest(t, app, "GET", "/sources/s1/dl-tok?target=bogus", "", nil)
assertStatus(t, "DownloadSource bad target", code, 400)
}
func TestHandleDownloadCollectionNotFound(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// no token + collection not found -> 404 (same continuation behavior as source)
code, _ := doRequest(t, app, "GET", "/download/collection/nope/mihomo", "", nil)
code, _ := doRequest(t, app, "GET", "/collections/nope/bad-token?target=mihomo", "", nil)
assertStatus(t, "DownloadCollection no token", code, 404)
// with token, collection not found
code, _ = doRequest(t, app, "GET", "/download/collection/nope/mihomo?token=dl-tok", "", nil)
code, _ = doRequest(t, app, "GET", "/collections/nope/dl-tok?target=mihomo", "", nil)
assertStatus(t, "DownloadCollection not found", code, 404)
}
@@ -1951,7 +1799,7 @@ func TestHandleDownloadSourceLocalSuccess(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/download/source/s1/json?token=dl-tok", "", nil)
code, body := doRequest(t, app, "GET", "/sources/s1/dl-tok?target=json", "", nil)
// Should succeed (200) — body contains rendered JSON
assertStatus(t, "DownloadSource local", code, 200)
if body != nil {
@@ -1961,24 +1809,11 @@ func TestHandleDownloadSourceLocalSuccess(t *testing.T) {
}
}
func TestHandleDownloadSourceScopedToken(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
// create a scoped grant
_, token, _ := deps.GrantRepo.Create("source", "s1", "json", nil)
code, _ := doRequest(t, app, "GET", "/download/source/s1/json?token="+token, "", nil)
assertStatus(t, "DownloadSource scoped", code, 200)
// scoped token for wrong target -> 403
code, _ = doRequest(t, app, "GET", "/download/source/s1/mihomo?token="+token, "", nil)
assertStatus(t, "DownloadSource scoped wrong target", code, 403)
}
func TestHandleDownloadSourceDisabled(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: false, Filters: []model.FilterRule{}, Meta: map[string]any{}})
code, _ := doRequest(t, app, "GET", "/download/source/s1/json?token=dl-tok", "", nil)
code, _ := doRequest(t, app, "GET", "/sources/s1/dl-tok?target=json", "", nil)
assertStatus(t, "DownloadSource disabled", code, 404)
}
@@ -2014,6 +1849,27 @@ func TestHandlePreviewSourceLocalSuccess(t *testing.T) {
}
}
func TestHandlePreviewSourceRemoteBase64Success(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
sub := base64.StdEncoding.EncodeToString([]byte("ss://pass@host:8080#keep\nss://pass@host:8081#drop"))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(sub))
}))
defer srv.Close()
code, body := doRequest(t, app, "POST", "/api/preview/source", `{"type":"remote","url":"`+srv.URL+`","filters":[{"type":"include","field":"name","pattern":"keep"}]}`, nil)
assertStatus(t, "PreviewSource remote base64", code, 200)
d := body["data"].(map[string]any)
if d["originalCount"] != float64(2) {
t.Fatalf("expected originalCount 2, got %v", d["originalCount"])
}
processed, ok := d["processed"].([]any)
if !ok || len(processed) != 1 {
t.Fatalf("expected one processed node, got %v", d["processed"])
}
}
func TestHandlePreviewCollectionBadJSON(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
@@ -2120,6 +1976,20 @@ func TestHandleProxyParse(t *testing.T) {
assertStatus(t, "ProxyParse platform key", code, 200)
}
func TestHandleProxyURI(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
code, body := doRequest(t, app, "POST", "/api/utils/proxy-uri", `{"type":"ss","name":"n","server":"host","port":8388,"cipher":"aes-256-gcm","password":"pass"}`, nil)
assertStatus(t, "ProxyURI ss", code, 200)
if uri, _ := body["data"].(map[string]any)["uri"].(string); !strings.HasPrefix(uri, "ss://") {
t.Fatalf("expected ss uri, got %v", body)
}
code, _ = doRequest(t, app, "POST", "/api/utils/proxy-uri", `{"type":"snell","name":"n"}`, nil)
assertStatus(t, "ProxyURI unsupported", code, 400)
}
// ---------------------------------------------------------------------------
// Rule parse handler
// ---------------------------------------------------------------------------
@@ -2228,7 +2098,7 @@ func TestRegisterRoutes(t *testing.T) {
resp2.Body.Close()
// Download route (public, no admin token) -> 403 or 404 without download token
req3 := httptest.NewRequest("GET", "/download/source/nope/mihomo", nil)
req3 := httptest.NewRequest("GET", "/sources/nope/bad-token", nil)
resp3, err := app.Test(req3)
if err != nil {
t.Fatalf("app.Test: %v", err)
@@ -2248,7 +2118,7 @@ func TestNewDeps(t *testing.T) {
if deps.SourceRepo == nil || deps.CollectionRepo == nil || deps.TemplateRepo == nil {
t.Error("expected non-nil repos")
}
if deps.SettingsRepo == nil || deps.GrantRepo == nil || deps.RecycleRepo == nil || deps.CacheRepo == nil {
if deps.SettingsRepo == nil || deps.RecycleRepo == nil || deps.CacheRepo == nil {
t.Error("expected non-nil repos")
}
if deps.Cfg == nil || deps.DB == nil {
@@ -2291,11 +2161,8 @@ func TestRejectInvalidDownloadToken(t *testing.T) {
code, _ = doRequest(t, app, "GET", "/dl/s1/mihomo?token=dl-tok", "", nil)
assertStatus(t, "rejectToken global", code, 200)
// path token param (not registered in route) — test query-only
// valid scoped token
_, token, _ := deps.GrantRepo.Create("source", "s1", "mihomo", nil)
code, _ = doRequest(t, app, "GET", "/dl/s1/mihomo?token="+token, "", nil)
assertStatus(t, "rejectToken scoped", code, 200)
code, _ = doRequest(t, app, "GET", "/dl/s1/mihomo?token=wrong", "", nil)
assertStatus(t, "rejectToken wrong", code, 403)
}
func TestSendDownloadResponse(t *testing.T) {
+10 -19
View File
@@ -11,15 +11,14 @@ import (
// Deps holds all shared dependencies for handlers.
type Deps struct {
Cfg *config.Config
DB *sqlx.DB
SourceRepo *database.SourceRepo
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
TemplateRepo *database.TemplateRepo
SettingsRepo *database.SettingsRepo
RecycleRepo *database.RecycleRepo
CacheRepo *database.CacheRepo
}
func NewDeps(cfg *config.Config, db *sqlx.DB) *Deps {
@@ -30,7 +29,6 @@ func NewDeps(cfg *config.Config, db *sqlx.DB) *Deps {
CollectionRepo: database.NewCollectionRepo(db),
TemplateRepo: database.NewTemplateRepo(db),
SettingsRepo: database.NewSettingsRepo(db),
GrantRepo: database.NewGrantRepo(db),
RecycleRepo: database.NewRecycleRepo(db),
CacheRepo: database.NewCacheRepo(db),
}
@@ -79,12 +77,6 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config, db *sqlx.DB) {
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)
@@ -104,13 +96,12 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config, db *sqlx.DB) {
// Tool APIs
api.Post("/proxy/parse", deps.HandleProxyParse)
api.Post("/rule/parse", deps.HandleRuleParse)
api.Post("/utils/proxy-uri", deps.HandleProxyURI)
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)
app.Get("/sources/:name/:token", deps.HandleDownloadSource)
app.Get("/collections/:name/:token", deps.HandleDownloadCollection)
}
// success sends a success JSON response.
+1 -128
View File
@@ -1,133 +1,13 @@
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 {
@@ -179,13 +59,6 @@ func (d *Deps) HandleRestoreRecycleBinEntry(c fiber.Ctx) error {
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{
@@ -214,7 +87,7 @@ func getPublicBaseUrl(c fiber.Ctx) string {
if proto == "" {
proto = "http"
}
return proto + "://" + c.Hostname()
return proto + "://" + c.Host()
}
// SetSafeResponseHeader wraps middleware.SetSafeResponseHeader for handler use.
+35 -50
View File
@@ -79,40 +79,40 @@ func TestNormalizeTarget(t *testing.T) {
func TestNormalizeTargetAlias(t *testing.T) {
// All aliases should map correctly
aliases := map[string]string{
"clash": TargetMihomo,
"clashmeta": TargetMihomo,
"clash-meta": TargetMihomo,
"meta": TargetMihomo,
"mihomo": TargetMihomo,
"stash": TargetStash,
"surge": TargetSurge,
"surgemac": TargetSurgeMac,
"surge-mac": TargetSurgeMac,
"surge_mac": TargetSurgeMac,
"surfboard": TargetSurfboard,
"loon": TargetLoon,
"egern": TargetEgern,
"shadowrocket": TargetShadowrocket,
"sr": TargetShadowrocket,
"qx": TargetQX,
"quanx": TargetQX,
"quantumult": TargetQX,
"quantumultx": TargetQX,
"quantumult-x": TargetQX,
"singbox": TargetSingBox,
"sing-box": TargetSingBox,
"sfa": TargetSingBox,
"karing": TargetSingBox,
"v2ray": TargetV2ray,
"v2rayn": TargetV2ray,
"v2rayng": TargetV2ray,
"base64": TargetV2ray,
"uri": TargetURI,
"uris": TargetURI,
"plain": TargetURI,
"text": TargetURI,
"json": TargetJSON,
"raw": TargetJSON,
"clash": TargetMihomo,
"clashmeta": TargetMihomo,
"clash-meta": TargetMihomo,
"meta": TargetMihomo,
"mihomo": TargetMihomo,
"stash": TargetStash,
"surge": TargetSurge,
"surgemac": TargetSurgeMac,
"surge-mac": TargetSurgeMac,
"surge_mac": TargetSurgeMac,
"surfboard": TargetSurfboard,
"loon": TargetLoon,
"egern": TargetEgern,
"shadowrocket": TargetShadowrocket,
"sr": TargetShadowrocket,
"qx": TargetQX,
"quanx": TargetQX,
"quantumult": TargetQX,
"quantumultx": TargetQX,
"quantumult-x": TargetQX,
"singbox": TargetSingBox,
"sing-box": TargetSingBox,
"sfa": TargetSingBox,
"karing": TargetSingBox,
"v2ray": TargetV2ray,
"v2rayn": TargetV2ray,
"v2rayng": TargetV2ray,
"base64": TargetV2ray,
"uri": TargetURI,
"uris": TargetURI,
"plain": TargetURI,
"text": TargetURI,
"json": TargetJSON,
"raw": TargetJSON,
}
for alias, want := range aliases {
t.Run("alias_"+alias, func(t *testing.T) {
@@ -471,7 +471,7 @@ func TestFilterRuleJSON(t *testing.T) {
}
func TestRecordStructsJSON(t *testing.T) {
// Verify SourceRecord, CollectionRecord, TemplateRecord, DownloadGrantRecord, RecycleBinRecord
// Verify SourceRecord, CollectionRecord, TemplateRecord, RecycleBinRecord
// can marshal/unmarshal without errors.
t.Run("SourceRecord", func(t *testing.T) {
r := SourceRecord{ID: "s1", Name: "test", Type: "remote", URL: "http://example.com"}
@@ -518,21 +518,6 @@ func TestRecordStructsJSON(t *testing.T) {
}
})
t.Run("DownloadGrantRecord", func(t *testing.T) {
r := DownloadGrantRecord{ID: "g1", ResourceType: "source", ResourceId: "s1"}
data, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var parsed DownloadGrantRecord
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.ResourceType != "source" {
t.Errorf("ResourceType = %q, want source", parsed.ResourceType)
}
})
t.Run("RecycleBinRecord", func(t *testing.T) {
r := RecycleBinRecord{ID: "r1", ResourceType: "source", ResourceId: "s1", Snapshot: map[string]any{"k": "v"}}
data, err := json.Marshal(r)
+18 -30
View File
@@ -51,30 +51,30 @@ type CustomRule struct {
// SourceRecord is the full DB record for a subscription source.
type SourceRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Content string `json:"content"`
Enabled bool `json:"enabled"`
Filters []FilterRule `json:"filters"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Content string `json:"content"`
Enabled bool `json:"enabled"`
Filters []FilterRule `json:"filters"`
Meta map[string]any `json:"meta"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// CollectionRecord is the full DB record for a subscription collection.
type CollectionRecord struct {
ID string `json:"id"`
Name string `json:"name"`
SourceIds []string `json:"sourceIds"`
Filters []FilterRule `json:"filters"`
TemplateId string `json:"templateId"`
IgnoreFailed bool `json:"ignoreFailed"`
Enabled bool `json:"enabled"`
ID string `json:"id"`
Name string `json:"name"`
SourceIds []string `json:"sourceIds"`
Filters []FilterRule `json:"filters"`
TemplateId string `json:"templateId"`
IgnoreFailed bool `json:"ignoreFailed"`
Enabled bool `json:"enabled"`
Meta map[string]any `json:"meta"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// TemplateRecord is the full DB record for a routing template.
@@ -87,18 +87,6 @@ type TemplateRecord struct {
UpdatedAt int64 `json:"updatedAt"`
}
// DownloadGrantRecord represents a scoped download share token.
type DownloadGrantRecord struct {
ID string `json:"id"`
ResourceType string `json:"resourceType"`
ResourceId string `json:"resourceId"`
Target string `json:"target,omitempty"`
ExpiresAt *int64 `json:"expiresAt,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// RecycleBinRecord represents a soft-deleted resource snapshot.
type RecycleBinRecord struct {
ID string `json:"id"`
+28 -15
View File
@@ -35,14 +35,15 @@ type BuildOptions struct {
// BuildResult holds the output of a subscription build.
type BuildResult struct {
Body string
Metadata model.SubscriptionResponseMetadata
Nodes int
Body string
Metadata model.SubscriptionResponseMetadata
Nodes int
OriginalNodes int
}
// BuildSubscriptionResult runs the full subscription pipeline.
func BuildSubscriptionResult(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
proxies, metadata, err := loadProxyNodes(ctx, opts)
proxies, originalNodes, metadata, err := loadProxyNodes(ctx, opts)
if err != nil {
return nil, err
}
@@ -56,13 +57,14 @@ func BuildSubscriptionResult(ctx context.Context, opts BuildOptions) (*BuildResu
}
return &BuildResult{
Body: body,
Metadata: metadata,
Nodes: len(proxies),
Body: body,
Metadata: metadata,
Nodes: len(proxies),
OriginalNodes: originalNodes,
}, nil
}
func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode, model.SubscriptionResponseMetadata, error) {
func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode, int, model.SubscriptionResponseMetadata, error) {
sources := getSources(opts)
enabledSources := make([]model.SourceRecord, 0, len(sources))
for _, s := range sources {
@@ -71,20 +73,21 @@ func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode,
}
}
if len(enabledSources) == 0 {
return nil, model.SubscriptionResponseMetadata{}, nil
return nil, 0, model.SubscriptionResponseMetadata{}, nil
}
metadataMap := make(map[string]model.SubscriptionResponseMetadata)
metadataByIndex := make([]model.SubscriptionResponseMetadata, len(enabledSources))
originalCounts := make([]int, len(enabledSources))
tasks := make([]func() ([]model.ProxyNode, error), len(enabledSources))
for i, sub := range enabledSources {
i := i
sub := sub
tasks[i] = func() ([]model.ProxyNode, error) {
raw, meta := loadSubscriptionRaw(ctx, sub, opts)
if sub.ID != "" {
metadataMap[sub.ID] = meta
}
metadataByIndex[i] = meta
nodes := proxy.ParseProxies(raw)
originalCounts[i] = len(nodes)
filters := sub.Filters
if filters == nil {
filters = []model.FilterRule{}
@@ -113,13 +116,17 @@ func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode,
proxyLists = results
}
if taskErr != nil {
return nil, model.SubscriptionResponseMetadata{}, taskErr
return nil, 0, model.SubscriptionResponseMetadata{}, taskErr
}
var allProxies []model.ProxyNode
originalNodes := 0
for _, list := range proxyLists {
allProxies = append(allProxies, list...)
}
for _, count := range originalCounts {
originalNodes += count
}
// Apply collection-level filters
if opts.Collection != nil {
@@ -137,8 +144,14 @@ func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode,
allProxies = proxy.EnsureUniqueProxyNames(allProxies)
// Select response metadata
metadataMap := make(map[string]model.SubscriptionResponseMetadata, len(enabledSources))
for i, s := range enabledSources {
if s.ID != "" {
metadataMap[s.ID] = metadataByIndex[i]
}
}
metadata := selectResponseMetadata(enabledSources, metadataMap)
return allProxies, metadata, nil
return allProxies, originalNodes, metadata, nil
}
func getSources(opts BuildOptions) []model.SourceRecord {
+11 -8
View File
@@ -90,13 +90,13 @@ func TestBuildSubscriptionResult_Collection(t *testing.T) {
Filters: []model.FilterRule{}, Meta: map[string]any{},
}
collection := &model.CollectionRecord{
ID: "col1",
Name: "Collection",
SourceIds: []string{"src1", "src2"},
Filters: []model.FilterRule{},
ID: "col1",
Name: "Collection",
SourceIds: []string{"src1", "src2"},
Filters: []model.FilterRule{},
TemplateId: "acl4ssr-mihomo",
Enabled: true,
Meta: map[string]any{},
Enabled: true,
Meta: map[string]any{},
}
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
Collection: collection,
@@ -169,6 +169,9 @@ func TestBuildSubscriptionResult_WithFilters(t *testing.T) {
if result.Nodes != 1 {
t.Errorf("expected 1 node after filter, got %d", result.Nodes)
}
if result.OriginalNodes != 2 {
t.Errorf("expected 2 original nodes, got %d", result.OriginalNodes)
}
}
func TestBuildSubscriptionResult_MihomoTarget(t *testing.T) {
@@ -421,8 +424,8 @@ func TestSplitSourceUrls_Empty(t *testing.T) {
func TestMetadataFromSource(t *testing.T) {
meta := metadataFromSource(model.SourceRecord{
Meta: map[string]any{
"subUserinfo": "upload=1;download=2;total=3",
"profileWebPageUrl": "https://example.com",
"subUserinfo": "upload=1;download=2;total=3",
"profileWebPageUrl": "https://example.com",
"profileUpdateInterval": "24",
},
})