248 lines
8.5 KiB
Go
248 lines
8.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Space operations
|
|
|
|
func (r *sqliteRepo) CreateSpace(ctx context.Context, space *Space) (*Space, error) {
|
|
now := utcNow()
|
|
space.ID = uuid.NewString()
|
|
space.CreatedAt = now
|
|
space.UpdatedAt = now
|
|
|
|
_, err := r.db.ExecContext(ctx,
|
|
`INSERT INTO spaces (id, slug, name, description, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
space.ID, space.Slug, space.Name, space.Description, space.IsPublic, space.CreatedAt, space.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: create space: %w", err)
|
|
}
|
|
return space, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) {
|
|
row := r.db.QueryRowContext(ctx,
|
|
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE slug = ?`, slug)
|
|
space := &Space{}
|
|
if err := row.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: get space by slug: %w", err)
|
|
}
|
|
return space, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) UpdateSpace(ctx context.Context, space *Space) (*Space, error) {
|
|
space.UpdatedAt = utcNow()
|
|
_, err := r.db.ExecContext(ctx,
|
|
`UPDATE spaces SET name = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?`,
|
|
space.Name, space.Description, space.IsPublic, space.UpdatedAt, space.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: update space: %w", err)
|
|
}
|
|
return space, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) DeleteSpace(ctx context.Context, id string) error {
|
|
_, err := r.db.ExecContext(ctx, `DELETE FROM spaces WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete space: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *sqliteRepo) ListPublicSpaces(ctx context.Context) ([]*Space, error) {
|
|
rows, err := r.db.QueryContext(ctx,
|
|
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE is_public = TRUE ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list public spaces: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var spaces []*Space
|
|
for rows.Next() {
|
|
space := &Space{}
|
|
if err := rows.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: scan space: %w", err)
|
|
}
|
|
spaces = append(spaces, space)
|
|
}
|
|
return spaces, rows.Err()
|
|
}
|
|
|
|
// Photo operations
|
|
|
|
func (r *sqliteRepo) CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error) {
|
|
photo.ID = uuid.NewString()
|
|
photo.CreatedAt = utcNow()
|
|
|
|
_, err := r.db.ExecContext(ctx,
|
|
`INSERT INTO photos (id, space_id, url, thumbnail_url, face_count, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
photo.ID, photo.SpaceID, photo.URL, photo.ThumbnailURL, photo.FaceCount, photo.Metadata, photo.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: create photo: %w", err)
|
|
}
|
|
return photo, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) GetPhoto(ctx context.Context, id string) (*Photo, error) {
|
|
row := r.db.QueryRowContext(ctx,
|
|
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE id = ?`, id)
|
|
photo := &Photo{}
|
|
if err := row.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: get photo: %w", err)
|
|
}
|
|
return photo, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
rows, err := r.db.QueryContext(ctx,
|
|
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE space_id = ? ORDER BY created_at DESC LIMIT ?`,
|
|
spaceID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list photos by space: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var photos []*Photo
|
|
for rows.Next() {
|
|
photo := &Photo{}
|
|
if err := rows.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: scan photo: %w", err)
|
|
}
|
|
photos = append(photos, photo)
|
|
}
|
|
return photos, rows.Err()
|
|
}
|
|
|
|
func (r *sqliteRepo) DeletePhoto(ctx context.Context, id string) error {
|
|
_, err := r.db.ExecContext(ctx, `DELETE FROM photos WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete photo: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Face embedding operations
|
|
|
|
func (r *sqliteRepo) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error) {
|
|
embedding.ID = uuid.NewString()
|
|
embedding.CreatedAt = utcNow()
|
|
|
|
// Convert embedding to JSON
|
|
embeddingJSON, err := json.Marshal(embedding.Embedding)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: marshal face embedding: %w", err)
|
|
}
|
|
|
|
_, err = r.db.ExecContext(ctx,
|
|
`INSERT INTO face_embeddings (id, photo_id, space_id, embedding, bounding_box, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
embedding.ID, embedding.PhotoID, embedding.SpaceID, embeddingJSON, embedding.BoundingBox, embedding.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: create face embedding: %w", err)
|
|
}
|
|
return embedding, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error) {
|
|
rows, err := r.db.QueryContext(ctx,
|
|
`SELECT id, photo_id, space_id, embedding, bounding_box, created_at FROM face_embeddings WHERE space_id = ?`,
|
|
spaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list face embeddings by space: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var embeddings []*FaceEmbedding
|
|
for rows.Next() {
|
|
embedding := &FaceEmbedding{}
|
|
var embeddingJSON []byte
|
|
if err := rows.Scan(&embedding.ID, &embedding.PhotoID, &embedding.SpaceID, &embeddingJSON, &embedding.BoundingBox, &embedding.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("store: scan face embedding: %w", err)
|
|
}
|
|
if err := json.Unmarshal(embeddingJSON, &embedding.Embedding); err != nil {
|
|
return nil, fmt.Errorf("store: unmarshal face embedding: %w", err)
|
|
}
|
|
embeddings = append(embeddings, embedding)
|
|
}
|
|
return embeddings, rows.Err()
|
|
}
|
|
|
|
func (r *sqliteRepo) SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
// For now, return all embeddings in the space (cosine similarity search would be implemented here)
|
|
// In a real implementation, you'd compute cosine similarity between the query embedding and all embeddings
|
|
embeddings, err := r.ListFaceEmbeddingsBySpace(ctx, spaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Return up to limit results
|
|
if len(embeddings) > limit {
|
|
embeddings = embeddings[:limit]
|
|
}
|
|
return embeddings, nil
|
|
}
|
|
|
|
// Rate limiting operations
|
|
|
|
func (r *sqliteRepo) CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
|
|
// Calculate window start time
|
|
windowStart := time.Now().UTC().Add(-time.Duration(windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
|
|
|
|
var count int
|
|
err := r.db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(SUM(request_count), 0) FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start >= ?`,
|
|
ipAddress, spaceID, windowStart).Scan(&count)
|
|
if err != nil {
|
|
return false, fmt.Errorf("store: check rate limit: %w", err)
|
|
}
|
|
|
|
return count < limit, nil
|
|
}
|
|
|
|
func (r *sqliteRepo) IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error {
|
|
// Calculate window start time (rounded to the nearest window)
|
|
now := time.Now().UTC()
|
|
windowStart := now.Add(-time.Duration(now.Minute()%windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
|
|
|
|
// Check if there's an existing record for this IP, space, and window
|
|
var id string
|
|
err := r.db.QueryRowContext(ctx,
|
|
`SELECT id FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start = ?`,
|
|
ipAddress, spaceID, windowStart).Scan(&id)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// Create new record
|
|
id = uuid.NewString()
|
|
_, err = r.db.ExecContext(ctx,
|
|
`INSERT INTO rate_limits (id, ip_address, space_id, request_count, window_start, created_at) VALUES (?, ?, ?, 1, ?, ?)`,
|
|
id, ipAddress, spaceID, windowStart, utcNow())
|
|
if err != nil {
|
|
return fmt.Errorf("store: create rate limit: %w", err)
|
|
}
|
|
} else if err == nil {
|
|
// Update existing record
|
|
_, err = r.db.ExecContext(ctx,
|
|
`UPDATE rate_limits SET request_count = request_count + 1 WHERE id = ?`,
|
|
id)
|
|
if err != nil {
|
|
return fmt.Errorf("store: increment rate limit: %w", err)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("store: get rate limit: %w", err)
|
|
}
|
|
|
|
return nil
|
|
} |