chore: sub-store Go 重写项目初始化
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user