detached 8abf46b

fix(tls): restore certificate validation after gotk4 migration

Re-implements persistent TLS certificate error handling that was lost during
the gotk4 migration. Users can now handle certificate errors through an
interactive dialog with three options:

- Go Back: Cancel the navigation
- Proceed Once: Allow certificate temporarily (not stored)
- Always Accept This Site: Store acceptance for 30 days

Technical changes:
- Added TLS error signal handler using load-failed-with-tls-errors
- Configured NetworkSession TLS policy to emit signals instead of failing silently
- Database-backed certificate decision storage with SHA256 fingerprinting
- GTK4 AlertDialog implementation via CGO for modern async UI
- Automatic cleanup of expired certificate validations on startup
- Migration verification system to ensure all migrations are applied

Known issue: Accepted certificates are not pre-loaded into WebKit session
on startup. Stored decisions only apply when TLS error occurs again (runtime check).
commit
8abf46b36e5cf8eeb3cc74eff5f094653ccce87d
author
bamen · · 2025-10-15 16:16 UTC
parent
9660aaa
signature
unsigned
10 files · +906 -61
M CHANGELOG.md +6 -0
@@ -20,6 +20,12 @@ All notable changes to this project will be documented in this file.
2020 - **Workspace pane mode**: Moved ~450 lines of JavaScript state machine to Go backend for more reliable keyboard handling and eliminated race conditions between webviews
2121
2222 ### Fixed
23+- **TLS certificate validation restored**: Re-implemented persistent certificate error handling (broken during gotk4 migration)
24+ - Interactive three-option dialog: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
25+ - SQLite-based certificate decision storage with SHA256 certificate fingerprinting
26+ - Smart expiration: "Proceed Once" temporary (not stored), "Always Accept" persists for 30 days
27+ - Automatic cleanup of expired validations on application startup
28+ - **Known issue**: Accepted certificates not pre-loaded into WebKit session on startup - stored decisions only apply when TLS error occurs again
2329 - **Content blocking restored**: Re-implemented WebKit native ad/tracker blocking (broken during gotk4 migration) using UserContentFilterStore API
2430 - **Script injection spam**: Fixed 100+ duplicate "Sending color palettes" messages by injecting scripts only in top frame instead of all iframes
2531 - **Favicon race conditions**: Eliminated duplicate downloads and file handle errors via mutex protection and proper handler registration
@@ -20,6 +20,12 @@ All notable changes to this project will be documented in this file.
20 - **Workspace pane mode**: Moved ~450 lines of JavaScript state machine to Go backend for more reliable keyboard handling and eliminated race conditions between webviews20 - **Workspace pane mode**: Moved ~450 lines of JavaScript state machine to Go backend for more reliable keyboard handling and eliminated race conditions between webviews
21 21
22 ### Fixed22 ### Fixed
23+- **TLS certificate validation restored**: Re-implemented persistent certificate error handling (broken during gotk4 migration)
24+ - Interactive three-option dialog: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
25+ - SQLite-based certificate decision storage with SHA256 certificate fingerprinting
26+ - Smart expiration: "Proceed Once" temporary (not stored), "Always Accept" persists for 30 days
27+ - Automatic cleanup of expired validations on application startup
28+ - **Known issue**: Accepted certificates not pre-loaded into WebKit session on startup - stored decisions only apply when TLS error occurs again
23 - **Content blocking restored**: Re-implemented WebKit native ad/tracker blocking (broken during gotk4 migration) using UserContentFilterStore API29 - **Content blocking restored**: Re-implemented WebKit native ad/tracker blocking (broken during gotk4 migration) using UserContentFilterStore API
24 - **Script injection spam**: Fixed 100+ duplicate "Sending color palettes" messages by injecting scripts only in top frame instead of all iframes30 - **Script injection spam**: Fixed 100+ duplicate "Sending color palettes" messages by injecting scripts only in top frame instead of all iframes
25 - **Favicon race conditions**: Eliminated duplicate downloads and file handle errors via mutex protection and proper handler registration31 - **Favicon race conditions**: Eliminated duplicate downloads and file handle errors via mutex protection and proper handler registration
M internal/app/browser/browser.go +6 -9
@@ -3,7 +3,6 @@ package browser
33 import (
44 "database/sql"
55 "embed"
6- "fmt"
76 "log"
87 "os"
98 "os/signal"
@@ -18,7 +17,6 @@ import (
1817 "github.com/bnema/dumber/internal/db"
1918 "github.com/bnema/dumber/internal/filtering"
2019 "github.com/bnema/dumber/internal/logging"
21- "github.com/bnema/dumber/internal/migrations"
2220 "github.com/bnema/dumber/internal/services"
2321 "github.com/bnema/dumber/pkg/webkit"
2422 )
@@ -119,21 +117,20 @@ func (app *BrowserApp) Initialize() error {
119117 // Detect keyboard layout
120118 environment.DetectAndSetKeyboardLocale()
121119
122- // Initialize database
120+ // Initialize database (migrations run automatically in InitDB)
123121 database, err := db.InitDB(app.config.Database.Path)
124122 if err != nil {
125123 return err
126124 }
127125 app.database = database
128-
129- // Run database migrations
130- if err := migrations.RunEmbeddedMigrations(database); err != nil {
131- return fmt.Errorf("failed to run database migrations: %w", err)
132- }
133-
134126 app.queries = db.New(database)
135127 log.Printf("Database opened at %s", app.config.Database.Path)
136128
129+ // Cleanup expired certificate validations
130+ if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
131+ log.Printf("Warning: failed to cleanup expired certificate validations: %v", err)
132+ }
133+
137134 // Initialize services
138135 app.parserService = services.NewParserService(app.config, app.queries)
139136 app.browserService = services.NewBrowserService(app.config, app.queries)
@@ -3,7 +3,6 @@ package browser
3 import (3 import (
4 "database/sql"4 "database/sql"
5 "embed"5 "embed"
6- "fmt"
7 "log"6 "log"
8 "os"7 "os"
9 "os/signal"8 "os/signal"
@@ -18,7 +17,6 @@ import (
18 "github.com/bnema/dumber/internal/db"17 "github.com/bnema/dumber/internal/db"
19 "github.com/bnema/dumber/internal/filtering"18 "github.com/bnema/dumber/internal/filtering"
20 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
21- "github.com/bnema/dumber/internal/migrations"
22 "github.com/bnema/dumber/internal/services"20 "github.com/bnema/dumber/internal/services"
23 "github.com/bnema/dumber/pkg/webkit"21 "github.com/bnema/dumber/pkg/webkit"
24 )22 )
@@ -119,21 +117,20 @@ func (app *BrowserApp) Initialize() error {
119 // Detect keyboard layout117 // Detect keyboard layout
120 environment.DetectAndSetKeyboardLocale()118 environment.DetectAndSetKeyboardLocale()
121 119
122- // Initialize database120+ // Initialize database (migrations run automatically in InitDB)
123 database, err := db.InitDB(app.config.Database.Path)121 database, err := db.InitDB(app.config.Database.Path)
124 if err != nil {122 if err != nil {
125 return err123 return err
126 }124 }
127 app.database = database125 app.database = database
128-
129- // Run database migrations
130- if err := migrations.RunEmbeddedMigrations(database); err != nil {
131- return fmt.Errorf("failed to run database migrations: %w", err)
132- }
133-
134 app.queries = db.New(database)126 app.queries = db.New(database)
135 log.Printf("Database opened at %s", app.config.Database.Path)127 log.Printf("Database opened at %s", app.config.Database.Path)
136 128
129+ // Cleanup expired certificate validations
130+ if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
131+ log.Printf("Warning: failed to cleanup expired certificate validations: %v", err)
132+ }
133+
137 // Initialize services134 // Initialize services
138 app.parserService = services.NewParserService(app.config, app.queries)135 app.parserService = services.NewParserService(app.config, app.queries)
139 app.browserService = services.NewBrowserService(app.config, app.queries)136 app.browserService = services.NewBrowserService(app.config, app.queries)
M internal/cli/root.go +4 -9
@@ -11,7 +11,6 @@ import (
1111 "github.com/bnema/dumber/internal/cache"
1212 "github.com/bnema/dumber/internal/config"
1313 "github.com/bnema/dumber/internal/db"
14- "github.com/bnema/dumber/internal/migrations"
1514 "github.com/bnema/dumber/internal/services"
1615
1716 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
@@ -193,16 +192,12 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
193192 return rootCmd
194193 }
195194
196-// initializeDatabase runs embedded migrations and ensures database is up to date
197-func initializeDatabase(db *sql.DB, cfg *config.Config) error {
198- // Run embedded migrations - this will create all tables and apply any new migrations
199- if err := migrations.RunEmbeddedMigrations(db); err != nil {
200- return fmt.Errorf("failed to run database migrations: %w", err)
201- }
202-
195+// initializeDatabase ensures configured shortcuts are inserted
196+// Migrations are run automatically in db.InitDB()
197+func initializeDatabase(database *sql.DB, cfg *config.Config) error {
203198 // Insert configured shortcuts (these are additive, won't override existing)
204199 for shortcut, shortcutCfg := range cfg.SearchShortcuts {
205- _, err := db.Exec(
200+ _, err := database.Exec(
206201 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
207202 shortcut, shortcutCfg.URL, shortcutCfg.Description,
208203 )
@@ -11,7 +11,6 @@ import (
11 "github.com/bnema/dumber/internal/cache"11 "github.com/bnema/dumber/internal/cache"
12 "github.com/bnema/dumber/internal/config"12 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/db"13 "github.com/bnema/dumber/internal/db"
14- "github.com/bnema/dumber/internal/migrations"
15 "github.com/bnema/dumber/internal/services"14 "github.com/bnema/dumber/internal/services"
16 15
17 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql16 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
@@ -193,16 +192,12 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
193 return rootCmd192 return rootCmd
194 }193 }
195 194
196-// initializeDatabase runs embedded migrations and ensures database is up to date195+// initializeDatabase ensures configured shortcuts are inserted
197-func initializeDatabase(db *sql.DB, cfg *config.Config) error {196+// Migrations are run automatically in db.InitDB()
198- // Run embedded migrations - this will create all tables and apply any new migrations197+func initializeDatabase(database *sql.DB, cfg *config.Config) error {
199- if err := migrations.RunEmbeddedMigrations(db); err != nil {
200- return fmt.Errorf("failed to run database migrations: %w", err)
201- }
202-
203 // Insert configured shortcuts (these are additive, won't override existing)198 // Insert configured shortcuts (these are additive, won't override existing)
204 for shortcut, shortcutCfg := range cfg.SearchShortcuts {199 for shortcut, shortcutCfg := range cfg.SearchShortcuts {
205- _, err := db.Exec(200+ _, err := database.Exec(
206 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",201 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
207 shortcut, shortcutCfg.URL, shortcutCfg.Description,202 shortcut, shortcutCfg.URL, shortcutCfg.Description,
208 )203 )
M internal/db/init.go +12 -43
@@ -8,6 +8,7 @@ import (
88 "path/filepath"
99
1010 "github.com/bnema/dumber/internal/config"
11+ "github.com/bnema/dumber/internal/migrations"
1112 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
1213 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
1314 )
@@ -37,12 +38,20 @@ func InitDB(dbPath string) (*sql.DB, error) {
3738 return nil, fmt.Errorf("failed to connect to database: %w", err)
3839 }
3940
40- // Initialize database schema
41- if err := initializeSchema(database); err != nil {
41+ // Run embedded migrations - single source of truth for schema initialization
42+ if err := migrations.RunEmbeddedMigrations(database); err != nil {
4243 if err := database.Close(); err != nil {
4344 log.Printf("Warning: failed to close database: %v", err)
4445 }
45- return nil, fmt.Errorf("failed to initialize database schema: %w", err)
46+ return nil, fmt.Errorf("failed to run database migrations: %w", err)
47+ }
48+
49+ // Verify all migrations are applied
50+ if err := migrations.VerifyAllMigrationsApplied(database); err != nil {
51+ if err := database.Close(); err != nil {
52+ log.Printf("Warning: failed to close database: %v", err)
53+ }
54+ return nil, fmt.Errorf("migration verification failed: %w", err)
4655 }
4756
4857 return database, nil
@@ -66,46 +75,6 @@ func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
6675 return db, nil
6776 }
6877
69-// initializeSchema creates the database schema if it doesn't exist
70-func initializeSchema(db *sql.DB) error {
71- schema := `
72- -- History tracking for visited URLs
73- CREATE TABLE IF NOT EXISTS history (
74- id INTEGER PRIMARY KEY AUTOINCREMENT,
75- url TEXT NOT NULL UNIQUE,
76- title TEXT,
77- visit_count INTEGER DEFAULT 1,
78- last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
79- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
80- );
81-
82- CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
83- CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
84-
85- -- URL shortcuts configuration
86- CREATE TABLE IF NOT EXISTS shortcuts (
87- id INTEGER PRIMARY KEY AUTOINCREMENT,
88- shortcut TEXT NOT NULL UNIQUE,
89- url_template TEXT NOT NULL,
90- description TEXT,
91- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
92- );
93-
94- -- Zoom persistence per domain (not full URL)
95- CREATE TABLE IF NOT EXISTS zoom_levels (
96- domain TEXT PRIMARY KEY,
97- zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
98- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
99- );
100-
101- CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);
102- `
103-
104- // Execute schema creation
105- _, err := db.Exec(schema)
106- return err
107-}
108-
10978 // initializeShortcuts inserts configured shortcuts into the database
11079 func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
11180 if cfg == nil || cfg.SearchShortcuts == nil {
@@ -8,6 +8,7 @@ import (
8 "path/filepath"8 "path/filepath"
9 9
10 "github.com/bnema/dumber/internal/config"10 "github.com/bnema/dumber/internal/config"
11+ "github.com/bnema/dumber/internal/migrations"
11 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver12 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
12 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite13 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
13 )14 )
@@ -37,12 +38,20 @@ func InitDB(dbPath string) (*sql.DB, error) {
37 return nil, fmt.Errorf("failed to connect to database: %w", err)38 return nil, fmt.Errorf("failed to connect to database: %w", err)
38 }39 }
39 40
40- // Initialize database schema41+ // Run embedded migrations - single source of truth for schema initialization
41- if err := initializeSchema(database); err != nil {42+ if err := migrations.RunEmbeddedMigrations(database); err != nil {
42 if err := database.Close(); err != nil {43 if err := database.Close(); err != nil {
43 log.Printf("Warning: failed to close database: %v", err)44 log.Printf("Warning: failed to close database: %v", err)
44 }45 }
45- return nil, fmt.Errorf("failed to initialize database schema: %w", err)46+ return nil, fmt.Errorf("failed to run database migrations: %w", err)
47+ }
48+
49+ // Verify all migrations are applied
50+ if err := migrations.VerifyAllMigrationsApplied(database); err != nil {
51+ if err := database.Close(); err != nil {
52+ log.Printf("Warning: failed to close database: %v", err)
53+ }
54+ return nil, fmt.Errorf("migration verification failed: %w", err)
46 }55 }
47 56
48 return database, nil57 return database, nil
@@ -66,46 +75,6 @@ func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
66 return db, nil75 return db, nil
67 }76 }
68 77
69-// initializeSchema creates the database schema if it doesn't exist
70-func initializeSchema(db *sql.DB) error {
71- schema := `
72- -- History tracking for visited URLs
73- CREATE TABLE IF NOT EXISTS history (
74- id INTEGER PRIMARY KEY AUTOINCREMENT,
75- url TEXT NOT NULL UNIQUE,
76- title TEXT,
77- visit_count INTEGER DEFAULT 1,
78- last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
79- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
80- );
81-
82- CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
83- CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
84-
85- -- URL shortcuts configuration
86- CREATE TABLE IF NOT EXISTS shortcuts (
87- id INTEGER PRIMARY KEY AUTOINCREMENT,
88- shortcut TEXT NOT NULL UNIQUE,
89- url_template TEXT NOT NULL,
90- description TEXT,
91- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
92- );
93-
94- -- Zoom persistence per domain (not full URL)
95- CREATE TABLE IF NOT EXISTS zoom_levels (
96- domain TEXT PRIMARY KEY,
97- zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
98- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
99- );
100-
101- CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);
102- `
103-
104- // Execute schema creation
105- _, err := db.Exec(schema)
106- return err
107-}
108-
109 // initializeShortcuts inserts configured shortcuts into the database78 // initializeShortcuts inserts configured shortcuts into the database
110 func initializeShortcuts(db *sql.DB, cfg *config.Config) error {79 func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
111 if cfg == nil || cfg.SearchShortcuts == nil {80 if cfg == nil || cfg.SearchShortcuts == nil {
M internal/migrations/migrations.go +46 -0
@@ -78,6 +78,10 @@ func RunEmbeddedMigrations(db *sql.DB) error {
7878 return fmt.Errorf("failed to create migrations table: %w", err)
7979 }
8080
81+ // NOTE: Legacy databases without migration tracking should be deleted and recreated.
82+ // All new databases will have migration tracking from the start.
83+ // Backfill logic was removed to maintain idempotency - migrations are the single source of truth.
84+
8185 migrations, err := GetMigrations()
8286 if err != nil {
8387 return fmt.Errorf("failed to load migrations: %w", err)
@@ -188,3 +192,45 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
188192
189193 return versions, nil
190194 }
195+
196+// VerifyAllMigrationsApplied scans all embedded migrations and verifies they have been applied
197+// Returns an error if any migrations are missing or if verification fails
198+func VerifyAllMigrationsApplied(db *sql.DB) error {
199+ // Get all embedded migrations
200+ allMigrations, err := GetMigrations()
201+ if err != nil {
202+ return fmt.Errorf("failed to get embedded migrations: %w", err)
203+ }
204+
205+ // Get applied migrations from database
206+ appliedVersions, err := GetAppliedMigrations(db)
207+ if err != nil {
208+ return fmt.Errorf("failed to get applied migrations: %w", err)
209+ }
210+
211+ // Create map for quick lookup
212+ appliedMap := make(map[int]bool)
213+ for _, version := range appliedVersions {
214+ appliedMap[version] = true
215+ }
216+
217+ // Check each migration is applied
218+ var missingMigrations []Migration
219+ for _, migration := range allMigrations {
220+ if !appliedMap[migration.Version] {
221+ missingMigrations = append(missingMigrations, migration)
222+ }
223+ }
224+
225+ // Report results
226+ if len(missingMigrations) > 0 {
227+ log.Printf("WARNING: %d migrations are not applied:", len(missingMigrations))
228+ for _, migration := range missingMigrations {
229+ log.Printf(" - Migration %03d: %s", migration.Version, migration.Name)
230+ }
231+ return fmt.Errorf("%d migrations are not applied", len(missingMigrations))
232+ }
233+
234+ log.Printf("Migration verification: All %d migrations are applied ✓", len(allMigrations))
235+ return nil
236+}
@@ -78,6 +78,10 @@ func RunEmbeddedMigrations(db *sql.DB) error {
78 return fmt.Errorf("failed to create migrations table: %w", err)78 return fmt.Errorf("failed to create migrations table: %w", err)
79 }79 }
80 80
81+ // NOTE: Legacy databases without migration tracking should be deleted and recreated.
82+ // All new databases will have migration tracking from the start.
83+ // Backfill logic was removed to maintain idempotency - migrations are the single source of truth.
84+
81 migrations, err := GetMigrations()85 migrations, err := GetMigrations()
82 if err != nil {86 if err != nil {
83 return fmt.Errorf("failed to load migrations: %w", err)87 return fmt.Errorf("failed to load migrations: %w", err)
@@ -188,3 +192,45 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
188 192
189 return versions, nil193 return versions, nil
190 }194 }
195+
196+// VerifyAllMigrationsApplied scans all embedded migrations and verifies they have been applied
197+// Returns an error if any migrations are missing or if verification fails
198+func VerifyAllMigrationsApplied(db *sql.DB) error {
199+ // Get all embedded migrations
200+ allMigrations, err := GetMigrations()
201+ if err != nil {
202+ return fmt.Errorf("failed to get embedded migrations: %w", err)
203+ }
204+
205+ // Get applied migrations from database
206+ appliedVersions, err := GetAppliedMigrations(db)
207+ if err != nil {
208+ return fmt.Errorf("failed to get applied migrations: %w", err)
209+ }
210+
211+ // Create map for quick lookup
212+ appliedMap := make(map[int]bool)
213+ for _, version := range appliedVersions {
214+ appliedMap[version] = true
215+ }
216+
217+ // Check each migration is applied
218+ var missingMigrations []Migration
219+ for _, migration := range allMigrations {
220+ if !appliedMap[migration.Version] {
221+ missingMigrations = append(missingMigrations, migration)
222+ }
223+ }
224+
225+ // Report results
226+ if len(missingMigrations) > 0 {
227+ log.Printf("WARNING: %d migrations are not applied:", len(missingMigrations))
228+ for _, migration := range missingMigrations {
229+ log.Printf(" - Migration %03d: %s", migration.Version, migration.Name)
230+ }
231+ return fmt.Errorf("%d migrations are not applied", len(missingMigrations))
232+ }
233+
234+ log.Printf("Migration verification: All %d migrations are applied ✓", len(allMigrations))
235+ return nil
236+}
A internal/migrations/migrations_test.go +438 -0
@@ -0,0 +1,438 @@
1+package migrations
2+
3+import (
4+ "database/sql"
5+ "path/filepath"
6+ "testing"
7+
8+ _ "github.com/ncruces/go-sqlite3/driver"
9+ _ "github.com/ncruces/go-sqlite3/embed"
10+)
11+
12+// TestMigrationsAreIdempotent verifies that running migrations multiple times doesn't cause errors
13+func TestMigrationsAreIdempotent(t *testing.T) {
14+ // Create temporary database
15+ tmpDir := t.TempDir()
16+ dbPath := filepath.Join(tmpDir, "test.db")
17+
18+ // Open database
19+ db, err := sql.Open("sqlite3", dbPath)
20+ if err != nil {
21+ t.Fatalf("Failed to open test database: %v", err)
22+ }
23+ defer db.Close()
24+
25+ // Run migrations first time
26+ if err := RunEmbeddedMigrations(db); err != nil {
27+ t.Fatalf("First migration run failed: %v", err)
28+ }
29+
30+ // Get applied migrations after first run
31+ appliedFirst, err := GetAppliedMigrations(db)
32+ if err != nil {
33+ t.Fatalf("Failed to get applied migrations after first run: %v", err)
34+ }
35+
36+ if len(appliedFirst) == 0 {
37+ t.Fatal("No migrations were applied on first run")
38+ }
39+
40+ // Run migrations second time - should be idempotent
41+ if err := RunEmbeddedMigrations(db); err != nil {
42+ t.Fatalf("Second migration run failed (not idempotent): %v", err)
43+ }
44+
45+ // Get applied migrations after second run
46+ appliedSecond, err := GetAppliedMigrations(db)
47+ if err != nil {
48+ t.Fatalf("Failed to get applied migrations after second run: %v", err)
49+ }
50+
51+ // Verify same migrations are applied
52+ if len(appliedFirst) != len(appliedSecond) {
53+ t.Errorf("Migration count changed: first=%d, second=%d", len(appliedFirst), len(appliedSecond))
54+ }
55+
56+ for i, v := range appliedFirst {
57+ if i >= len(appliedSecond) || v != appliedSecond[i] {
58+ t.Errorf("Applied migrations differ at index %d: first=%d, second=%d", i, v, appliedSecond[i])
59+ }
60+ }
61+
62+ // Run migrations third time to be extra sure
63+ if err := RunEmbeddedMigrations(db); err != nil {
64+ t.Fatalf("Third migration run failed (not idempotent): %v", err)
65+ }
66+
67+ appliedThird, err := GetAppliedMigrations(db)
68+ if err != nil {
69+ t.Fatalf("Failed to get applied migrations after third run: %v", err)
70+ }
71+
72+ if len(appliedThird) != len(appliedFirst) {
73+ t.Errorf("Migration count changed on third run: expected=%d, got=%d", len(appliedFirst), len(appliedThird))
74+ }
75+}
76+
77+// TestMigrationTrackingPreventsReapplication verifies that applied migrations are tracked and not re-run
78+func TestMigrationTrackingPreventsReapplication(t *testing.T) {
79+ tmpDir := t.TempDir()
80+ dbPath := filepath.Join(tmpDir, "test.db")
81+
82+ db, err := sql.Open("sqlite3", dbPath)
83+ if err != nil {
84+ t.Fatalf("Failed to open test database: %v", err)
85+ }
86+ defer db.Close()
87+
88+ // Run migrations
89+ if err := RunEmbeddedMigrations(db); err != nil {
90+ t.Fatalf("Migration run failed: %v", err)
91+ }
92+
93+ // Check that schema_migrations table exists and has entries
94+ var count int
95+ err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
96+ if err != nil {
97+ t.Fatalf("Failed to query schema_migrations table: %v", err)
98+ }
99+
100+ if count == 0 {
101+ t.Fatal("No migration tracking entries found")
102+ }
103+
104+ // Verify each migration has exactly one entry
105+ rows, err := db.Query("SELECT version, COUNT(*) FROM schema_migrations GROUP BY version HAVING COUNT(*) > 1")
106+ if err != nil {
107+ t.Fatalf("Failed to check for duplicate migration entries: %v", err)
108+ }
109+ defer rows.Close()
110+
111+ duplicates := []int{}
112+ for rows.Next() {
113+ var version, dupeCount int
114+ if err := rows.Scan(&version, &dupeCount); err != nil {
115+ t.Fatalf("Failed to scan duplicate check: %v", err)
116+ }
117+ duplicates = append(duplicates, version)
118+ }
119+
120+ if len(duplicates) > 0 {
121+ t.Errorf("Found duplicate migration entries for versions: %v", duplicates)
122+ }
123+}
124+
125+// TestAllMigrationsApplyOnFreshDatabase verifies all migrations work on a new database
126+func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) {
127+ tmpDir := t.TempDir()
128+ dbPath := filepath.Join(tmpDir, "test.db")
129+
130+ db, err := sql.Open("sqlite3", dbPath)
131+ if err != nil {
132+ t.Fatalf("Failed to open test database: %v", err)
133+ }
134+ defer db.Close()
135+
136+ // Get expected migrations
137+ expectedMigrations, err := GetMigrations()
138+ if err != nil {
139+ t.Fatalf("Failed to get migrations: %v", err)
140+ }
141+
142+ if len(expectedMigrations) == 0 {
143+ t.Fatal("No migration files found")
144+ }
145+
146+ // Run migrations
147+ if err := RunEmbeddedMigrations(db); err != nil {
148+ t.Fatalf("Failed to run migrations: %v", err)
149+ }
150+
151+ // Verify all migrations were applied
152+ appliedVersions, err := GetAppliedMigrations(db)
153+ if err != nil {
154+ t.Fatalf("Failed to get applied migrations: %v", err)
155+ }
156+
157+ if len(appliedVersions) != len(expectedMigrations) {
158+ t.Errorf("Expected %d migrations to be applied, got %d", len(expectedMigrations), len(appliedVersions))
159+ }
160+
161+ // Verify each expected migration was applied
162+ for i, migration := range expectedMigrations {
163+ if i >= len(appliedVersions) {
164+ t.Errorf("Migration %d (%s) was not applied", migration.Version, migration.Name)
165+ continue
166+ }
167+ if appliedVersions[i] != migration.Version {
168+ t.Errorf("Migration version mismatch at index %d: expected %d, got %d", i, migration.Version, appliedVersions[i])
169+ }
170+ }
171+}
172+
173+// TestMigrationOrdering verifies migrations are applied in correct order
174+func TestMigrationOrdering(t *testing.T) {
175+ migrations, err := GetMigrations()
176+ if err != nil {
177+ t.Fatalf("Failed to get migrations: %v", err)
178+ }
179+
180+ if len(migrations) == 0 {
181+ t.Fatal("No migrations found")
182+ }
183+
184+ // Verify migrations are in ascending order
185+ for i := 1; i < len(migrations); i++ {
186+ if migrations[i].Version <= migrations[i-1].Version {
187+ t.Errorf("Migrations not in ascending order: migration[%d].Version=%d, migration[%d].Version=%d",
188+ i-1, migrations[i-1].Version, i, migrations[i].Version)
189+ }
190+ }
191+
192+ // Verify no gaps in version numbers (should be 1, 2, 3, 4, 5...)
193+ for i, migration := range migrations {
194+ expectedVersion := i + 1
195+ if migration.Version != expectedVersion {
196+ t.Errorf("Migration version gap detected: expected version %d, got %d", expectedVersion, migration.Version)
197+ }
198+ }
199+}
200+
201+// TestMigrationTableCreationIsIdempotent verifies the tracking table can be created multiple times
202+func TestMigrationTableCreationIsIdempotent(t *testing.T) {
203+ tmpDir := t.TempDir()
204+ dbPath := filepath.Join(tmpDir, "test.db")
205+
206+ db, err := sql.Open("sqlite3", dbPath)
207+ if err != nil {
208+ t.Fatalf("Failed to open test database: %v", err)
209+ }
210+ defer db.Close()
211+
212+ // Create tracking table multiple times
213+ for i := 0; i < 3; i++ {
214+ if err := createMigrationsTable(db); err != nil {
215+ t.Fatalf("Failed to create migrations table on attempt %d: %v", i+1, err)
216+ }
217+ }
218+
219+ // Verify table exists and has correct schema
220+ var tableName string
221+ err = db.QueryRow(`
222+ SELECT name FROM sqlite_master
223+ WHERE type='table' AND name='schema_migrations'
224+ `).Scan(&tableName)
225+
226+ if err != nil {
227+ t.Fatalf("Migrations table not found: %v", err)
228+ }
229+
230+ if tableName != "schema_migrations" {
231+ t.Errorf("Expected table name 'schema_migrations', got '%s'", tableName)
232+ }
233+
234+ // Verify table has expected columns
235+ rows, err := db.Query("PRAGMA table_info(schema_migrations)")
236+ if err != nil {
237+ t.Fatalf("Failed to get table info: %v", err)
238+ }
239+ defer rows.Close()
240+
241+ expectedColumns := map[string]bool{
242+ "version": false,
243+ "name": false,
244+ "applied_at": false,
245+ }
246+
247+ for rows.Next() {
248+ var cid int
249+ var name, ctype string
250+ var notnull, pk int
251+ var dfltValue sql.NullString
252+
253+ if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err != nil {
254+ t.Fatalf("Failed to scan table info: %v", err)
255+ }
256+
257+ if _, expected := expectedColumns[name]; expected {
258+ expectedColumns[name] = true
259+ }
260+ }
261+
262+ for col, found := range expectedColumns {
263+ if !found {
264+ t.Errorf("Expected column '%s' not found in schema_migrations table", col)
265+ }
266+ }
267+}
268+
269+// TestPartialMigrationState verifies that a database with some migrations can have new ones applied
270+func TestPartialMigrationState(t *testing.T) {
271+ tmpDir := t.TempDir()
272+ dbPath := filepath.Join(tmpDir, "test.db")
273+
274+ db, err := sql.Open("sqlite3", dbPath)
275+ if err != nil {
276+ t.Fatalf("Failed to open test database: %v", err)
277+ }
278+ defer db.Close()
279+
280+ // Create tracking table
281+ if err := createMigrationsTable(db); err != nil {
282+ t.Fatalf("Failed to create migrations table: %v", err)
283+ }
284+
285+ // Get all migrations
286+ allMigrations, err := GetMigrations()
287+ if err != nil {
288+ t.Fatalf("Failed to get migrations: %v", err)
289+ }
290+
291+ if len(allMigrations) < 2 {
292+ t.Skip("Need at least 2 migrations for this test")
293+ }
294+
295+ // Manually apply only the first migration
296+ firstMigration := allMigrations[0]
297+ tx, err := db.Begin()
298+ if err != nil {
299+ t.Fatalf("Failed to begin transaction: %v", err)
300+ }
301+
302+ if _, err := tx.Exec(firstMigration.SQL); err != nil {
303+ tx.Rollback()
304+ t.Fatalf("Failed to execute first migration: %v", err)
305+ }
306+
307+ if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {
308+ tx.Rollback()
309+ t.Fatalf("Failed to record first migration: %v", err)
310+ }
311+
312+ if err := tx.Commit(); err != nil {
313+ t.Fatalf("Failed to commit first migration: %v", err)
314+ }
315+
316+ // Verify only one migration is applied
317+ applied, err := GetAppliedMigrations(db)
318+ if err != nil {
319+ t.Fatalf("Failed to get applied migrations: %v", err)
320+ }
321+
322+ if len(applied) != 1 {
323+ t.Fatalf("Expected 1 applied migration, got %d", len(applied))
324+ }
325+
326+ // Now run all migrations - should apply the remaining ones
327+ if err := RunEmbeddedMigrations(db); err != nil {
328+ t.Fatalf("Failed to run remaining migrations: %v", err)
329+ }
330+
331+ // Verify all migrations are now applied
332+ appliedAfter, err := GetAppliedMigrations(db)
333+ if err != nil {
334+ t.Fatalf("Failed to get applied migrations after full run: %v", err)
335+ }
336+
337+ if len(appliedAfter) != len(allMigrations) {
338+ t.Errorf("Expected %d migrations applied, got %d", len(allMigrations), len(appliedAfter))
339+ }
340+
341+ // Verify the first migration wasn't re-applied
342+ if appliedAfter[0] != firstMigration.Version {
343+ t.Errorf("First migration version changed: expected %d, got %d", firstMigration.Version, appliedAfter[0])
344+ }
345+}
346+
347+// TestGetAppliedMigrationsEmptyDatabase verifies behavior on a database with no migrations
348+func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) {
349+ tmpDir := t.TempDir()
350+ dbPath := filepath.Join(tmpDir, "test.db")
351+
352+ db, err := sql.Open("sqlite3", dbPath)
353+ if err != nil {
354+ t.Fatalf("Failed to open test database: %v", err)
355+ }
356+ defer db.Close()
357+
358+ // Query applied migrations on empty database
359+ applied, err := GetAppliedMigrations(db)
360+ if err != nil {
361+ t.Fatalf("GetAppliedMigrations failed on empty database: %v", err)
362+ }
363+
364+ if len(applied) != 0 {
365+ t.Errorf("Expected 0 applied migrations on empty database, got %d", len(applied))
366+ }
367+}
368+
369+// TestVerifyAllMigrationsApplied verifies the verification function works correctly
370+func TestVerifyAllMigrationsApplied(t *testing.T) {
371+ tmpDir := t.TempDir()
372+ dbPath := filepath.Join(tmpDir, "test.db")
373+
374+ db, err := sql.Open("sqlite3", dbPath)
375+ if err != nil {
376+ t.Fatalf("Failed to open test database: %v", err)
377+ }
378+ defer db.Close()
379+
380+ // Run migrations
381+ if err := RunEmbeddedMigrations(db); err != nil {
382+ t.Fatalf("Failed to run migrations: %v", err)
383+ }
384+
385+ // Verify should pass
386+ if err := VerifyAllMigrationsApplied(db); err != nil {
387+ t.Errorf("VerifyAllMigrationsApplied() failed after running all migrations: %v", err)
388+ }
389+
390+ // Test with missing migration - manually delete one from tracking
391+ _, err = db.Exec("DELETE FROM schema_migrations WHERE version = 3")
392+ if err != nil {
393+ t.Fatalf("Failed to delete migration record: %v", err)
394+ }
395+
396+ // Verify should now fail
397+ err = VerifyAllMigrationsApplied(db)
398+ if err == nil {
399+ t.Error("VerifyAllMigrationsApplied() should have failed with missing migration, but it passed")
400+ }
401+}
402+
403+// TestExpectedTablesExistAfterMigrations verifies that all expected tables are created
404+func TestExpectedTablesExistAfterMigrations(t *testing.T) {
405+ tmpDir := t.TempDir()
406+ dbPath := filepath.Join(tmpDir, "test.db")
407+
408+ db, err := sql.Open("sqlite3", dbPath)
409+ if err != nil {
410+ t.Fatalf("Failed to open test database: %v", err)
411+ }
412+ defer db.Close()
413+
414+ // Run migrations
415+ if err := RunEmbeddedMigrations(db); err != nil {
416+ t.Fatalf("Failed to run migrations: %v", err)
417+ }
418+
419+ // Expected tables based on current migrations
420+ expectedTables := []string{
421+ "schema_migrations", // Migration tracking
422+ "history", // 001_initial.sql
423+ "shortcuts", // 001_initial.sql
424+ "zoom_levels", // 003_add_zoom_levels.sql
425+ "certificate_validations", // 005_add_certificate_validations.sql
426+ }
427+
428+ // Check each expected table exists
429+ for _, tableName := range expectedTables {
430+ var name string
431+ err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&name)
432+ if err == sql.ErrNoRows {
433+ t.Errorf("Expected table '%s' does not exist", tableName)
434+ } else if err != nil {
435+ t.Fatalf("Failed to check for table '%s': %v", tableName, err)
436+ }
437+ }
438+}
@@ -0,0 +1,438 @@
1+package migrations
2+
3+import (
4+ "database/sql"
5+ "path/filepath"
6+ "testing"
7+
8+ _ "github.com/ncruces/go-sqlite3/driver"
9+ _ "github.com/ncruces/go-sqlite3/embed"
10+)
11+
12+// TestMigrationsAreIdempotent verifies that running migrations multiple times doesn't cause errors
13+func TestMigrationsAreIdempotent(t *testing.T) {
14+ // Create temporary database
15+ tmpDir := t.TempDir()
16+ dbPath := filepath.Join(tmpDir, "test.db")
17+
18+ // Open database
19+ db, err := sql.Open("sqlite3", dbPath)
20+ if err != nil {
21+ t.Fatalf("Failed to open test database: %v", err)
22+ }
23+ defer db.Close()
24+
25+ // Run migrations first time
26+ if err := RunEmbeddedMigrations(db); err != nil {
27+ t.Fatalf("First migration run failed: %v", err)
28+ }
29+
30+ // Get applied migrations after first run
31+ appliedFirst, err := GetAppliedMigrations(db)
32+ if err != nil {
33+ t.Fatalf("Failed to get applied migrations after first run: %v", err)
34+ }
35+
36+ if len(appliedFirst) == 0 {
37+ t.Fatal("No migrations were applied on first run")
38+ }
39+
40+ // Run migrations second time - should be idempotent
41+ if err := RunEmbeddedMigrations(db); err != nil {
42+ t.Fatalf("Second migration run failed (not idempotent): %v", err)
43+ }
44+
45+ // Get applied migrations after second run
46+ appliedSecond, err := GetAppliedMigrations(db)
47+ if err != nil {
48+ t.Fatalf("Failed to get applied migrations after second run: %v", err)
49+ }
50+
51+ // Verify same migrations are applied
52+ if len(appliedFirst) != len(appliedSecond) {
53+ t.Errorf("Migration count changed: first=%d, second=%d", len(appliedFirst), len(appliedSecond))
54+ }
55+
56+ for i, v := range appliedFirst {
57+ if i >= len(appliedSecond) || v != appliedSecond[i] {
58+ t.Errorf("Applied migrations differ at index %d: first=%d, second=%d", i, v, appliedSecond[i])
59+ }
60+ }
61+
62+ // Run migrations third time to be extra sure
63+ if err := RunEmbeddedMigrations(db); err != nil {
64+ t.Fatalf("Third migration run failed (not idempotent): %v", err)
65+ }
66+
67+ appliedThird, err := GetAppliedMigrations(db)
68+ if err != nil {
69+ t.Fatalf("Failed to get applied migrations after third run: %v", err)
70+ }
71+
72+ if len(appliedThird) != len(appliedFirst) {
73+ t.Errorf("Migration count changed on third run: expected=%d, got=%d", len(appliedFirst), len(appliedThird))
74+ }
75+}
76+
77+// TestMigrationTrackingPreventsReapplication verifies that applied migrations are tracked and not re-run
78+func TestMigrationTrackingPreventsReapplication(t *testing.T) {
79+ tmpDir := t.TempDir()
80+ dbPath := filepath.Join(tmpDir, "test.db")
81+
82+ db, err := sql.Open("sqlite3", dbPath)
83+ if err != nil {
84+ t.Fatalf("Failed to open test database: %v", err)
85+ }
86+ defer db.Close()
87+
88+ // Run migrations
89+ if err := RunEmbeddedMigrations(db); err != nil {
90+ t.Fatalf("Migration run failed: %v", err)
91+ }
92+
93+ // Check that schema_migrations table exists and has entries
94+ var count int
95+ err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
96+ if err != nil {
97+ t.Fatalf("Failed to query schema_migrations table: %v", err)
98+ }
99+
100+ if count == 0 {
101+ t.Fatal("No migration tracking entries found")
102+ }
103+
104+ // Verify each migration has exactly one entry
105+ rows, err := db.Query("SELECT version, COUNT(*) FROM schema_migrations GROUP BY version HAVING COUNT(*) > 1")
106+ if err != nil {
107+ t.Fatalf("Failed to check for duplicate migration entries: %v", err)
108+ }
109+ defer rows.Close()
110+
111+ duplicates := []int{}
112+ for rows.Next() {
113+ var version, dupeCount int
114+ if err := rows.Scan(&version, &dupeCount); err != nil {
115+ t.Fatalf("Failed to scan duplicate check: %v", err)
116+ }
117+ duplicates = append(duplicates, version)
118+ }
119+
120+ if len(duplicates) > 0 {
121+ t.Errorf("Found duplicate migration entries for versions: %v", duplicates)
122+ }
123+}
124+
125+// TestAllMigrationsApplyOnFreshDatabase verifies all migrations work on a new database
126+func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) {
127+ tmpDir := t.TempDir()
128+ dbPath := filepath.Join(tmpDir, "test.db")
129+
130+ db, err := sql.Open("sqlite3", dbPath)
131+ if err != nil {
132+ t.Fatalf("Failed to open test database: %v", err)
133+ }
134+ defer db.Close()
135+
136+ // Get expected migrations
137+ expectedMigrations, err := GetMigrations()
138+ if err != nil {
139+ t.Fatalf("Failed to get migrations: %v", err)
140+ }
141+
142+ if len(expectedMigrations) == 0 {
143+ t.Fatal("No migration files found")
144+ }
145+
146+ // Run migrations
147+ if err := RunEmbeddedMigrations(db); err != nil {
148+ t.Fatalf("Failed to run migrations: %v", err)
149+ }
150+
151+ // Verify all migrations were applied
152+ appliedVersions, err := GetAppliedMigrations(db)
153+ if err != nil {
154+ t.Fatalf("Failed to get applied migrations: %v", err)
155+ }
156+
157+ if len(appliedVersions) != len(expectedMigrations) {
158+ t.Errorf("Expected %d migrations to be applied, got %d", len(expectedMigrations), len(appliedVersions))
159+ }
160+
161+ // Verify each expected migration was applied
162+ for i, migration := range expectedMigrations {
163+ if i >= len(appliedVersions) {
164+ t.Errorf("Migration %d (%s) was not applied", migration.Version, migration.Name)
165+ continue
166+ }
167+ if appliedVersions[i] != migration.Version {
168+ t.Errorf("Migration version mismatch at index %d: expected %d, got %d", i, migration.Version, appliedVersions[i])
169+ }
170+ }
171+}
172+
173+// TestMigrationOrdering verifies migrations are applied in correct order
174+func TestMigrationOrdering(t *testing.T) {
175+ migrations, err := GetMigrations()
176+ if err != nil {
177+ t.Fatalf("Failed to get migrations: %v", err)
178+ }
179+
180+ if len(migrations) == 0 {
181+ t.Fatal("No migrations found")
182+ }
183+
184+ // Verify migrations are in ascending order
185+ for i := 1; i < len(migrations); i++ {
186+ if migrations[i].Version <= migrations[i-1].Version {
187+ t.Errorf("Migrations not in ascending order: migration[%d].Version=%d, migration[%d].Version=%d",
188+ i-1, migrations[i-1].Version, i, migrations[i].Version)
189+ }
190+ }
191+
192+ // Verify no gaps in version numbers (should be 1, 2, 3, 4, 5...)
193+ for i, migration := range migrations {
194+ expectedVersion := i + 1
195+ if migration.Version != expectedVersion {
196+ t.Errorf("Migration version gap detected: expected version %d, got %d", expectedVersion, migration.Version)
197+ }
198+ }
199+}
200+
201+// TestMigrationTableCreationIsIdempotent verifies the tracking table can be created multiple times
202+func TestMigrationTableCreationIsIdempotent(t *testing.T) {
203+ tmpDir := t.TempDir()
204+ dbPath := filepath.Join(tmpDir, "test.db")
205+
206+ db, err := sql.Open("sqlite3", dbPath)
207+ if err != nil {
208+ t.Fatalf("Failed to open test database: %v", err)
209+ }
210+ defer db.Close()
211+
212+ // Create tracking table multiple times
213+ for i := 0; i < 3; i++ {
214+ if err := createMigrationsTable(db); err != nil {
215+ t.Fatalf("Failed to create migrations table on attempt %d: %v", i+1, err)
216+ }
217+ }
218+
219+ // Verify table exists and has correct schema
220+ var tableName string
221+ err = db.QueryRow(`
222+ SELECT name FROM sqlite_master
223+ WHERE type='table' AND name='schema_migrations'
224+ `).Scan(&tableName)
225+
226+ if err != nil {
227+ t.Fatalf("Migrations table not found: %v", err)
228+ }
229+
230+ if tableName != "schema_migrations" {
231+ t.Errorf("Expected table name 'schema_migrations', got '%s'", tableName)
232+ }
233+
234+ // Verify table has expected columns
235+ rows, err := db.Query("PRAGMA table_info(schema_migrations)")
236+ if err != nil {
237+ t.Fatalf("Failed to get table info: %v", err)
238+ }
239+ defer rows.Close()
240+
241+ expectedColumns := map[string]bool{
242+ "version": false,
243+ "name": false,
244+ "applied_at": false,
245+ }
246+
247+ for rows.Next() {
248+ var cid int
249+ var name, ctype string
250+ var notnull, pk int
251+ var dfltValue sql.NullString
252+
253+ if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err != nil {
254+ t.Fatalf("Failed to scan table info: %v", err)
255+ }
256+
257+ if _, expected := expectedColumns[name]; expected {
258+ expectedColumns[name] = true
259+ }
260+ }
261+
262+ for col, found := range expectedColumns {
263+ if !found {
264+ t.Errorf("Expected column '%s' not found in schema_migrations table", col)
265+ }
266+ }
267+}
268+
269+// TestPartialMigrationState verifies that a database with some migrations can have new ones applied
270+func TestPartialMigrationState(t *testing.T) {
271+ tmpDir := t.TempDir()
272+ dbPath := filepath.Join(tmpDir, "test.db")
273+
274+ db, err := sql.Open("sqlite3", dbPath)
275+ if err != nil {
276+ t.Fatalf("Failed to open test database: %v", err)
277+ }
278+ defer db.Close()
279+
280+ // Create tracking table
281+ if err := createMigrationsTable(db); err != nil {
282+ t.Fatalf("Failed to create migrations table: %v", err)
283+ }
284+
285+ // Get all migrations
286+ allMigrations, err := GetMigrations()
287+ if err != nil {
288+ t.Fatalf("Failed to get migrations: %v", err)
289+ }
290+
291+ if len(allMigrations) < 2 {
292+ t.Skip("Need at least 2 migrations for this test")
293+ }
294+
295+ // Manually apply only the first migration
296+ firstMigration := allMigrations[0]
297+ tx, err := db.Begin()
298+ if err != nil {
299+ t.Fatalf("Failed to begin transaction: %v", err)
300+ }
301+
302+ if _, err := tx.Exec(firstMigration.SQL); err != nil {
303+ tx.Rollback()
304+ t.Fatalf("Failed to execute first migration: %v", err)
305+ }
306+
307+ if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {
308+ tx.Rollback()
309+ t.Fatalf("Failed to record first migration: %v", err)
310+ }
311+
312+ if err := tx.Commit(); err != nil {
313+ t.Fatalf("Failed to commit first migration: %v", err)
314+ }
315+
316+ // Verify only one migration is applied
317+ applied, err := GetAppliedMigrations(db)
318+ if err != nil {
319+ t.Fatalf("Failed to get applied migrations: %v", err)
320+ }
321+
322+ if len(applied) != 1 {
323+ t.Fatalf("Expected 1 applied migration, got %d", len(applied))
324+ }
325+
326+ // Now run all migrations - should apply the remaining ones
327+ if err := RunEmbeddedMigrations(db); err != nil {
328+ t.Fatalf("Failed to run remaining migrations: %v", err)
329+ }
330+
331+ // Verify all migrations are now applied
332+ appliedAfter, err := GetAppliedMigrations(db)
333+ if err != nil {
334+ t.Fatalf("Failed to get applied migrations after full run: %v", err)
335+ }
336+
337+ if len(appliedAfter) != len(allMigrations) {
338+ t.Errorf("Expected %d migrations applied, got %d", len(allMigrations), len(appliedAfter))
339+ }
340+
341+ // Verify the first migration wasn't re-applied
342+ if appliedAfter[0] != firstMigration.Version {
343+ t.Errorf("First migration version changed: expected %d, got %d", firstMigration.Version, appliedAfter[0])
344+ }
345+}
346+
347+// TestGetAppliedMigrationsEmptyDatabase verifies behavior on a database with no migrations
348+func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) {
349+ tmpDir := t.TempDir()
350+ dbPath := filepath.Join(tmpDir, "test.db")
351+
352+ db, err := sql.Open("sqlite3", dbPath)
353+ if err != nil {
354+ t.Fatalf("Failed to open test database: %v", err)
355+ }
356+ defer db.Close()
357+
358+ // Query applied migrations on empty database
359+ applied, err := GetAppliedMigrations(db)
360+ if err != nil {
361+ t.Fatalf("GetAppliedMigrations failed on empty database: %v", err)
362+ }
363+
364+ if len(applied) != 0 {
365+ t.Errorf("Expected 0 applied migrations on empty database, got %d", len(applied))
366+ }
367+}
368+
369+// TestVerifyAllMigrationsApplied verifies the verification function works correctly
370+func TestVerifyAllMigrationsApplied(t *testing.T) {
371+ tmpDir := t.TempDir()
372+ dbPath := filepath.Join(tmpDir, "test.db")
373+
374+ db, err := sql.Open("sqlite3", dbPath)
375+ if err != nil {
376+ t.Fatalf("Failed to open test database: %v", err)
377+ }
378+ defer db.Close()
379+
380+ // Run migrations
381+ if err := RunEmbeddedMigrations(db); err != nil {
382+ t.Fatalf("Failed to run migrations: %v", err)
383+ }
384+
385+ // Verify should pass
386+ if err := VerifyAllMigrationsApplied(db); err != nil {
387+ t.Errorf("VerifyAllMigrationsApplied() failed after running all migrations: %v", err)
388+ }
389+
390+ // Test with missing migration - manually delete one from tracking
391+ _, err = db.Exec("DELETE FROM schema_migrations WHERE version = 3")
392+ if err != nil {
393+ t.Fatalf("Failed to delete migration record: %v", err)
394+ }
395+
396+ // Verify should now fail
397+ err = VerifyAllMigrationsApplied(db)
398+ if err == nil {
399+ t.Error("VerifyAllMigrationsApplied() should have failed with missing migration, but it passed")
400+ }
401+}
402+
403+// TestExpectedTablesExistAfterMigrations verifies that all expected tables are created
404+func TestExpectedTablesExistAfterMigrations(t *testing.T) {
405+ tmpDir := t.TempDir()
406+ dbPath := filepath.Join(tmpDir, "test.db")
407+
408+ db, err := sql.Open("sqlite3", dbPath)
409+ if err != nil {
410+ t.Fatalf("Failed to open test database: %v", err)
411+ }
412+ defer db.Close()
413+
414+ // Run migrations
415+ if err := RunEmbeddedMigrations(db); err != nil {
416+ t.Fatalf("Failed to run migrations: %v", err)
417+ }
418+
419+ // Expected tables based on current migrations
420+ expectedTables := []string{
421+ "schema_migrations", // Migration tracking
422+ "history", // 001_initial.sql
423+ "shortcuts", // 001_initial.sql
424+ "zoom_levels", // 003_add_zoom_levels.sql
425+ "certificate_validations", // 005_add_certificate_validations.sql
426+ }
427+
428+ // Check each expected table exists
429+ for _, tableName := range expectedTables {
430+ var name string
431+ err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&name)
432+ if err == sql.ErrNoRows {
433+ t.Errorf("Expected table '%s' does not exist", tableName)
434+ } else if err != nil {
435+ t.Fatalf("Failed to check for table '%s': %v", tableName, err)
436+ }
437+ }
438+}
M pkg/webkit/network_session.go +5 -0
@@ -77,6 +77,11 @@ func InitPersistentSession(dataDir, cacheDir string) error {
7777 // Enable persistent credential storage (HTTP auth, etc.)
7878 session.SetPersistentCredentialStorageEnabled(true)
7979
80+ // Configure TLS errors to emit signals instead of failing silently
81+ // This enables the load-failed-with-tls-errors signal
82+ session.SetTLSErrorsPolicy(webkit.TLSErrorsPolicyFail)
83+ log.Printf("[webkit] TLS errors policy set to FAIL (signals enabled)")
84+
8085 // Verify this is indeed the default session now
8186 defaultSession := webkit.NetworkSessionGetDefault()
8287 if defaultSession == nil {
@@ -77,6 +77,11 @@ func InitPersistentSession(dataDir, cacheDir string) error {
77 // Enable persistent credential storage (HTTP auth, etc.)77 // Enable persistent credential storage (HTTP auth, etc.)
78 session.SetPersistentCredentialStorageEnabled(true)78 session.SetPersistentCredentialStorageEnabled(true)
79 79
80+ // Configure TLS errors to emit signals instead of failing silently
81+ // This enables the load-failed-with-tls-errors signal
82+ session.SetTLSErrorsPolicy(webkit.TLSErrorsPolicyFail)
83+ log.Printf("[webkit] TLS errors policy set to FAIL (signals enabled)")
84+
80 // Verify this is indeed the default session now85 // Verify this is indeed the default session now
81 defaultSession := webkit.NetworkSessionGetDefault()86 defaultSession := webkit.NetworkSessionGetDefault()
82 if defaultSession == nil {87 if defaultSession == nil {
A pkg/webkit/tls_dialog_cgo.go +34 -0
@@ -0,0 +1,34 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: gtk4
5+#include <gtk/gtk.h>
6+#include <stdlib.h>
7+
8+// Create a GtkAlertDialog with a message
9+static GtkAlertDialog* create_alert_dialog(const char* message) {
10+ return gtk_alert_dialog_new("%s", message);
11+}
12+*/
13+import "C"
14+import (
15+ "unsafe"
16+
17+ "github.com/diamondburned/gotk4/pkg/core/glib"
18+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
19+)
20+
21+// newAlertDialog creates a new GTK4 AlertDialog using CGO
22+func newAlertDialog(message string) *gtk.AlertDialog {
23+ cstr := C.CString(message)
24+ defer C.free(unsafe.Pointer(cstr))
25+
26+ cDialog := C.create_alert_dialog(cstr)
27+ if cDialog == nil {
28+ return nil
29+ }
30+
31+ // Wrap the C object in a Go AlertDialog
32+ obj := glib.AssumeOwnership(unsafe.Pointer(cDialog))
33+ return &gtk.AlertDialog{Object: obj}
34+}
@@ -0,0 +1,34 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: gtk4
5+#include <gtk/gtk.h>
6+#include <stdlib.h>
7+
8+// Create a GtkAlertDialog with a message
9+static GtkAlertDialog* create_alert_dialog(const char* message) {
10+ return gtk_alert_dialog_new("%s", message);
11+}
12+*/
13+import "C"
14+import (
15+ "unsafe"
16+
17+ "github.com/diamondburned/gotk4/pkg/core/glib"
18+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
19+)
20+
21+// newAlertDialog creates a new GTK4 AlertDialog using CGO
22+func newAlertDialog(message string) *gtk.AlertDialog {
23+ cstr := C.CString(message)
24+ defer C.free(unsafe.Pointer(cstr))
25+
26+ cDialog := C.create_alert_dialog(cstr)
27+ if cDialog == nil {
28+ return nil
29+ }
30+
31+ // Wrap the C object in a Go AlertDialog
32+ obj := glib.AssumeOwnership(unsafe.Pointer(cDialog))
33+ return &gtk.AlertDialog{Object: obj}
34+}
A pkg/webkit/tls_handler.go +352 -0
@@ -0,0 +1,352 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "database/sql"
7+ "fmt"
8+ "log"
9+ "net/url"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/config"
14+ "github.com/bnema/dumber/internal/db"
15+ "github.com/diamondburned/gotk4/pkg/gio/v2"
16+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
17+)
18+
19+// TLSCertificateInfo holds information about a TLS certificate for display
20+type TLSCertificateInfo struct {
21+ Subject string
22+ Issuer string
23+ NotBefore string
24+ NotAfter string
25+ IsExpired bool
26+ Errors []string
27+}
28+
29+// setupTLSErrorHandler sets up the TLS error handler for the WebView
30+func (wv *WebView) setupTLSErrorHandler() {
31+ wv.view.ConnectLoadFailedWithTLSErrors(func(failingUri string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) bool {
32+ log.Printf("[tls] Load failed with TLS errors for: %s, errors: %v", failingUri, errors)
33+
34+ // Extract hostname from URI
35+ hostname := extractHostname(failingUri)
36+ if hostname == "" {
37+ log.Printf("[tls] Failed to extract hostname from URI: %s", failingUri)
38+ return false // Let WebKit handle it
39+ }
40+
41+ // Get certificate info string for hashing
42+ certInfo := formatCertificateInfo(certificate)
43+ certHash := getCertificateHash(certInfo)
44+
45+ // Check if user has previously made a decision about this certificate
46+ decision, exists := checkStoredCertificateDecision(hostname, certHash)
47+ if exists {
48+ if decision == "accepted" {
49+ log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)
50+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
51+ return true // Signal handled
52+ } else {
53+ log.Printf("[tls] Certificate previously rejected for %s, blocking", hostname)
54+ return false // Let WebKit show error
55+ }
56+ }
57+
58+ // No stored decision - show dialog to user
59+ log.Printf("[tls] No stored decision for %s, showing dialog", hostname)
60+ wv.showTLSErrorDialog(failingUri, hostname, certificate, certHash, errors)
61+
62+ return true // Signal handled - we're managing the error
63+ })
64+}
65+
66+// extractHostname extracts the hostname from a URI
67+func extractHostname(uri string) string {
68+ parsed, err := url.Parse(uri)
69+ if err != nil {
70+ // Try to extract manually
71+ if strings.Contains(uri, "://") {
72+ parts := strings.SplitN(uri, "://", 2)
73+ if len(parts) == 2 {
74+ host := strings.Split(parts[1], "/")[0]
75+ // Remove port if present
76+ host = strings.Split(host, ":")[0]
77+ return host
78+ }
79+ }
80+ return ""
81+ }
82+
83+ // Remove port if present
84+ host := parsed.Hostname()
85+ return host
86+}
87+
88+// formatCertificateInfo formats certificate information for display and hashing
89+func formatCertificateInfo(certificate gio.TLSCertificater) string {
90+ // Convert to TLSCertificate to access methods
91+ tlsCert := gio.BaseTLSCertificate(certificate)
92+ if tlsCert == nil {
93+ // Fallback: use the object pointer address as a weak identifier
94+ return fmt.Sprintf("%p", certificate)
95+ }
96+
97+ // Build a unique string from certificate properties
98+ // Use subject, issuer, and validity period
99+ subject := tlsCert.SubjectName()
100+ issuer := ""
101+ if issuerCert := tlsCert.Issuer(); issuerCert != nil {
102+ issuer = gio.BaseTLSCertificate(issuerCert).SubjectName()
103+ }
104+
105+ notBefore := tlsCert.NotValidBefore()
106+ notAfter := tlsCert.NotValidAfter()
107+
108+ // Combine properties into a unique string
109+ return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v",
110+ subject, issuer, notBefore, notAfter)
111+}
112+
113+// getCertificateHash generates a SHA256 hash of the certificate info
114+func getCertificateHash(certificateInfo string) string {
115+ hash := sha256.Sum256([]byte(certificateInfo))
116+ return fmt.Sprintf("%x", hash)
117+}
118+
119+// checkStoredCertificateDecision checks if user has previously decided on this certificate
120+func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
121+ cfg := config.Get()
122+ if cfg == nil {
123+ log.Printf("[tls] Config not available, cannot check stored decisions")
124+ return "", false
125+ }
126+
127+ dbPath := cfg.Database.Path
128+ if dbPath == "" {
129+ log.Printf("[tls] Database path not configured")
130+ return "", false
131+ }
132+
133+ database, err := sql.Open("sqlite3", dbPath)
134+ if err != nil {
135+ log.Printf("[tls] Failed to open database: %v", err)
136+ return "", false
137+ }
138+ defer database.Close()
139+
140+ queries := db.New(database)
141+ ctx := context.Background()
142+
143+ validation, err := queries.GetCertificateValidation(ctx, hostname, certHash)
144+ if err != nil {
145+ if err == sql.ErrNoRows {
146+ return "", false
147+ }
148+ log.Printf("[tls] Error checking certificate validation: %v", err)
149+ return "", false
150+ }
151+
152+ return validation.UserDecision, true
153+}
154+
155+// storeCertificateDecision stores the user's decision about a certificate
156+func storeCertificateDecision(hostname, certHash, decision string, expiresAt sql.NullTime) error {
157+ cfg := config.Get()
158+ if cfg == nil {
159+ return fmt.Errorf("config not available")
160+ }
161+
162+ dbPath := cfg.Database.Path
163+ if dbPath == "" {
164+ return fmt.Errorf("database path not configured")
165+ }
166+
167+ database, err := sql.Open("sqlite3", dbPath)
168+ if err != nil {
169+ return fmt.Errorf("failed to open database: %w", err)
170+ }
171+ defer database.Close()
172+
173+ queries := db.New(database)
174+ ctx := context.Background()
175+
176+ log.Printf("[tls] Saving certificate decision to database: hostname=%s, certHash=%s, decision=%s, expiresAt=%v",
177+ hostname, certHash[:16]+"...", decision, expiresAt)
178+
179+ if err := queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt); err != nil {
180+ log.Printf("[tls] Failed to save certificate decision to database: %v", err)
181+ return err
182+ }
183+
184+ log.Printf("[tls] Certificate decision saved successfully in database")
185+ return nil
186+}
187+
188+// TLS Certificate Flag constants from GIO
189+// These match the C enum values from gioenums.h
190+const (
191+ tlsCertificateUnknownCA gio.TLSCertificateFlags = 1 << 0 // G_TLS_CERTIFICATE_UNKNOWN_CA
192+ tlsCertificateBadIdentity gio.TLSCertificateFlags = 1 << 1 // G_TLS_CERTIFICATE_BAD_IDENTITY
193+ tlsCertificateNotActivated gio.TLSCertificateFlags = 1 << 2 // G_TLS_CERTIFICATE_NOT_ACTIVATED
194+ tlsCertificateExpired gio.TLSCertificateFlags = 1 << 3 // G_TLS_CERTIFICATE_EXPIRED
195+ tlsCertificateRevoked gio.TLSCertificateFlags = 1 << 4 // G_TLS_CERTIFICATE_REVOKED
196+ tlsCertificateInsecure gio.TLSCertificateFlags = 1 << 5 // G_TLS_CERTIFICATE_INSECURE
197+ tlsCertificateGenericError gio.TLSCertificateFlags = 1 << 6 // G_TLS_CERTIFICATE_GENERIC_ERROR
198+)
199+
200+// getTLSErrorMessages converts TLS certificate flags to human-readable messages
201+func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
202+ var messages []string
203+
204+ if errors&tlsCertificateUnknownCA != 0 {
205+ messages = append(messages, "• The certificate authority is not trusted")
206+ }
207+ if errors&tlsCertificateBadIdentity != 0 {
208+ messages = append(messages, "• The certificate does not match the site identity")
209+ }
210+ if errors&tlsCertificateNotActivated != 0 {
211+ messages = append(messages, "• The certificate is not yet valid")
212+ }
213+ if errors&tlsCertificateExpired != 0 {
214+ messages = append(messages, "• The certificate has expired")
215+ }
216+ if errors&tlsCertificateRevoked != 0 {
217+ messages = append(messages, "• The certificate has been revoked")
218+ }
219+ if errors&tlsCertificateInsecure != 0 {
220+ messages = append(messages, "• The certificate uses an insecure algorithm")
221+ }
222+ if errors&tlsCertificateGenericError != 0 {
223+ messages = append(messages, "• A generic error occurred validating the certificate")
224+ }
225+
226+ if len(messages) == 0 {
227+ messages = append(messages, "• Unknown certificate error")
228+ }
229+
230+ return messages
231+}
232+
233+// showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
234+func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, certHash string, errors gio.TLSCertificateFlags) {
235+ // This must run on the main thread
236+ wv.RunOnMainThread(func() {
237+ // Get error messages
238+ errorMessages := getTLSErrorMessages(errors)
239+
240+ // Build detailed message
241+ detail := fmt.Sprintf("The site %s has a certificate error:\n\n%s\n\nProceeding is unsafe and may expose your data to attackers.",
242+ hostname,
243+ strings.Join(errorMessages, "\n"))
244+
245+ // Create AlertDialog using CGO wrapper
246+ dialog := newAlertDialog(fmt.Sprintf("Certificate Error for %s", hostname))
247+ if dialog == nil {
248+ log.Printf("[tls] Failed to create alert dialog")
249+ return
250+ }
251+
252+ // Set dialog properties
253+ dialog.SetDetail(detail)
254+ dialog.SetModal(true)
255+
256+ // Set buttons: Go Back (0), Proceed Once (1), Always Accept (2)
257+ dialog.SetButtons([]string{"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site"})
258+ dialog.SetDefaultButton(0) // Default to "Go Back"
259+ dialog.SetCancelButton(0) // Cancel = "Go Back"
260+
261+ // Get parent window
262+ var parentWindow *gtk.Window
263+ if wv.window != nil {
264+ parentWindow = wv.window.AsWindow()
265+ }
266+
267+ // Show dialog and handle response
268+ dialog.Choose(context.Background(), parentWindow, func(result gio.AsyncResulter) {
269+ buttonIndex, err := dialog.ChooseFinish(result)
270+ if err != nil {
271+ log.Printf("[tls] Dialog error: %v", err)
272+ return
273+ }
274+
275+ log.Printf("[tls] User chose button %d for %s", buttonIndex, hostname)
276+
277+ switch buttonIndex {
278+ case 0: // Go Back
279+ log.Printf("[tls] User chose to go back")
280+ // Do nothing - let the load fail
281+
282+ case 1: // Proceed Once
283+ log.Printf("[tls] User chose to proceed once")
284+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
285+
286+ case 2: // Always Accept This Site
287+ log.Printf("[tls] User chose to always accept for %s", hostname)
288+ // Store decision in database (expires in 30 days)
289+ expiresAt := sql.NullTime{
290+ Time: time.Now().Add(30 * 24 * time.Hour),
291+ Valid: true,
292+ }
293+ if err := storeCertificateDecision(hostname, certHash, "accepted", expiresAt); err != nil {
294+ log.Printf("[tls] Failed to store certificate decision: %v", err)
295+ } else {
296+ log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)
297+ }
298+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
299+ }
300+ })
301+ })
302+}
303+
304+// allowCertificateAndReload allows the certificate for the host and reloads the page
305+func (wv *WebView) allowCertificateAndReload(certificate gio.TLSCertificater, hostname, uri string) {
306+ wv.RunOnMainThread(func() {
307+ // Get the network session
308+ session := wv.view.NetworkSession()
309+ if session == nil {
310+ log.Printf("[tls] Failed to get network session")
311+ return
312+ }
313+
314+ // Allow the certificate for this host
315+ session.AllowTLSCertificateForHost(certificate, hostname)
316+ log.Printf("[tls] Certificate exception added for host: %s", hostname)
317+
318+ // Reload the page
319+ wv.view.LoadURI(uri)
320+ log.Printf("[tls] Reloading %s with certificate exception", uri)
321+ })
322+}
323+
324+// CleanupExpiredCertificateValidations removes expired certificate validations from the database
325+// This should be called on application startup
326+func CleanupExpiredCertificateValidations() error {
327+ cfg := config.Get()
328+ if cfg == nil {
329+ return fmt.Errorf("config not available")
330+ }
331+
332+ dbPath := cfg.Database.Path
333+ if dbPath == "" {
334+ return fmt.Errorf("database path not configured")
335+ }
336+
337+ database, err := sql.Open("sqlite3", dbPath)
338+ if err != nil {
339+ return fmt.Errorf("failed to open database: %w", err)
340+ }
341+ defer database.Close()
342+
343+ queries := db.New(database)
344+ ctx := context.Background()
345+
346+ if err := queries.DeleteExpiredCertificateValidations(ctx); err != nil {
347+ return fmt.Errorf("failed to delete expired validations: %w", err)
348+ }
349+
350+ log.Printf("[tls] Cleaned up expired certificate validations")
351+ return nil
352+}
@@ -0,0 +1,352 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "database/sql"
7+ "fmt"
8+ "log"
9+ "net/url"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/config"
14+ "github.com/bnema/dumber/internal/db"
15+ "github.com/diamondburned/gotk4/pkg/gio/v2"
16+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
17+)
18+
19+// TLSCertificateInfo holds information about a TLS certificate for display
20+type TLSCertificateInfo struct {
21+ Subject string
22+ Issuer string
23+ NotBefore string
24+ NotAfter string
25+ IsExpired bool
26+ Errors []string
27+}
28+
29+// setupTLSErrorHandler sets up the TLS error handler for the WebView
30+func (wv *WebView) setupTLSErrorHandler() {
31+ wv.view.ConnectLoadFailedWithTLSErrors(func(failingUri string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) bool {
32+ log.Printf("[tls] Load failed with TLS errors for: %s, errors: %v", failingUri, errors)
33+
34+ // Extract hostname from URI
35+ hostname := extractHostname(failingUri)
36+ if hostname == "" {
37+ log.Printf("[tls] Failed to extract hostname from URI: %s", failingUri)
38+ return false // Let WebKit handle it
39+ }
40+
41+ // Get certificate info string for hashing
42+ certInfo := formatCertificateInfo(certificate)
43+ certHash := getCertificateHash(certInfo)
44+
45+ // Check if user has previously made a decision about this certificate
46+ decision, exists := checkStoredCertificateDecision(hostname, certHash)
47+ if exists {
48+ if decision == "accepted" {
49+ log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)
50+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
51+ return true // Signal handled
52+ } else {
53+ log.Printf("[tls] Certificate previously rejected for %s, blocking", hostname)
54+ return false // Let WebKit show error
55+ }
56+ }
57+
58+ // No stored decision - show dialog to user
59+ log.Printf("[tls] No stored decision for %s, showing dialog", hostname)
60+ wv.showTLSErrorDialog(failingUri, hostname, certificate, certHash, errors)
61+
62+ return true // Signal handled - we're managing the error
63+ })
64+}
65+
66+// extractHostname extracts the hostname from a URI
67+func extractHostname(uri string) string {
68+ parsed, err := url.Parse(uri)
69+ if err != nil {
70+ // Try to extract manually
71+ if strings.Contains(uri, "://") {
72+ parts := strings.SplitN(uri, "://", 2)
73+ if len(parts) == 2 {
74+ host := strings.Split(parts[1], "/")[0]
75+ // Remove port if present
76+ host = strings.Split(host, ":")[0]
77+ return host
78+ }
79+ }
80+ return ""
81+ }
82+
83+ // Remove port if present
84+ host := parsed.Hostname()
85+ return host
86+}
87+
88+// formatCertificateInfo formats certificate information for display and hashing
89+func formatCertificateInfo(certificate gio.TLSCertificater) string {
90+ // Convert to TLSCertificate to access methods
91+ tlsCert := gio.BaseTLSCertificate(certificate)
92+ if tlsCert == nil {
93+ // Fallback: use the object pointer address as a weak identifier
94+ return fmt.Sprintf("%p", certificate)
95+ }
96+
97+ // Build a unique string from certificate properties
98+ // Use subject, issuer, and validity period
99+ subject := tlsCert.SubjectName()
100+ issuer := ""
101+ if issuerCert := tlsCert.Issuer(); issuerCert != nil {
102+ issuer = gio.BaseTLSCertificate(issuerCert).SubjectName()
103+ }
104+
105+ notBefore := tlsCert.NotValidBefore()
106+ notAfter := tlsCert.NotValidAfter()
107+
108+ // Combine properties into a unique string
109+ return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v",
110+ subject, issuer, notBefore, notAfter)
111+}
112+
113+// getCertificateHash generates a SHA256 hash of the certificate info
114+func getCertificateHash(certificateInfo string) string {
115+ hash := sha256.Sum256([]byte(certificateInfo))
116+ return fmt.Sprintf("%x", hash)
117+}
118+
119+// checkStoredCertificateDecision checks if user has previously decided on this certificate
120+func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
121+ cfg := config.Get()
122+ if cfg == nil {
123+ log.Printf("[tls] Config not available, cannot check stored decisions")
124+ return "", false
125+ }
126+
127+ dbPath := cfg.Database.Path
128+ if dbPath == "" {
129+ log.Printf("[tls] Database path not configured")
130+ return "", false
131+ }
132+
133+ database, err := sql.Open("sqlite3", dbPath)
134+ if err != nil {
135+ log.Printf("[tls] Failed to open database: %v", err)
136+ return "", false
137+ }
138+ defer database.Close()
139+
140+ queries := db.New(database)
141+ ctx := context.Background()
142+
143+ validation, err := queries.GetCertificateValidation(ctx, hostname, certHash)
144+ if err != nil {
145+ if err == sql.ErrNoRows {
146+ return "", false
147+ }
148+ log.Printf("[tls] Error checking certificate validation: %v", err)
149+ return "", false
150+ }
151+
152+ return validation.UserDecision, true
153+}
154+
155+// storeCertificateDecision stores the user's decision about a certificate
156+func storeCertificateDecision(hostname, certHash, decision string, expiresAt sql.NullTime) error {
157+ cfg := config.Get()
158+ if cfg == nil {
159+ return fmt.Errorf("config not available")
160+ }
161+
162+ dbPath := cfg.Database.Path
163+ if dbPath == "" {
164+ return fmt.Errorf("database path not configured")
165+ }
166+
167+ database, err := sql.Open("sqlite3", dbPath)
168+ if err != nil {
169+ return fmt.Errorf("failed to open database: %w", err)
170+ }
171+ defer database.Close()
172+
173+ queries := db.New(database)
174+ ctx := context.Background()
175+
176+ log.Printf("[tls] Saving certificate decision to database: hostname=%s, certHash=%s, decision=%s, expiresAt=%v",
177+ hostname, certHash[:16]+"...", decision, expiresAt)
178+
179+ if err := queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt); err != nil {
180+ log.Printf("[tls] Failed to save certificate decision to database: %v", err)
181+ return err
182+ }
183+
184+ log.Printf("[tls] Certificate decision saved successfully in database")
185+ return nil
186+}
187+
188+// TLS Certificate Flag constants from GIO
189+// These match the C enum values from gioenums.h
190+const (
191+ tlsCertificateUnknownCA gio.TLSCertificateFlags = 1 << 0 // G_TLS_CERTIFICATE_UNKNOWN_CA
192+ tlsCertificateBadIdentity gio.TLSCertificateFlags = 1 << 1 // G_TLS_CERTIFICATE_BAD_IDENTITY
193+ tlsCertificateNotActivated gio.TLSCertificateFlags = 1 << 2 // G_TLS_CERTIFICATE_NOT_ACTIVATED
194+ tlsCertificateExpired gio.TLSCertificateFlags = 1 << 3 // G_TLS_CERTIFICATE_EXPIRED
195+ tlsCertificateRevoked gio.TLSCertificateFlags = 1 << 4 // G_TLS_CERTIFICATE_REVOKED
196+ tlsCertificateInsecure gio.TLSCertificateFlags = 1 << 5 // G_TLS_CERTIFICATE_INSECURE
197+ tlsCertificateGenericError gio.TLSCertificateFlags = 1 << 6 // G_TLS_CERTIFICATE_GENERIC_ERROR
198+)
199+
200+// getTLSErrorMessages converts TLS certificate flags to human-readable messages
201+func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
202+ var messages []string
203+
204+ if errors&tlsCertificateUnknownCA != 0 {
205+ messages = append(messages, "• The certificate authority is not trusted")
206+ }
207+ if errors&tlsCertificateBadIdentity != 0 {
208+ messages = append(messages, "• The certificate does not match the site identity")
209+ }
210+ if errors&tlsCertificateNotActivated != 0 {
211+ messages = append(messages, "• The certificate is not yet valid")
212+ }
213+ if errors&tlsCertificateExpired != 0 {
214+ messages = append(messages, "• The certificate has expired")
215+ }
216+ if errors&tlsCertificateRevoked != 0 {
217+ messages = append(messages, "• The certificate has been revoked")
218+ }
219+ if errors&tlsCertificateInsecure != 0 {
220+ messages = append(messages, "• The certificate uses an insecure algorithm")
221+ }
222+ if errors&tlsCertificateGenericError != 0 {
223+ messages = append(messages, "• A generic error occurred validating the certificate")
224+ }
225+
226+ if len(messages) == 0 {
227+ messages = append(messages, "• Unknown certificate error")
228+ }
229+
230+ return messages
231+}
232+
233+// showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
234+func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, certHash string, errors gio.TLSCertificateFlags) {
235+ // This must run on the main thread
236+ wv.RunOnMainThread(func() {
237+ // Get error messages
238+ errorMessages := getTLSErrorMessages(errors)
239+
240+ // Build detailed message
241+ detail := fmt.Sprintf("The site %s has a certificate error:\n\n%s\n\nProceeding is unsafe and may expose your data to attackers.",
242+ hostname,
243+ strings.Join(errorMessages, "\n"))
244+
245+ // Create AlertDialog using CGO wrapper
246+ dialog := newAlertDialog(fmt.Sprintf("Certificate Error for %s", hostname))
247+ if dialog == nil {
248+ log.Printf("[tls] Failed to create alert dialog")
249+ return
250+ }
251+
252+ // Set dialog properties
253+ dialog.SetDetail(detail)
254+ dialog.SetModal(true)
255+
256+ // Set buttons: Go Back (0), Proceed Once (1), Always Accept (2)
257+ dialog.SetButtons([]string{"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site"})
258+ dialog.SetDefaultButton(0) // Default to "Go Back"
259+ dialog.SetCancelButton(0) // Cancel = "Go Back"
260+
261+ // Get parent window
262+ var parentWindow *gtk.Window
263+ if wv.window != nil {
264+ parentWindow = wv.window.AsWindow()
265+ }
266+
267+ // Show dialog and handle response
268+ dialog.Choose(context.Background(), parentWindow, func(result gio.AsyncResulter) {
269+ buttonIndex, err := dialog.ChooseFinish(result)
270+ if err != nil {
271+ log.Printf("[tls] Dialog error: %v", err)
272+ return
273+ }
274+
275+ log.Printf("[tls] User chose button %d for %s", buttonIndex, hostname)
276+
277+ switch buttonIndex {
278+ case 0: // Go Back
279+ log.Printf("[tls] User chose to go back")
280+ // Do nothing - let the load fail
281+
282+ case 1: // Proceed Once
283+ log.Printf("[tls] User chose to proceed once")
284+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
285+
286+ case 2: // Always Accept This Site
287+ log.Printf("[tls] User chose to always accept for %s", hostname)
288+ // Store decision in database (expires in 30 days)
289+ expiresAt := sql.NullTime{
290+ Time: time.Now().Add(30 * 24 * time.Hour),
291+ Valid: true,
292+ }
293+ if err := storeCertificateDecision(hostname, certHash, "accepted", expiresAt); err != nil {
294+ log.Printf("[tls] Failed to store certificate decision: %v", err)
295+ } else {
296+ log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)
297+ }
298+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
299+ }
300+ })
301+ })
302+}
303+
304+// allowCertificateAndReload allows the certificate for the host and reloads the page
305+func (wv *WebView) allowCertificateAndReload(certificate gio.TLSCertificater, hostname, uri string) {
306+ wv.RunOnMainThread(func() {
307+ // Get the network session
308+ session := wv.view.NetworkSession()
309+ if session == nil {
310+ log.Printf("[tls] Failed to get network session")
311+ return
312+ }
313+
314+ // Allow the certificate for this host
315+ session.AllowTLSCertificateForHost(certificate, hostname)
316+ log.Printf("[tls] Certificate exception added for host: %s", hostname)
317+
318+ // Reload the page
319+ wv.view.LoadURI(uri)
320+ log.Printf("[tls] Reloading %s with certificate exception", uri)
321+ })
322+}
323+
324+// CleanupExpiredCertificateValidations removes expired certificate validations from the database
325+// This should be called on application startup
326+func CleanupExpiredCertificateValidations() error {
327+ cfg := config.Get()
328+ if cfg == nil {
329+ return fmt.Errorf("config not available")
330+ }
331+
332+ dbPath := cfg.Database.Path
333+ if dbPath == "" {
334+ return fmt.Errorf("database path not configured")
335+ }
336+
337+ database, err := sql.Open("sqlite3", dbPath)
338+ if err != nil {
339+ return fmt.Errorf("failed to open database: %w", err)
340+ }
341+ defer database.Close()
342+
343+ queries := db.New(database)
344+ ctx := context.Background()
345+
346+ if err := queries.DeleteExpiredCertificateValidations(ctx); err != nil {
347+ return fmt.Errorf("failed to delete expired validations: %w", err)
348+ }
349+
350+ log.Printf("[tls] Cleaned up expired certificate validations")
351+ return nil
352+}
M pkg/webkit/webview.go +3 -0
@@ -209,6 +209,9 @@ func (w *WebView) setupEventHandlers() {
209209 // Handle load events if needed
210210 })
211211
212+ // TLS error handling - connect to load-failed-with-tls-errors signal
213+ w.setupTLSErrorHandler()
214+
212215 // Close
213216 w.view.ConnectClose(func() {
214217 if w.onClose != nil {
@@ -209,6 +209,9 @@ func (w *WebView) setupEventHandlers() {
209 // Handle load events if needed209 // Handle load events if needed
210 })210 })
211 211
212+ // TLS error handling - connect to load-failed-with-tls-errors signal
213+ w.setupTLSErrorHandler()
214+
212 // Close215 // Close
213 w.view.ConnectClose(func() {216 w.view.ConnectClose(func() {
214 if w.onClose != nil {217 if w.onClose != nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close