| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- package storage
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "syscall"
- "github.com/google/uuid"
- )
- // MigrationReport counts rows examined and their outcomes. Unknown includes missing,
- // unsafe, unreadable-by-format, or unsupported media, but not I/O errors.
- type MigrationReport struct{ Examined, Updated, Unknown int }
- // MigrateLegacyImages adds legacy image columns and backfills missing media types.
- // The caller must stop all database and media writers and create a verified backup
- // first. All database changes are committed together. Existing values are preserved.
- // category is the intended category for rows when that column is absent.
- func MigrateLegacyImages(ctx context.Context, db *sql.DB, mediaDir, category string) (MigrationReport, error) {
- var report MigrationReport
- if strings.TrimSpace(category) == "" {
- return report, errors.New("legacy category must not be empty")
- }
- info, err := os.Stat(mediaDir)
- if err != nil {
- return report, fmt.Errorf("media directory: %w", err)
- }
- if !info.IsDir() {
- return report, errors.New("media path is not a directory")
- }
- tx, err := db.BeginTx(ctx, nil)
- if err != nil {
- return report, err
- }
- defer tx.Rollback()
- rows, err := tx.QueryContext(ctx, `PRAGMA table_info(images)`)
- if err != nil {
- return report, err
- }
- columns := map[string]bool{}
- for rows.Next() {
- var cid, notnull, pk int
- var name, typ string
- var def sql.NullString
- if err = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk); err != nil {
- rows.Close()
- return report, err
- }
- columns[strings.ToLower(name)] = true
- }
- err = rows.Err()
- rows.Close()
- if err != nil {
- return report, err
- }
- if !columns["id"] {
- return report, errors.New("images table missing or has no id column")
- }
- if !columns["category"] {
- if _, err = tx.ExecContext(ctx, `ALTER TABLE images ADD COLUMN category TEXT`); err != nil {
- return report, err
- }
- if _, err = tx.ExecContext(ctx, `UPDATE images SET category = ?`, category); err != nil {
- return report, err
- }
- }
- if !columns["media_type"] {
- if _, err = tx.ExecContext(ctx, `ALTER TABLE images ADD COLUMN media_type TEXT`); err != nil {
- return report, err
- }
- }
- rows, err = tx.QueryContext(ctx, `SELECT rowid, id FROM images WHERE media_type IS NULL OR media_type = ''`)
- if err != nil {
- return report, err
- }
- type item struct {
- row int64
- id sql.NullString
- }
- var items []item
- for rows.Next() {
- var i item
- if err = rows.Scan(&i.row, &i.id); err != nil {
- rows.Close()
- return report, err
- }
- items = append(items, i)
- }
- err = rows.Err()
- rows.Close()
- if err != nil {
- return report, err
- }
- for _, i := range items {
- report.Examined++
- mediaType, e := migrationMediaType(mediaDir, i.id.String)
- if e != nil {
- return MigrationReport{}, fmt.Errorf("media for row %d: %w", i.row, e)
- }
- if mediaType == "" {
- report.Unknown++
- continue
- }
- if _, err = tx.ExecContext(ctx, `UPDATE images SET media_type = ? WHERE rowid = ?`, mediaType, i.row); err != nil {
- return MigrationReport{}, err
- }
- report.Updated++
- }
- if err = tx.Commit(); err != nil {
- return MigrationReport{}, err
- }
- return report, nil
- }
- // Read only canonical UUID filenames, never follow symlinks, and bound memory.
- func migrationMediaType(dir, name string) (string, error) {
- id, err := uuid.Parse(name)
- if err != nil || id.String() != name {
- return "", nil
- }
- fd, err := syscall.Open(filepath.Join(dir, name), syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
- if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ELOOP) {
- return "", nil
- }
- if err != nil {
- return "", err
- }
- f := os.NewFile(uintptr(fd), name)
- defer f.Close()
- info, err := f.Stat()
- if err != nil {
- return "", err
- }
- if !info.Mode().IsRegular() {
- return "", nil
- }
- data, err := io.ReadAll(io.LimitReader(f, 1<<20))
- if err != nil {
- return "", err
- }
- return detectMediaType(data), nil
- }
|