207 lines
5.5 KiB
Go
207 lines
5.5 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Repository provides database access for sync operations.
|
|
type Repository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewRepository(db *sql.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
// APIKeyRecord represents a stored API key.
|
|
type APIKeyRecord struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"userId"`
|
|
Name string `json:"name"`
|
|
KeyHash string `json:"keyHash"`
|
|
Scopes string `json:"scopes"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
|
}
|
|
|
|
// CreateSnapshot persists a new snapshot and returns it with an ID.
|
|
func (r *Repository) CreateSnapshot(ctx context.Context, deviceID, userID string, files []FileEntry) (*Snapshot, error) {
|
|
id := generateID("snap", deviceID)
|
|
now := time.Now().UTC()
|
|
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO sync_snapshots (id, device_id, user_id, created_at)
|
|
VALUES (?, ?, ?, ?)
|
|
`, id, deviceID, userID, now.Format(time.RFC3339)); err != nil {
|
|
return nil, fmt.Errorf("insert snapshot: %w", err)
|
|
}
|
|
|
|
for _, f := range files {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO sync_files (snapshot_id, path, hash, size_bytes, modified_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, id, f.Path, f.Hash, f.Size, f.Modified.Format(time.RFC3339)); err != nil {
|
|
return nil, fmt.Errorf("insert sync file %s: %w", f.Path, err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit snapshot: %w", err)
|
|
}
|
|
|
|
return &Snapshot{
|
|
ID: id,
|
|
DeviceID: deviceID,
|
|
CreatedAt: now,
|
|
Files: files,
|
|
}, nil
|
|
}
|
|
|
|
// GetSnapshot retrieves a snapshot by ID.
|
|
func (r *Repository) GetSnapshot(ctx context.Context, snapshotID string) (*Snapshot, error) {
|
|
var snap Snapshot
|
|
var created string
|
|
var userID sql.NullString
|
|
|
|
err := r.db.QueryRowContext(ctx, `
|
|
SELECT id, device_id, user_id, created_at
|
|
FROM sync_snapshots
|
|
WHERE id = ?
|
|
`, snapshotID).Scan(&snap.ID, &snap.DeviceID, &userID, &created)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
snap.CreatedAt, err = time.Parse(time.RFC3339, created)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse created_at: %w", err)
|
|
}
|
|
if userID.Valid {
|
|
snap.UserID = userID.String
|
|
}
|
|
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT path, hash, size_bytes, modified_at
|
|
FROM sync_files
|
|
WHERE snapshot_id = ?
|
|
ORDER BY path ASC
|
|
`, snapshotID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query sync files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var f FileEntry
|
|
var modified string
|
|
if err := rows.Scan(&f.Path, &f.Hash, &f.Size, &modified); err != nil {
|
|
return nil, fmt.Errorf("scan sync file: %w", err)
|
|
}
|
|
f.Modified, err = time.Parse(time.RFC3339, modified)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse modified_at: %w", err)
|
|
}
|
|
snap.Files = append(snap.Files, f)
|
|
}
|
|
|
|
return &snap, rows.Err()
|
|
}
|
|
|
|
// ValidateAPIKey checks if an API key hash is valid and returns the associated user.
|
|
func (r *Repository) ValidateAPIKey(ctx context.Context, keyHash string) (*APIKeyRecord, error) {
|
|
var record APIKeyRecord
|
|
var created, expires, lastUsed string
|
|
|
|
err := r.db.QueryRowContext(ctx, `
|
|
SELECT id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at
|
|
FROM api_keys
|
|
WHERE key_hash = ?
|
|
`, keyHash).Scan(
|
|
&record.ID, &record.UserID, &record.Name, &record.KeyHash,
|
|
&record.Scopes, &created, &expires, &lastUsed,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
record.CreatedAt, err = time.Parse(time.RFC3339, created)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse created_at: %w", err)
|
|
}
|
|
if expires != "" {
|
|
t, err := time.Parse(time.RFC3339, expires)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse expires_at: %w", err)
|
|
}
|
|
if time.Now().UTC().After(t) {
|
|
return nil, fmt.Errorf("api key expired")
|
|
}
|
|
record.ExpiresAt = &t
|
|
}
|
|
if lastUsed != "" {
|
|
t, err := time.Parse(time.RFC3339, lastUsed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse last_used_at: %w", err)
|
|
}
|
|
record.LastUsedAt = &t
|
|
}
|
|
|
|
// Update last_used_at
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := r.db.ExecContext(ctx, `
|
|
UPDATE api_keys SET last_used_at = ? WHERE id = ?
|
|
`, now, record.ID); err != nil {
|
|
return nil, fmt.Errorf("update last_used_at: %w", err)
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
// ListLatestFiles returns the most recent file states from the latest snapshot.
|
|
func (r *Repository) ListLatestFiles(ctx context.Context, deviceID string) ([]FileEntry, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT sf.path, sf.hash, sf.size_bytes, sf.modified_at
|
|
FROM sync_files sf
|
|
JOIN sync_snapshots ss ON ss.id = sf.snapshot_id
|
|
WHERE ss.device_id = ?
|
|
AND ss.created_at = (
|
|
SELECT MAX(created_at) FROM sync_snapshots WHERE device_id = ?
|
|
)
|
|
ORDER BY sf.path ASC
|
|
`, deviceID, deviceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query latest files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var files []FileEntry
|
|
for rows.Next() {
|
|
var f FileEntry
|
|
var modified string
|
|
if err := rows.Scan(&f.Path, &f.Hash, &f.Size, &modified); err != nil {
|
|
return nil, fmt.Errorf("scan file: %w", err)
|
|
}
|
|
f.Modified, err = time.Parse(time.RFC3339, modified)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse modified: %w", err)
|
|
}
|
|
files = append(files, f)
|
|
}
|
|
|
|
return files, rows.Err()
|
|
}
|
|
|
|
func generateID(prefix, suffix string) string {
|
|
return fmt.Sprintf("%s:%s:%d", prefix, suffix, time.Now().UnixNano())
|
|
}
|