package db import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" ) // ─────────────────────────── Users ─────────────────────────── // CreateUser inserts a new user and returns it with the assigned ID. func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) { admin := 0 if isAdmin { admin = 1 } t := now() res, err := d.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, is_admin, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, username, passwordHash, admin, t, t, ) if err != nil { return nil, fmt.Errorf("create user: %w", err) } id, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("create user last insert id: %w", err) } return d.GetUserByID(ctx, id) } // GetUserByID returns a single user by primary key. func (d *DB) GetUserByID(ctx context.Context, id int64) (*User, error) { row := d.db.QueryRowContext(ctx, `SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path FROM users WHERE id = ?`, id) return scanUser(row) } // SetUserAvatar updates the avatar_path for a user. Pass nil to clear. func (d *DB) SetUserAvatar(ctx context.Context, id int64, avatarPath *string) error { _, err := d.db.ExecContext(ctx, `UPDATE users SET avatar_path = ?, updated_at = ? WHERE id = ?`, avatarPath, now(), id) return err } // GetUserByUsername returns a single user by username. func (d *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := d.db.QueryRowContext(ctx, `SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path FROM users WHERE username = ?`, username) return scanUser(row) } // ListUsers returns all users ordered by username. func (d *DB) ListUsers(ctx context.Context) ([]User, error) { rows, err := d.db.QueryContext(ctx, `SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path FROM users ORDER BY username`) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() users := make([]User, 0) for rows.Next() { u, err := scanUser(rows) if err != nil { return nil, err } users = append(users, *u) } return users, rows.Err() } // DeleteUser removes a user by ID. Returns sql.ErrNoRows if not found. func (d *DB) DeleteUser(ctx context.Context, id int64) error { res, err := d.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete user: %w", err) } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } // UpdateUserPassword sets a new password hash for the user. func (d *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { res, err := d.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), id) if err != nil { return fmt.Errorf("update password: %w", err) } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } type userScanner interface { Scan(dest ...any) error } func scanUser(s userScanner) (*User, error) { var u User var isAdmin int var createdAt, updatedAt string var avatarPath *string if err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &isAdmin, &createdAt, &updatedAt, &avatarPath); err != nil { return nil, err } u.IsAdmin = isAdmin == 1 u.CreatedAt, _ = scanTime(createdAt) u.UpdatedAt, _ = scanTime(updatedAt) u.AvatarPath = avatarPath return &u, nil } // ─────────────────────────── Sessions ─────────────────────────── // CreateSession inserts a new session and returns it. func (d *DB) CreateSession(ctx context.Context, userID int64, token string, expiresAt time.Time) (*Session, error) { t := now() res, err := d.db.ExecContext(ctx, `INSERT INTO sessions (user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?)`, userID, token, expiresAt.UTC().Format(time.RFC3339), t, ) if err != nil { return nil, fmt.Errorf("create session: %w", err) } id, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("create session last insert id: %w", err) } return &Session{ ID: id, UserID: userID, Token: token, ExpiresAt: expiresAt, }, nil } // GetSessionByToken retrieves a session by its token. func (d *DB) GetSessionByToken(ctx context.Context, token string) (*Session, error) { row := d.db.QueryRowContext(ctx, `SELECT id, user_id, token, expires_at, created_at FROM sessions WHERE token = ?`, token) return scanSession(row) } // DeleteSession removes a session by token. func (d *DB) DeleteSession(ctx context.Context, token string) error { _, err := d.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token) return err } // DeleteUserSessions removes all sessions for a given user. func (d *DB) DeleteUserSessions(ctx context.Context, userID int64) error { _, err := d.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) return err } // ExtendSession sets a new expiration time on the session. func (d *DB) ExtendSession(ctx context.Context, token string, expiresAt time.Time) error { _, err := d.db.ExecContext(ctx, `UPDATE sessions SET expires_at = ? WHERE token = ?`, expiresAt.UTC().Format(time.RFC3339), token) return err } type sessionScanner interface { Scan(dest ...any) error } func scanSession(s sessionScanner) (*Session, error) { var sess Session var createdAt, expiresAt string if err := s.Scan(&sess.ID, &sess.UserID, &sess.Token, &expiresAt, &createdAt); err != nil { return nil, err } sess.ExpiresAt, _ = scanTime(expiresAt) sess.CreatedAt, _ = scanTime(createdAt) return &sess, nil } // ─────────────────────────── Images ─────────────────────────── // CreateImage inserts a new image record. func (d *DB) CreateImage(ctx context.Context, img *Image) (*Image, error) { t := now() innateTagsJSON := marshalStrings(img.InnateTags) res, err := d.db.ExecContext(ctx, `INSERT INTO images (filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, innate_tags, duration, gem_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, img.Filename, img.StoragePath, img.ThumbnailPath, img.UploadedBy, img.FileSize, img.Width, img.Height, img.MimeType, innateTagsJSON, img.Duration, 0, t, t, ) if err != nil { return nil, fmt.Errorf("create image: %w", err) } id, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("create image last insert id: %w", err) } return d.GetImageByID(ctx, id) } // GetImageByID returns a single image by primary key. func (d *DB) GetImageByID(ctx context.Context, id int64) (*Image, error) { row := d.db.QueryRowContext(ctx, `SELECT id, filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, innate_tags, duration, gem_count, created_at, updated_at FROM images WHERE id = ?`, id) img, err := scanImage(row) if err != nil { return nil, err } // Load tags for this image. tags, err := d.GetImageTags(ctx, id) if err != nil { return nil, err } img.Tags = tags return img, nil } // ListImages returns a paginated list of images, optionally filtered by tags // and/or uploader username. Multiple tags are AND-ed (image must have all // specified tags). Tag filtering also matches innate tags stored in the // innate_tags JSON column. If sortGems is true, results are ordered by // gem_count DESC then created_at DESC. If filterGems is true, only images // with gem_count > 0 are returned. func (d *DB) ListImages(ctx context.Context, filterTags []string, filterUploader string, limit, offset int, sortGems, filterGems bool) (*ImageList, error) { // Count query countQuery := `SELECT COUNT(DISTINCT i.id) FROM images i` // Data query dataQuery := `SELECT i.id, i.filename, i.storage_path, i.thumbnail_path, i.uploaded_by, i.file_size, i.width, i.height, i.mime_type, i.innate_tags, i.duration, i.gem_count, i.created_at, i.updated_at FROM images i` var args []any var tagJoin string var conditions []string if len(filterTags) > 0 { placeholders := make([]string, len(filterTags)) for j, t := range filterTags { placeholders[j] = "?" args = append(args, t) } // Union image_tags and innate_tags so both are searchable via tag filter. tagJoin = ` JOIN ( SELECT image_id, tag FROM image_tags UNION ALL SELECT i.id, j.value FROM images i, json_each(COALESCE(i.innate_tags, '[]')) j ) all_tags ON i.id = all_tags.image_id` conditions = append(conditions, `all_tags.tag IN (`+strings.Join(placeholders, ",")+`)`) } if filterUploader != "" { conditions = append(conditions, `i.uploaded_by = (SELECT id FROM users WHERE username = ?)`) args = append(args, filterUploader) } if filterGems { conditions = append(conditions, `i.gem_count > 0`) } // Build WHERE clause. whereClause := strings.Join(conditions, " AND ") if whereClause != "" { whereClause = ` WHERE ` + whereClause } countQuery += tagJoin + whereClause dataQuery += tagJoin + whereClause if len(filterTags) > 0 { dataQuery += ` GROUP BY i.id HAVING COUNT(DISTINCT all_tags.tag) = ` + fmt.Sprintf("%d", len(filterTags)) } // ORDER BY. if sortGems { dataQuery += ` ORDER BY i.gem_count DESC, i.created_at DESC` } else { dataQuery += ` ORDER BY i.created_at DESC` } dataQuery += ` LIMIT ? OFFSET ?` argsData := make([]any, len(args)) copy(argsData, args) argsData = append(argsData, limit, offset) args = append(args, limit, offset) // Execute count. var total int if err := d.db.QueryRowContext(ctx, countQuery, argsData[:len(argsData)-2]...).Scan(&total); err != nil { return nil, fmt.Errorf("list images count: %w", err) } // Execute data. rows, err := d.db.QueryContext(ctx, dataQuery, argsData...) if err != nil { return nil, fmt.Errorf("list images: %w", err) } defer rows.Close() images := make([]Image, 0) for rows.Next() { img, err := scanImage(rows) if err != nil { return nil, err } images = append(images, *img) } if err := rows.Err(); err != nil { return nil, err } return &ImageList{Images: images, Total: total}, nil } // DeleteImage removes an image record (tags cleaned via ON DELETE CASCADE). func (d *DB) DeleteImage(ctx context.Context, id int64) error { // First decrement usage counts for associated tags. tags, err := d.GetImageTags(ctx, id) if err != nil { return err } for _, tag := range tags { if err := d.decrementTagUsage(ctx, tag); err != nil { return err } } res, err := d.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete image: %w", err) } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } // ListAllImages returns all images ordered by id ascending, without tag/uploader // filtering. Used by CLI commands that need to iterate over every record. func (d *DB) ListAllImages(ctx context.Context) ([]Image, error) { rows, err := d.db.QueryContext(ctx, `SELECT id, filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, innate_tags, duration, gem_count, created_at, updated_at FROM images ORDER BY id`) if err != nil { return nil, fmt.Errorf("list all images: %w", err) } defer rows.Close() images := make([]Image, 0) for rows.Next() { img, err := scanImage(rows) if err != nil { return nil, err } images = append(images, *img) } return images, rows.Err() } // UpdateImageInnateTags sets the innate_tags column for a single image. func (d *DB) UpdateImageInnateTags(ctx context.Context, id int64, tags []string) error { _, err := d.db.ExecContext(ctx, `UPDATE images SET innate_tags = ?, updated_at = ? WHERE id = ?`, marshalStrings(tags), now(), id) return err } // UpdateImageVideoMetadata updates the width, height, and duration columns for // a single image. Used by CLI repair commands to backfill metadata for videos // that were uploaded before video dimension/duration probing was added. func (d *DB) UpdateImageVideoMetadata(ctx context.Context, id int64, width, height int, duration float64) error { _, err := d.db.ExecContext(ctx, `UPDATE images SET width = ?, height = ?, duration = ?, updated_at = ? WHERE id = ?`, width, height, duration, now(), id) return err } // ─────────────────────────── Gems ─────────────────────────── // ToggleGem adds or removes a gem from an image for a user. // Returns the new gem status (true=gemmed) and updated gem count. func (d *DB) ToggleGem(ctx context.Context, imageID, userID int64) (bool, int, error) { var count int err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM image_gems WHERE image_id = ? AND user_id = ?`, imageID, userID).Scan(&count) if err != nil { return false, 0, fmt.Errorf("check gem: %w", err) } if count > 0 { _, err = d.db.ExecContext(ctx, `DELETE FROM image_gems WHERE image_id = ? AND user_id = ?`, imageID, userID) if err != nil { return false, 0, fmt.Errorf("remove gem: %w", err) } _, err = d.db.ExecContext(ctx, `UPDATE images SET gem_count = MAX(0, gem_count - 1), updated_at = ? WHERE id = ?`, now(), imageID) if err != nil { return false, 0, fmt.Errorf("decrement gem count: %w", err) } } else { _, err = d.db.ExecContext(ctx, `INSERT INTO image_gems (image_id, user_id, created_at) VALUES (?, ?, ?)`, imageID, userID, now()) if err != nil { return false, 0, fmt.Errorf("add gem: %w", err) } _, err = d.db.ExecContext(ctx, `UPDATE images SET gem_count = gem_count + 1, updated_at = ? WHERE id = ?`, now(), imageID) if err != nil { return false, 0, fmt.Errorf("increment gem count: %w", err) } } var gemCount int d.db.QueryRowContext(ctx, `SELECT gem_count FROM images WHERE id = ?`, imageID).Scan(&gemCount) return count == 0, gemCount, nil } // GetUserGemStatus returns true if the user has gemmed the specified image. func (d *DB) GetUserGemStatus(ctx context.Context, imageID, userID int64) (bool, error) { var count int err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM image_gems WHERE image_id = ? AND user_id = ?`, imageID, userID).Scan(&count) return count > 0, err } type imageScanner interface { Scan(dest ...any) error } func scanImage(s imageScanner) (*Image, error) { var img Image var thumbnailPath, innateTags, createdAt, updatedAt sql.NullString var duration sql.NullFloat64 if err := s.Scan( &img.ID, &img.Filename, &img.StoragePath, &thumbnailPath, &img.UploadedBy, &img.FileSize, &img.Width, &img.Height, &img.MimeType, &innateTags, &duration, &img.GemCount, &createdAt, &updatedAt, ); err != nil { return nil, err } if thumbnailPath.Valid { img.ThumbnailPath = &thumbnailPath.String } if innateTags.Valid { img.InnateTags = unmarshalStrings(innateTags.String) } if duration.Valid { img.Duration = duration.Float64 } if createdAt.Valid { img.CreatedAt, _ = scanTime(createdAt.String) } if updatedAt.Valid { img.UpdatedAt, _ = scanTime(updatedAt.String) } return &img, nil } // ─────────────────────────── Tags ─────────────────────────── // SetImageTags replaces all tags for an image and updates usage counts. func (d *DB) SetImageTags(ctx context.Context, imageID int64, tags []string) error { // Get existing tags to decrement counts. oldTags, err := d.GetImageTags(ctx, imageID) if err != nil { return err } // Remove old associations. if _, err := d.db.ExecContext(ctx, `DELETE FROM image_tags WHERE image_id = ?`, imageID); err != nil { return fmt.Errorf("clear image tags: %w", err) } for _, t := range oldTags { if err := d.decrementTagUsage(ctx, t); err != nil { return err } } // Insert new tags. for _, t := range tags { tag := normalizeTag(t) if tag == "" { continue } if _, err := d.db.ExecContext(ctx, `INSERT INTO image_tags (image_id, tag) VALUES (?, ?)`, imageID, tag, ); err != nil { return fmt.Errorf("insert image tag: %w", err) } if err := d.incrementTagUsage(ctx, tag); err != nil { return err } } return nil } // GetImageTags returns the tag strings for a given image. func (d *DB) GetImageTags(ctx context.Context, imageID int64) ([]string, error) { rows, err := d.db.QueryContext(ctx, `SELECT tag FROM image_tags WHERE image_id = ? ORDER BY tag`, imageID) if err != nil { return nil, fmt.Errorf("get image tags: %w", err) } defer rows.Close() tags := make([]string, 0) for rows.Next() { var t string if err := rows.Scan(&t); err != nil { return nil, err } tags = append(tags, t) } return tags, rows.Err() } // GetImageTagsWithCounts returns tags and their usage counts for a given image, // ordered by most frequent first. func (d *DB) GetImageTagsWithCounts(ctx context.Context, imageID int64) ([]Tag, error) { rows, err := d.db.QueryContext(ctx, `SELECT t.tag, t.usage_count FROM tags t JOIN image_tags it ON it.tag = t.tag WHERE it.image_id = ? ORDER BY t.usage_count DESC, t.tag`, imageID) if err != nil { return nil, fmt.Errorf("get image tags with counts: %w", err) } defer rows.Close() tags := make([]Tag, 0) for rows.Next() { var t Tag if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil { return nil, err } tags = append(tags, t) } return tags, rows.Err() } // SuggestTags returns tags matching a prefix, ordered by usage count descending. // SuggestUploaders returns usernames matching a prefix, ordered by upload count descending. func (d *DB) SuggestUploaders(ctx context.Context, prefix string, limit int) ([]Tag, error) { rows, err := d.db.QueryContext(ctx, `SELECT u.username, COUNT(i.id) AS cnt FROM users u LEFT JOIN images i ON i.uploaded_by = u.id WHERE u.username LIKE ? GROUP BY u.id, u.username ORDER BY cnt DESC, u.username LIMIT ?`, prefix+"%", limit) if err != nil { return nil, fmt.Errorf("suggest uploaders: %w", err) } defer rows.Close() users := make([]Tag, 0) for rows.Next() { var t Tag if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil { return nil, err } users = append(users, t) } return users, rows.Err() } func (d *DB) SuggestTags(ctx context.Context, prefix string, limit int) ([]Tag, error) { rows, err := d.db.QueryContext(ctx, `SELECT tag, usage_count FROM ( SELECT tag, usage_count FROM tags WHERE tag LIKE ? UNION ALL SELECT j.value AS tag, COUNT(*) AS usage_count FROM images i, json_each(COALESCE(i.innate_tags, '[]')) j WHERE j.value LIKE ? GROUP BY j.value ) ORDER BY usage_count DESC, tag LIMIT ?`, prefix+"%", prefix+"%", limit) if err != nil { return nil, fmt.Errorf("suggest tags: %w", err) } defer rows.Close() tags := make([]Tag, 0) for rows.Next() { var t Tag if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil { return nil, err } tags = append(tags, t) } return tags, rows.Err() } func (d *DB) incrementTagUsage(ctx context.Context, tag string) error { _, err := d.db.ExecContext(ctx, `INSERT INTO tags (tag, usage_count) VALUES (?, 1) ON CONFLICT(tag) DO UPDATE SET usage_count = usage_count + 1`, tag) return err } func (d *DB) decrementTagUsage(ctx context.Context, tag string) error { _, err := d.db.ExecContext(ctx, `UPDATE tags SET usage_count = usage_count - 1 WHERE tag = ? AND usage_count > 0`, tag) if err != nil { return err } // Clean up tags with zero usage. _, err = d.db.ExecContext(ctx, `DELETE FROM tags WHERE tag = ? AND usage_count <= 0`, tag) return err } // AddImageTags adds tags to an image, skipping existing ones, and increments usage counts. func (d *DB) AddImageTags(ctx context.Context, imageID int64, tags []string) error { for _, t := range tags { tag := normalizeTag(t) if tag == "" { continue } // Check if tag already exists for this image. var count int if err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM image_tags WHERE image_id = ? AND tag = ?`, imageID, tag).Scan(&count); err != nil { return fmt.Errorf("check existing tag: %w", err) } if count > 0 { continue } if _, err := d.db.ExecContext(ctx, `INSERT INTO image_tags (image_id, tag) VALUES (?, ?)`, imageID, tag, ); err != nil { return fmt.Errorf("insert image tag: %w", err) } if err := d.incrementTagUsage(ctx, tag); err != nil { return err } } return nil } // RemoveImageTags removes specific tags from an image and decrements usage counts. func (d *DB) RemoveImageTags(ctx context.Context, imageID int64, tags []string) error { for _, t := range tags { tag := normalizeTag(t) if tag == "" { continue } res, err := d.db.ExecContext(ctx, `DELETE FROM image_tags WHERE image_id = ? AND tag = ?`, imageID, tag) if err != nil { return fmt.Errorf("remove image tag: %w", err) } n, _ := res.RowsAffected() if n > 0 { if err := d.decrementTagUsage(ctx, tag); err != nil { return err } } } return nil } // normalizeTag trims whitespace, lowercases, and collapses internal whitespace. func normalizeTag(tag string) string { return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(tag))), " ") } // marshalStrings marshals a string slice to a JSON array for storage in TEXT columns. func marshalStrings(s []string) string { if len(s) == 0 { return "[]" } b, _ := json.Marshal(s) return string(b) } // unmarshalStrings unmarshals a JSON array from a TEXT column back to a string slice. func unmarshalStrings(s string) []string { if s == "" { return nil } var result []string json.Unmarshal([]byte(s), &result) return result }