refactor: simplify auth and subscription links
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user