MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- GET /link-preview?url= fetches the page on the server (https only, SSRF-guarded like webhooks), reads Open Graph/Twitter tags, title, description and icon from the head, loads picture and icon (no SVG) and keeps them in link_previews (migration 6) for 7 days; readers' browsers never contact the page. Off in the demo; 300 fetches per address in 15 minutes. - Compact card above the bullet (icon, site, title, one line, small picture); the duplicate link badge is left out. - ✕ turns the address into [title](url), so hiding syncs with the note; "Link-Vorschauen anzeigen" under appearance switches all off per device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
3.2 KiB
Go
116 lines
3.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
// Schema migrations (FLAW-13). Every step runs exactly once per database and is recorded in
|
|
// schema_migrations; a failing step stops the start with its error instead of being ignored.
|
|
// New schema changes are appended as new steps (next free id), never edited into old ones.
|
|
// PRAGMA user_version mirrors the highest applied id. Ids 1 and 2 were used by earlier releases
|
|
// (database wipes on start), so the first step here is 3.
|
|
|
|
type migration struct {
|
|
id int
|
|
name string
|
|
run func(s *Store) error
|
|
}
|
|
|
|
var migrations = []migration{
|
|
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
|
|
{4, "Sitzungen", (*Store).migrateSessions},
|
|
{5, "Medien", (*Store).migrateMedia},
|
|
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
|
}
|
|
|
|
func (s *Store) migrate() error {
|
|
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL
|
|
)`); err != nil {
|
|
return err
|
|
}
|
|
applied := map[int]bool{}
|
|
rows, err := s.db.Query(`SELECT id FROM schema_migrations`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for rows.Next() {
|
|
var id int
|
|
if rows.Scan(&id) == nil {
|
|
applied[id] = true
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
last := 0
|
|
for _, m := range migrations {
|
|
last = m.id
|
|
if applied[m.id] {
|
|
continue
|
|
}
|
|
slog.Info("Datenbank-Migration", "id", m.id, "name", m.name)
|
|
if err := m.run(s); err != nil {
|
|
return fmt.Errorf("Migration %d (%s): %w", m.id, m.name, err)
|
|
}
|
|
if _, err := s.db.Exec(`INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)`,
|
|
m.id, m.name, domain.NowISO()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, last))
|
|
return err
|
|
}
|
|
|
|
var addColumnRe = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)\s+(.+?);?\s*$`)
|
|
|
|
// execSchemaStatement runs one schema statement; "ALTER TABLE … ADD COLUMN" only when the column is
|
|
// missing, so that a real error is never mistaken for "already there".
|
|
func (s *Store) execSchemaStatement(q string) error {
|
|
if m := addColumnRe.FindStringSubmatch(q); m != nil {
|
|
return s.addColumnIfMissing(m[1], m[2], m[3])
|
|
}
|
|
if _, err := s.db.Exec(q); err != nil {
|
|
return fmt.Errorf("%w (%s)", err, strings.TrimSpace(q))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) addColumnIfMissing(table, column, definition string) error {
|
|
exists, err := s.columnExists(table, column)
|
|
if err != nil || exists {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, definition)); err != nil {
|
|
return fmt.Errorf("Spalte %s.%s anlegen: %w", table, column, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) columnExists(table, column string) (bool, error) {
|
|
rows, err := s.db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var cid, notNull, pk int
|
|
var name, typ string
|
|
var dflt sql.NullString
|
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.EqualFold(name, column) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, rows.Err()
|
|
}
|