migration.go 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. package storage
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "syscall"
  12. "github.com/google/uuid"
  13. )
  14. // MigrationReport counts rows examined and their outcomes. Unknown includes missing,
  15. // unsafe, unreadable-by-format, or unsupported media, but not I/O errors.
  16. type MigrationReport struct{ Examined, Updated, Unknown int }
  17. // MigrateLegacyImages adds legacy image columns and backfills missing media types.
  18. // The caller must stop all database and media writers and create a verified backup
  19. // first. All database changes are committed together. Existing values are preserved.
  20. // category is the intended category for rows when that column is absent.
  21. func MigrateLegacyImages(ctx context.Context, db *sql.DB, mediaDir, category string) (MigrationReport, error) {
  22. var report MigrationReport
  23. if strings.TrimSpace(category) == "" {
  24. return report, errors.New("legacy category must not be empty")
  25. }
  26. info, err := os.Stat(mediaDir)
  27. if err != nil {
  28. return report, fmt.Errorf("media directory: %w", err)
  29. }
  30. if !info.IsDir() {
  31. return report, errors.New("media path is not a directory")
  32. }
  33. tx, err := db.BeginTx(ctx, nil)
  34. if err != nil {
  35. return report, err
  36. }
  37. defer tx.Rollback()
  38. rows, err := tx.QueryContext(ctx, `PRAGMA table_info(images)`)
  39. if err != nil {
  40. return report, err
  41. }
  42. columns := map[string]bool{}
  43. for rows.Next() {
  44. var cid, notnull, pk int
  45. var name, typ string
  46. var def sql.NullString
  47. if err = rows.Scan(&cid, &name, &typ, &notnull, &def, &pk); err != nil {
  48. rows.Close()
  49. return report, err
  50. }
  51. columns[strings.ToLower(name)] = true
  52. }
  53. err = rows.Err()
  54. rows.Close()
  55. if err != nil {
  56. return report, err
  57. }
  58. if !columns["id"] {
  59. return report, errors.New("images table missing or has no id column")
  60. }
  61. if !columns["category"] {
  62. if _, err = tx.ExecContext(ctx, `ALTER TABLE images ADD COLUMN category TEXT`); err != nil {
  63. return report, err
  64. }
  65. if _, err = tx.ExecContext(ctx, `UPDATE images SET category = ?`, category); err != nil {
  66. return report, err
  67. }
  68. }
  69. if !columns["media_type"] {
  70. if _, err = tx.ExecContext(ctx, `ALTER TABLE images ADD COLUMN media_type TEXT`); err != nil {
  71. return report, err
  72. }
  73. }
  74. rows, err = tx.QueryContext(ctx, `SELECT rowid, id FROM images WHERE media_type IS NULL OR media_type = ''`)
  75. if err != nil {
  76. return report, err
  77. }
  78. type item struct {
  79. row int64
  80. id sql.NullString
  81. }
  82. var items []item
  83. for rows.Next() {
  84. var i item
  85. if err = rows.Scan(&i.row, &i.id); err != nil {
  86. rows.Close()
  87. return report, err
  88. }
  89. items = append(items, i)
  90. }
  91. err = rows.Err()
  92. rows.Close()
  93. if err != nil {
  94. return report, err
  95. }
  96. for _, i := range items {
  97. report.Examined++
  98. mediaType, e := migrationMediaType(mediaDir, i.id.String)
  99. if e != nil {
  100. return MigrationReport{}, fmt.Errorf("media for row %d: %w", i.row, e)
  101. }
  102. if mediaType == "" {
  103. report.Unknown++
  104. continue
  105. }
  106. if _, err = tx.ExecContext(ctx, `UPDATE images SET media_type = ? WHERE rowid = ?`, mediaType, i.row); err != nil {
  107. return MigrationReport{}, err
  108. }
  109. report.Updated++
  110. }
  111. if err = tx.Commit(); err != nil {
  112. return MigrationReport{}, err
  113. }
  114. return report, nil
  115. }
  116. // Read only canonical UUID filenames, never follow symlinks, and bound memory.
  117. func migrationMediaType(dir, name string) (string, error) {
  118. id, err := uuid.Parse(name)
  119. if err != nil || id.String() != name {
  120. return "", nil
  121. }
  122. fd, err := syscall.Open(filepath.Join(dir, name), syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
  123. if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ELOOP) {
  124. return "", nil
  125. }
  126. if err != nil {
  127. return "", err
  128. }
  129. f := os.NewFile(uintptr(fd), name)
  130. defer f.Close()
  131. info, err := f.Stat()
  132. if err != nil {
  133. return "", err
  134. }
  135. if !info.Mode().IsRegular() {
  136. return "", nil
  137. }
  138. data, err := io.ReadAll(io.LimitReader(f, 1<<20))
  139. if err != nil {
  140. return "", err
  141. }
  142. return detectMediaType(data), nil
  143. }