chore: sub-store Go 重写项目初始化

This commit is contained in:
2026-07-27 14:38:13 +08:00
commit 90e53aa754
85 changed files with 15271 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
package database
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/jmoiron/sqlx"
)
// CacheRepo manages the source_cache table for remote subscription caching.
type CacheRepo struct {
db *sqlx.DB
}
func NewCacheRepo(db *sqlx.DB) *CacheRepo {
return &CacheRepo{db: db}
}
type cacheRow struct {
CacheKey string `db:"cache_key"`
Content string `db:"content"`
Metadata string `db:"metadata"`
CachedAt int64 `db:"cached_at"`
TTL int `db:"ttl"`
}
// CacheEntry holds cached content and its metadata.
type CacheEntry struct {
Content string
Metadata map[string]any
CachedAt int64
TTL int
}
// Get retrieves a cache entry by key. Returns nil if not found or expired.
func (r *CacheRepo) Get(key string) (*CacheEntry, error) {
var row cacheRow
err := r.db.Get(&row, "SELECT * FROM source_cache WHERE cache_key = ?", key)
if err != nil {
return nil, nil // cache miss is not an error
}
// Check expiry
if time.Now().Unix() > row.CachedAt+int64(row.TTL) {
return nil, nil
}
var metadata map[string]any
json.Unmarshal([]byte(row.Metadata), &metadata)
if metadata == nil {
metadata = map[string]any{}
}
return &CacheEntry{
Content: row.Content,
Metadata: metadata,
CachedAt: row.CachedAt,
TTL: row.TTL,
}, nil
}
// Put stores a cache entry.
func (r *CacheRepo) Put(key, content string, metadata map[string]any, ttl int) error {
defer func() {
if rec := recover(); rec != nil {
slog.Warn("cache put panicked", "error", rec)
}
}()
now := time.Now().Unix()
metaJSON := marshalJSON(metadata)
_, err := r.db.Exec(
`INSERT INTO source_cache (cache_key, content, metadata, cached_at, ttl)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
content = excluded.content, metadata = excluded.metadata,
cached_at = excluded.cached_at, ttl = excluded.ttl`,
key, content, metaJSON, now, ttl,
)
return err
}
// SafeGet wraps Get with panic recovery — per review-resolution #35.
func (r *CacheRepo) SafeGet(key string) (*CacheEntry, bool) {
defer func() {
if rec := recover(); rec != nil {
slog.Warn("cache get panicked", "error", rec)
}
}()
entry, err := r.Get(key)
if err != nil || entry == nil {
return nil, false
}
return entry, true
}
// SafePut wraps Put with panic recovery — per review-resolution #35.
func (r *CacheRepo) SafePut(key, content string, metadata map[string]any, ttl int) {
defer func() {
if rec := recover(); rec != nil {
slog.Warn("cache put panicked", "error", rec)
}
}()
_ = r.Put(key, content, metadata, ttl)
}
// StartCacheCleaner runs a background goroutine that periodically removes expired cache entries.
// Per review-resolution #32.
func StartCacheCleaner(ctx context.Context, db *sqlx.DB, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
_, _ = db.ExecContext(ctx,
"DELETE FROM source_cache WHERE cached_at + ttl < ?",
time.Now().Unix(),
)
case <-ctx.Done():
return
}
}
}()
}
+160
View File
@@ -0,0 +1,160 @@
package database
import (
"database/sql"
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
)
type CollectionRepo struct {
db *sqlx.DB
}
func NewCollectionRepo(db *sqlx.DB) *CollectionRepo {
return &CollectionRepo{db: db}
}
type collectionRow struct {
ID string `db:"id"`
Name string `db:"name"`
SourceIdsJSON string `db:"source_ids_json"`
FiltersJSON string `db:"filters_json"`
TemplateID string `db:"template_id"`
IgnoreFailed int `db:"ignore_failed"`
Enabled int `db:"enabled"`
MetaJSON string `db:"meta_json"`
CreatedAt int64 `db:"created_at"`
UpdatedAt int64 `db:"updated_at"`
}
func (r *CollectionRepo) List() ([]model.CollectionRecord, error) {
var rows []collectionRow
if err := r.db.Select(&rows, "SELECT * FROM collections ORDER BY created_at ASC"); err != nil {
return nil, err
}
result := make([]model.CollectionRecord, 0, len(rows))
for _, row := range rows {
result = append(result, collectionFromRow(row))
}
return result, nil
}
func (r *CollectionRepo) Get(id string) (*model.CollectionRecord, error) {
var row collectionRow
if err := r.db.Get(&row, "SELECT * FROM collections WHERE id = ?", id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
rec := collectionFromRow(row)
return &rec, nil
}
func (r *CollectionRepo) Upsert(input model.CollectionRecord) (model.CollectionRecord, error) {
now := time.Now().UnixMilli()
id := input.ID
if id == "" {
id = util.ToId(input.Name)
}
existing, _ := r.Get(id)
createdAt := now
if existing != nil {
createdAt = existing.CreatedAt
}
sourceIds := input.SourceIds
if sourceIds == nil {
sourceIds = []string{}
}
filters := input.Filters
if filters == nil {
filters = []model.FilterRule{}
}
meta := input.Meta
if meta == nil {
meta = map[string]any{}
}
templateId := input.TemplateId
if templateId == "" {
templateId = "acl4ssr-mihomo"
}
rec := model.CollectionRecord{
ID: id,
Name: input.Name,
SourceIds: sourceIds,
Filters: filters,
TemplateId: templateId,
IgnoreFailed: input.IgnoreFailed,
Enabled: input.Enabled,
Meta: meta,
CreatedAt: createdAt,
UpdatedAt: now,
}
sourceIdsJSON := marshalJSON(rec.SourceIds)
filtersJSON := marshalJSON(rec.Filters)
metaJSON := marshalJSON(rec.Meta)
_, err := r.db.Exec(
`INSERT INTO collections (id, name, source_ids_json, filters_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, source_ids_json = excluded.source_ids_json,
filters_json = excluded.filters_json, template_id = excluded.template_id,
ignore_failed = excluded.ignore_failed, enabled = excluded.enabled,
meta_json = excluded.meta_json, updated_at = excluded.updated_at`,
rec.ID, rec.Name, sourceIdsJSON, filtersJSON, rec.TemplateId, boolToInt(rec.IgnoreFailed), boolToInt(rec.Enabled), metaJSON, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return rec, err
}
return rec, nil
}
func (r *CollectionRepo) Delete(id string) error {
_, err := r.db.Exec("DELETE FROM collections WHERE id = ?", id)
return err
}
func (r *CollectionRepo) Sort(ids []string) error {
now := time.Now().UnixMilli()
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range ids {
_, err := tx.Exec("UPDATE collections SET created_at = ?, updated_at = ? WHERE id = ?", now+int64(i), now, id)
if err != nil {
return err
}
}
return tx.Commit()
}
func collectionFromRow(row collectionRow) model.CollectionRecord {
var sourceIds []string
json.Unmarshal([]byte(row.SourceIdsJSON), &sourceIds)
if sourceIds == nil {
sourceIds = []string{}
}
return model.CollectionRecord{
ID: row.ID,
Name: row.Name,
SourceIds: sourceIds,
Filters: model.UnmarshalFilterRules(row.FiltersJSON),
TemplateId: row.TemplateID,
IgnoreFailed: row.IgnoreFailed != 0,
Enabled: row.Enabled != 0,
Meta: model.UnmarshalMeta(row.MetaJSON),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
+48
View File
@@ -0,0 +1,48 @@
package database
import (
"fmt"
"os"
"path/filepath"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
// InitDB opens a SQLite connection and configures PRAGMAs per review-resolutions #28 and #45.
//
// modernc.org/sqlite is a pure-Go driver (CGO_ENABLED=0) — acceptable write-performance
// tradeoff for a personal tool. To switch to mattn/go-sqlite3 (CGO), just change the
// import and driver name; the interface stays the same.
func InitDB(path string) (*sqlx.DB, error) {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
}
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path)
db, err := sqlx.Connect("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Single connection avoids "database is locked" under concurrent writes.
// WAL mode allows readers to proceed while a write is in progress.
db.SetMaxOpenConns(1)
for _, pragma := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
"PRAGMA foreign_keys = ON",
"PRAGMA synchronous = NORMAL",
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
return nil, fmt.Errorf("exec %s: %w", pragma, err)
}
}
return db, nil
}
+241
View File
@@ -0,0 +1,241 @@
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
}
+28
View File
@@ -0,0 +1,28 @@
package database
import "encoding/json"
// marshalJSON marshals a value to JSON, returning "{}" or "[]" on failure.
func marshalJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
return "null"
}
return string(data)
}
// boolToInt converts a bool to 1/0 for SQLite.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// jsonUnmarshal safely unmarshals JSON, tolerating empty/invalid input.
func jsonUnmarshal(data string, v any) {
if data == "" {
return
}
json.Unmarshal([]byte(data), v)
}
+20
View File
@@ -0,0 +1,20 @@
package database
import (
"embed"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var embedMigrations embed.FS
// RunMigrations applies all embedded SQL migrations.
func RunMigrations(db *sqlx.DB) error {
goose.SetBaseFS(embedMigrations)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.Up(db.DB, "migrations")
}
@@ -0,0 +1,41 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'remote',
url TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
filters_json TEXT NOT NULL DEFAULT '[]',
meta_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source_ids_json TEXT NOT NULL DEFAULT '[]',
filters_json TEXT NOT NULL DEFAULT '[]',
template_id TEXT NOT NULL DEFAULT 'acl4ssr-mihomo',
ignore_failed INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
meta_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
target TEXT NOT NULL DEFAULT 'mihomo',
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
id TEXT PRIMARY KEY,
value_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
@@ -0,0 +1,29 @@
-- +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_id TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
deleted_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_recycle_bin_deleted_at
ON recycle_bin(deleted_at DESC);
@@ -0,0 +1,11 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS source_cache (
cache_key TEXT PRIMARY KEY,
content TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
cached_at INTEGER NOT NULL,
ttl INTEGER NOT NULL DEFAULT 300
);
CREATE INDEX IF NOT EXISTS idx_source_cache_expiry
ON source_cache(cached_at + ttl);
+129
View File
@@ -0,0 +1,129 @@
package database
import (
"database/sql"
"time"
"github.com/jmoiron/sqlx"
"github.com/google/uuid"
"github.com/peterqiu0516/sub-store/internal/util"
)
type RecycleRepo struct {
db *sqlx.DB
}
func NewRecycleRepo(db *sqlx.DB) *RecycleRepo {
return &RecycleRepo{db: db}
}
type recycleRow struct {
ID string `db:"id"`
ResourceType string `db:"resource_type"`
ResourceID string `db:"resource_id"`
SnapshotJSON string `db:"snapshot_json"`
DeletedAt int64 `db:"deleted_at"`
}
// ArchiveAndDelete archives a resource snapshot to recycle_bin and deletes it in one transaction.
// Per review-resolution #29: BeginTx wrapping archive + delete + trim.
func (r *RecycleRepo) ArchiveAndDelete(resourceType, resourceID string, snapshot map[string]any, deleteSQL string, deleteArgs ...any) (string, error) {
deletedAt := time.Now().UnixMilli()
id := uuid.New().String()
snapshotJSON := marshalJSON(snapshot)
maxEntries := util.MaxRecycleEntries
tx, err := r.db.Beginx()
if err != nil {
return "", err
}
defer tx.Rollback()
// Step 1: Insert into recycle_bin
_, err = tx.Exec(
"INSERT INTO recycle_bin (id, resource_type, resource_id, snapshot_json, deleted_at) VALUES (?, ?, ?, ?, ?)",
id, resourceType, resourceID, snapshotJSON, deletedAt,
)
if err != nil {
return "", err
}
// Step 2: Delete the resource
_, err = tx.Exec(deleteSQL, deleteArgs...)
if err != nil {
return "", err
}
// Step 3: Trim recycle_bin to maxEntries
_, err = tx.Exec(
`DELETE FROM recycle_bin WHERE id IN (
SELECT id FROM recycle_bin ORDER BY deleted_at DESC LIMIT -1 OFFSET ?
)`,
maxEntries,
)
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return id, nil
}
func (r *RecycleRepo) List() ([]map[string]any, error) {
var rows []recycleRow
if err := r.db.Select(&rows,
"SELECT * FROM recycle_bin ORDER BY deleted_at DESC LIMIT ?",
util.MaxRecycleEntries,
); err != nil {
return nil, err
}
result := make([]map[string]any, 0, len(rows))
for _, row := range rows {
entry := map[string]any{
"id": row.ID,
"resourceType": row.ResourceType,
"resourceId": row.ResourceID,
"snapshot": parseSnapshot(row.SnapshotJSON),
"deletedAt": row.DeletedAt,
}
result = append(result, entry)
}
return result, nil
}
func (r *RecycleRepo) Get(id string) (map[string]any, error) {
var row recycleRow
if err := r.db.Get(&row, "SELECT * FROM recycle_bin WHERE id = ?", id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return map[string]any{
"id": row.ID,
"resourceType": row.ResourceType,
"resourceId": row.ResourceID,
"snapshot": parseSnapshot(row.SnapshotJSON),
"deletedAt": row.DeletedAt,
}, nil
}
func (r *RecycleRepo) Delete(id string) error {
_, err := r.db.Exec("DELETE FROM recycle_bin WHERE id = ?", id)
return err
}
func parseSnapshot(value string) map[string]any {
if value == "" {
return map[string]any{}
}
var m map[string]any
jsonUnmarshal(value, &m)
if m == nil {
m = map[string]any{}
}
return m
}
+336
View File
@@ -0,0 +1,336 @@
package database
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"github.com/peterqiu0516/sub-store/internal/model"
)
func testDB(t *testing.T) *sqlx.DB {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := sqlx.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)")
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := RunMigrations(db); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
return db
}
func TestSourceCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSourceRepo(db)
// Create
src := model.SourceRecord{
ID: "test-src",
Name: "Test Source",
Type: "local",
Content: "ss://pass@host:port#name",
Enabled: true,
Filters: []model.FilterRule{},
Meta: map[string]any{},
}
created, err := repo.Upsert(src)
if err != nil {
t.Fatalf("create failed: %v", err)
}
if created.ID != "test-src" {
t.Errorf("expected id test-src, got %s", created.ID)
}
// Read
got, err := repo.Get("test-src")
if err != nil {
t.Fatalf("get failed: %v", err)
}
if got.Name != "Test Source" {
t.Errorf("expected name Test Source, got %s", got.Name)
}
// List
list, err := repo.List()
if err != nil {
t.Fatalf("list failed: %v", err)
}
if len(list) != 1 {
t.Errorf("expected 1 source, got %d", len(list))
}
// Update
src.Name = "Updated"
src.URL = "https://example.com/sub"
src.Type = "remote"
updated, err := repo.Upsert(src)
if err != nil {
t.Fatalf("update failed: %v", err)
}
if updated.Name != "Updated" {
t.Errorf("expected name Updated, got %s", updated.Name)
}
// Delete — use RecycleRepo for archive + delete
recycleRepo := NewRecycleRepo(db)
_, err = recycleRepo.ArchiveAndDelete("source", "test-src", map[string]any{"id": "test-src"}, "DELETE FROM sources WHERE id = ?", "test-src")
if err != nil {
t.Fatalf("delete failed: %v", err)
}
got, err = repo.Get("test-src")
if got != nil {
t.Error("expected nil after delete")
}
}
func TestCollectionCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
// Create a source first
srcRepo := NewSourceRepo(db)
srcRepo.Upsert(model.SourceRecord{
ID: "src1", Name: "Src1", Type: "local", Content: "ss://pass@host:port#name",
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
})
repo := NewCollectionRepo(db)
col := model.CollectionRecord{
ID: "test-col",
Name: "Test Collection",
SourceIds: []string{"src1"},
Filters: []model.FilterRule{},
TemplateId: "default",
IgnoreFailed: true,
Enabled: true,
Meta: map[string]any{},
}
created, err := repo.Upsert(col)
if err != nil {
t.Fatalf("create failed: %v", err)
}
if created.ID != "test-col" {
t.Errorf("expected id test-col, got %s", created.ID)
}
got, err := repo.Get("test-col")
if err != nil {
t.Fatalf("get failed: %v", err)
}
if len(got.SourceIds) != 1 || got.SourceIds[0] != "src1" {
t.Errorf("expected sourceIds [src1], got %v", got.SourceIds)
}
}
func TestTemplateCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewTemplateRepo(db)
tmpl := model.TemplateRecord{
ID: "test-tmpl",
Name: "Test Template",
Target: "mihomo",
Config: map[string]any{
"proxy-groups": []any{
map[string]any{"name": "PROXY", "type": "select"},
},
},
}
created, err := repo.Upsert(tmpl)
if err != nil {
t.Fatalf("create failed: %v", err)
}
if created.ID != "test-tmpl" {
t.Errorf("expected id test-tmpl, got %s", created.ID)
}
got, err := repo.Get("test-tmpl")
if err != nil {
t.Fatalf("get failed: %v", err)
}
if got.Target != "mihomo" {
t.Errorf("expected target mihomo, got %s", got.Target)
}
}
func TestSettingsCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSettingsRepo(db)
// Get default (empty)
settings, err := repo.Get()
if err != nil {
t.Fatalf("get failed: %v", err)
}
if len(settings) != 0 {
t.Errorf("expected empty settings initially, got %d keys", len(settings))
}
// Update
updated, err := repo.Update(map[string]any{
"theme": map[string]any{"name": "dark"},
"appName": "MyStore",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
if updated["appName"] != "MyStore" {
t.Errorf("expected appName MyStore, got %v", updated["appName"])
}
// Get again
settings, err = repo.Get()
if err != nil {
t.Fatalf("get after update failed: %v", err)
}
if settings["appName"] != "MyStore" {
t.Errorf("expected appName MyStore, got %v", settings["appName"])
}
}
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()
repo := NewCacheRepo(db)
// Put
repo.SafePut("cache-key-1", "cached content", map[string]any{"etag": "abc"}, 300)
// Get
entry, ok := repo.SafeGet("cache-key-1")
if !ok {
t.Fatal("expected cache hit")
}
if entry.Content != "cached content" {
t.Errorf("expected content 'cached content', got %s", entry.Content)
}
// Test expiry — put with TTL 1 and wait for it to expire
repo.SafePut("expired-key", "old", map[string]any{}, 1)
time.Sleep(2 * time.Second)
_, ok = repo.SafeGet("expired-key")
if ok {
t.Error("expected cache miss for expired key")
}
}
func TestRecycleCRUD(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewRecycleRepo(db)
// Archive — ArchiveAndDelete returns (id, error)
snapshot := map[string]any{"id": "test-src", "name": "Test"}
entryID, err := repo.ArchiveAndDelete("source", "test-src", snapshot, "SELECT 1", nil)
if err != nil {
t.Fatalf("archive failed: %v", err)
}
if entryID == "" {
t.Fatal("expected non-empty entry id")
}
// List
list, err := repo.List()
if err != nil {
t.Fatalf("list failed: %v", err)
}
if len(list) != 1 {
t.Errorf("expected 1 entry, got %d", len(list))
}
// Get
got, err := repo.Get(entryID)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if got["resourceType"] != "source" {
t.Errorf("expected resourceType source, got %v", got["resourceType"])
}
// Delete
err = repo.Delete(entryID)
if err != nil {
t.Fatalf("delete failed: %v", err)
}
list, _ = repo.List()
if len(list) != 0 {
t.Errorf("expected 0 entries after delete, got %d", len(list))
}
}
func TestSortSources(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSourceRepo(db)
repo.Upsert(model.SourceRecord{ID: "a", Name: "A", Type: "local", Content: "", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
repo.Upsert(model.SourceRecord{ID: "b", Name: "B", Type: "local", Content: "", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
repo.Upsert(model.SourceRecord{ID: "c", Name: "C", Type: "local", Content: "", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
// Sort reverse
err := repo.Sort([]string{"c", "b", "a"})
if err != nil {
t.Fatalf("sort failed: %v", err)
}
list, _ := repo.List()
if list[0].ID != "c" || list[1].ID != "b" || list[2].ID != "a" {
t.Errorf("sort order wrong: %s, %s, %s", list[0].ID, list[1].ID, list[2].ID)
}
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
+69
View File
@@ -0,0 +1,69 @@
package database
import (
"database/sql"
"time"
"github.com/jmoiron/sqlx"
"github.com/peterqiu0516/sub-store/internal/util"
)
const settingsID = "default"
type SettingsRepo struct {
db *sqlx.DB
}
func NewSettingsRepo(db *sqlx.DB) *SettingsRepo {
return &SettingsRepo{db: db}
}
func (r *SettingsRepo) Get() (map[string]any, error) {
var valueJSON string
err := r.db.Get(&valueJSON, "SELECT value_json FROM app_settings WHERE id = ?", settingsID)
if err != nil {
if err == sql.ErrNoRows {
return map[string]any{}, nil
}
return map[string]any{}, err
}
var settings map[string]any
jsonUnmarshal(valueJSON, &settings)
if settings == nil {
settings = map[string]any{}
}
return settings, nil
}
func (r *SettingsRepo) Update(next map[string]any) (map[string]any, error) {
current, err := r.Get()
if err != nil {
return nil, err
}
merged := util.MergeDeep(current, next)
now := time.Now().UnixMilli()
mergedJSON := marshalJSON(merged)
_, err = r.db.Exec(
`INSERT INTO app_settings (id, value_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
settingsID, mergedJSON, now,
)
if err != nil {
return nil, err
}
return merged, nil
}
func (r *SettingsRepo) Replace(settings map[string]any) error {
now := time.Now().UnixMilli()
mergedJSON := marshalJSON(settings)
_, err := r.db.Exec(
`INSERT INTO app_settings (id, value_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
settingsID, mergedJSON, now,
)
return err
}
+157
View File
@@ -0,0 +1,157 @@
package database
import (
"database/sql"
"time"
"github.com/jmoiron/sqlx"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
)
type SourceRepo struct {
db *sqlx.DB
}
func NewSourceRepo(db *sqlx.DB) *SourceRepo {
return &SourceRepo{db: db}
}
type sourceRow struct {
ID string `db:"id"`
Name string `db:"name"`
Type string `db:"type"`
URL string `db:"url"`
Content string `db:"content"`
Enabled int `db:"enabled"`
FiltersJSON string `db:"filters_json"`
MetaJSON string `db:"meta_json"`
CreatedAt int64 `db:"created_at"`
UpdatedAt int64 `db:"updated_at"`
}
func (r *SourceRepo) List() ([]model.SourceRecord, error) {
var rows []sourceRow
if err := r.db.Select(&rows, "SELECT * FROM sources ORDER BY created_at ASC"); err != nil {
return nil, err
}
result := make([]model.SourceRecord, 0, len(rows))
for _, row := range rows {
result = append(result, sourceFromRow(row))
}
return result, nil
}
func (r *SourceRepo) Get(id string) (*model.SourceRecord, error) {
var row sourceRow
if err := r.db.Get(&row, "SELECT * FROM sources WHERE id = ?", id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
rec := sourceFromRow(row)
return &rec, nil
}
func (r *SourceRepo) Upsert(input model.SourceRecord) (model.SourceRecord, error) {
now := time.Now().UnixMilli()
id := input.ID
if id == "" {
id = util.ToId(input.Name)
}
existing, _ := r.Get(id)
createdAt := now
if existing != nil {
createdAt = existing.CreatedAt
}
filters := input.Filters
if filters == nil {
filters = []model.FilterRule{}
}
meta := input.Meta
if meta == nil {
meta = map[string]any{}
}
rec := model.SourceRecord{
ID: id,
Name: input.Name,
Type: input.Type,
URL: input.URL,
Content: input.Content,
Enabled: input.Enabled,
Filters: filters,
Meta: meta,
CreatedAt: createdAt,
UpdatedAt: now,
}
filtersJSON := marshalJSON(filters)
metaJSON := marshalJSON(meta)
enabled := boolToInt(rec.Enabled)
sourceType := rec.Type
if sourceType == "" {
sourceType = "remote"
}
if sourceType != "local" {
sourceType = "remote"
}
_, err := r.db.Exec(
`INSERT INTO sources (id, name, type, url, content, enabled, filters_json, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, type = excluded.type, url = excluded.url,
content = excluded.content, enabled = excluded.enabled,
filters_json = excluded.filters_json, meta_json = excluded.meta_json,
updated_at = excluded.updated_at`,
rec.ID, rec.Name, sourceType, rec.URL, rec.Content, enabled, filtersJSON, metaJSON, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return rec, err
}
return rec, nil
}
func (r *SourceRepo) Delete(id string) error {
_, err := r.db.Exec("DELETE FROM sources WHERE id = ?", id)
return err
}
func (r *SourceRepo) Sort(ids []string) error {
now := time.Now().UnixMilli()
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range ids {
_, err := tx.Exec("UPDATE sources SET created_at = ?, updated_at = ? WHERE id = ?", now+int64(i), now, id)
if err != nil {
return err
}
}
return tx.Commit()
}
func sourceFromRow(row sourceRow) model.SourceRecord {
sourceType := "remote"
if row.Type == "local" {
sourceType = "local"
}
return model.SourceRecord{
ID: row.ID,
Name: row.Name,
Type: sourceType,
URL: row.URL,
Content: row.Content,
Enabled: row.Enabled != 0,
Filters: model.UnmarshalFilterRules(row.FiltersJSON),
Meta: model.UnmarshalMeta(row.MetaJSON),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
+121
View File
@@ -0,0 +1,121 @@
package database
import (
"database/sql"
"time"
"github.com/jmoiron/sqlx"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
)
type TemplateRepo struct {
db *sqlx.DB
}
func NewTemplateRepo(db *sqlx.DB) *TemplateRepo {
return &TemplateRepo{db: db}
}
type templateRow struct {
ID string `db:"id"`
Name string `db:"name"`
Target string `db:"target"`
ConfigJSON string `db:"config_json"`
CreatedAt int64 `db:"created_at"`
UpdatedAt int64 `db:"updated_at"`
}
func (r *TemplateRepo) List() ([]model.TemplateRecord, error) {
var rows []templateRow
if err := r.db.Select(&rows, "SELECT * FROM templates ORDER BY created_at ASC"); err != nil {
return nil, err
}
result := make([]model.TemplateRecord, 0, len(rows))
for _, row := range rows {
result = append(result, templateFromRow(row))
}
return result, nil
}
func (r *TemplateRepo) Get(id string) (*model.TemplateRecord, error) {
var row templateRow
if err := r.db.Get(&row, "SELECT * FROM templates WHERE id = ?", id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
rec := templateFromRow(row)
return &rec, nil
}
func (r *TemplateRepo) Upsert(input model.TemplateRecord) (model.TemplateRecord, error) {
now := time.Now().UnixMilli()
id := input.ID
if id == "" {
id = util.ToId(input.Name)
}
existing, _ := r.Get(id)
createdAt := now
if existing != nil {
createdAt = existing.CreatedAt
}
target := input.Target
if target == "" {
target = "mihomo"
}
config := input.Config
if config == nil {
config = map[string]any{}
}
rec := model.TemplateRecord{
ID: id,
Name: input.Name,
Target: target,
Config: config,
CreatedAt: createdAt,
UpdatedAt: now,
}
configJSON := marshalJSON(rec.Config)
_, err := r.db.Exec(
`INSERT INTO templates (id, name, target, config_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, target = excluded.target,
config_json = excluded.config_json, updated_at = excluded.updated_at`,
rec.ID, rec.Name, rec.Target, configJSON, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return rec, err
}
return rec, nil
}
func (r *TemplateRepo) Delete(id string) error {
_, err := r.db.Exec("DELETE FROM templates WHERE id = ?", id)
return err
}
func templateFromRow(row templateRow) model.TemplateRecord {
var config map[string]any
jsonUnmarshal(row.ConfigJSON, &config)
if config == nil {
config = map[string]any{}
}
target := row.Target
if target == "" {
target = "mihomo"
}
return model.TemplateRecord{
ID: row.ID,
Name: row.Name,
Target: target,
Config: config,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}