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