Files

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())
}