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
+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) {