commit de8cfea0fbc4d28e7b700ed98ac511d86bedc6b5
parent 182840e210c0863d6ad3d46b2ac488b93c370670
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 23:28:00 -0400
Consolidate migrations
Diffstat:
16 files changed, 162 insertions(+), 155 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -214,6 +214,15 @@ func NewHandler(db *DB) (http.Handler, error) {
}
}
}
+ // If this looks like a username route (no dot/extension, no subpath),
+ // return 404 when the user does not exist. Otherwise, serve the SPA.
+ if !strings.Contains(p, "/") && !strings.Contains(p, ".") {
+ if u, err := db.GetUserByUsername(r.Context(), p); err != nil || u == nil {
+ http.NotFound(w, r)
+ return
+ }
+ // user exists: fall through to SPA to render the page
+ }
}
static.ServeHTTP(w, r)
})
@@ -388,56 +397,73 @@ func NewHandler(db *DB) (http.Handler, error) {
mux.HandleFunc("/bookmarks", bookmarksHandler)
mux.HandleFunc("/bookmarks.xml", bookmarksHandler)
- // Archive, PUT, DELETE under /bookmarks/{id}
- mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
- rest := strings.TrimPrefix(path, "/bookmarks/")
- if rest == "" || strings.HasPrefix(rest, "/") {
- http.NotFound(w, r)
- return
- }
- if strings.HasSuffix(rest, "/archive") {
- idPart := strings.TrimSuffix(rest, "/archive")
- idPart = strings.TrimSuffix(idPart, "/")
- id64, err := strconv.ParseInt(idPart, 10, 64)
- if err != nil || id64 <= 0 {
- http.Error(w, "invalid id", http.StatusBadRequest)
- return
- }
- switch r.Method {
- case http.MethodGet:
- // Enforce public/private visibility
- b, err := db.GetBookmark(r.Context(), id64)
- if err != nil { log.Printf("lookup bookmark error: %v", err); http.Error(w, "internal server error", http.StatusInternalServerError); return }
- if b == nil { http.NotFound(w, r); return }
- uid, _ := userIDFromRequest(r, db)
- isOwner := (uid > 0 && uid == b.UserID)
- if !b.Public && !isOwner { http.NotFound(w, r); return }
+ // Archive, PUT, DELETE under /bookmarks/{id}
+ mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Path
+ rest := strings.TrimPrefix(path, "/bookmarks/")
+ if rest == "" || strings.HasPrefix(rest, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ if strings.HasSuffix(rest, "/archive") {
+ idPart := strings.TrimSuffix(rest, "/archive")
+ idPart = strings.TrimSuffix(idPart, "/")
+ id64, err := strconv.ParseInt(idPart, 10, 64)
+ if err != nil || id64 <= 0 {
+ http.Error(w, "invalid id", http.StatusBadRequest)
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ // Enforce public/private visibility
+ b, err := db.GetBookmark(r.Context(), id64)
+ if err != nil {
+ log.Printf("lookup bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if b == nil {
+ http.NotFound(w, r)
+ return
+ }
+ uid, _ := userIDFromRequest(r, db)
+ isOwner := (uid > 0 && uid == b.UserID)
+ if !b.Public && !isOwner {
+ http.NotFound(w, r)
+ return
+ }
- content, _, ok, err := db.GetArchive(r.Context(), id64)
- if err != nil {
- log.Printf("get archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !ok {
- // Only the owner may fetch on-demand
- if isOwner && (r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true") {
- ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
- defer cancel()
- txt, err := ArchiveToText(ctx, b.URL)
- if err != nil { http.Error(w, "failed to fetch archive", http.StatusBadGateway); return }
- if err := db.SaveArchive(ctx, id64, txt); err != nil { log.Printf("save archive error: %v", err); http.Error(w, "internal server error", http.StatusInternalServerError); return }
- content = txt
- ok = true
- }
- }
- if !ok {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- _, _ = w.Write([]byte(content))
+ content, _, ok, err := db.GetArchive(r.Context(), id64)
+ if err != nil {
+ log.Printf("get archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ // Only the owner may fetch on-demand
+ if isOwner && (r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true") {
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ txt, err := ArchiveToText(ctx, b.URL)
+ if err != nil {
+ http.Error(w, "failed to fetch archive", http.StatusBadGateway)
+ return
+ }
+ if err := db.SaveArchive(ctx, id64, txt); err != nil {
+ log.Printf("save archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ content = txt
+ ok = true
+ }
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(content))
default:
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/database.go b/internal/database.go
@@ -213,9 +213,9 @@ func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
var b Bookmark
var created, updated string
if err := db.sql.QueryRowContext(ctx, `
- SELECT id, url, title, description, public, created_at, updated_at
+ SELECT id, url, title, description, public, user_id, created_at, updated_at
FROM bookmarks WHERE id = ?
- `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
+ `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
diff --git a/internal/migrations/0001_init.sql b/internal/migrations/0001_init.sql
@@ -1,17 +0,0 @@
--- Enable foreign key support
-PRAGMA foreign_keys = ON;
-
--- Create bookmarks table
-CREATE TABLE IF NOT EXISTS bookmarks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- url TEXT NOT NULL,
- title TEXT NOT NULL,
- description TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-
--- Optional indexes for common lookups
-CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);
-CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at);
-
diff --git a/internal/migrations/0001_users.sql b/internal/migrations/0001_users.sql
@@ -0,0 +1,15 @@
+-- Users table
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE CHECK (username <> '' AND username GLOB '[a-z0-9]*'),
+ encrypted_password TEXT NOT NULL,
+ api_token TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+-- Unique API token when present
+CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token_unique
+ON users(api_token)
+WHERE api_token IS NOT NULL AND api_token <> '';
+
diff --git a/internal/migrations/0002_archives.sql b/internal/migrations/0002_archives.sql
@@ -1,10 +0,0 @@
--- Archive table to store plaintext dumps per bookmark
-CREATE TABLE IF NOT EXISTS archives (
- bookmark_id INTEGER PRIMARY KEY,
- content TEXT NOT NULL,
- archived_at TEXT NOT NULL,
- FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_archives_archived_at ON archives(archived_at);
-
diff --git a/internal/migrations/0002_bookmarks.sql b/internal/migrations/0002_bookmarks.sql
@@ -0,0 +1,15 @@
+-- Bookmarks table (owned by users)
+CREATE TABLE IF NOT EXISTS bookmarks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ public INTEGER NOT NULL DEFAULT 0,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+-- Per-user URL uniqueness
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, url);
+
diff --git a/internal/migrations/0003_archives.sql b/internal/migrations/0003_archives.sql
@@ -0,0 +1,9 @@
+-- Archives table for plaintext dumps
+CREATE TABLE IF NOT EXISTS archives (
+ bookmark_id INTEGER PRIMARY KEY REFERENCES bookmarks(id) ON DELETE CASCADE,
+ content TEXT NOT NULL,
+ archived_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_archives_archived_at ON archives(archived_at);
+
diff --git a/internal/migrations/0003_fts.sql b/internal/migrations/0003_fts.sql
@@ -1,9 +0,0 @@
--- FTS5 virtual table for full-text search across bookmark metadata and archived content.
--- Standalone (contentful) FTS table managed explicitly by the application.
-DROP TABLE IF EXISTS bookmarks_fts;
-CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
- title,
- url,
- description,
- content
-);
diff --git a/internal/migrations/0004_fts.sql b/internal/migrations/0004_fts.sql
@@ -0,0 +1,8 @@
+-- FTS5 virtual table for full-text search (contentful)
+CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
+ title,
+ url,
+ description,
+ content
+);
+
diff --git a/internal/migrations/0004_unique_url.sql b/internal/migrations/0004_unique_url.sql
@@ -1,4 +0,0 @@
--- Ensure URL uniqueness.
-DROP INDEX IF EXISTS idx_bookmarks_url;
-CREATE UNIQUE INDEX IF NOT EXISTS idx_bookmarks_url_unique ON bookmarks(url);
-
diff --git a/internal/migrations/0005_public.sql b/internal/migrations/0005_public.sql
@@ -1,3 +0,0 @@
--- Add public flag to bookmarks (0 = private, 1 = public)
-ALTER TABLE bookmarks ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
-
diff --git a/internal/migrations/0006_users.sql b/internal/migrations/0006_users.sql
@@ -1,12 +0,0 @@
--- Users table for authentication
-CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL CHECK (username <> '' AND username GLOB '[a-z0-9]*'),
- encrypted_password TEXT NOT NULL,
- api_token TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
-
diff --git a/internal/migrations/0007_bookmarks_user.sql b/internal/migrations/0007_bookmarks_user.sql
@@ -1,7 +0,0 @@
--- Add user ownership to bookmarks (per-user uniqueness)
-ALTER TABLE bookmarks ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
-
--- Update URL uniqueness to be per-user
-DROP INDEX IF EXISTS idx_bookmarks_url_unique;
-CREATE UNIQUE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, url);
-
diff --git a/internal/migrations/0008_users_api_token_unique.sql b/internal/migrations/0008_users_api_token_unique.sql
@@ -1,5 +0,0 @@
--- Ensure API tokens are unique per user (allowing multiple NULL/empty)
-CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token_unique
-ON users(api_token)
-WHERE api_token IS NOT NULL AND api_token <> '';
-
diff --git a/internal/migrations/0009_bookmarks_user_not_null.sql b/internal/migrations/0009_bookmarks_user_not_null.sql
@@ -1,37 +0,0 @@
--- Rebuild bookmarks to enforce NOT NULL user_id and proper constraints
-PRAGMA foreign_keys = OFF;
-
--- Create new table with desired schema
-CREATE TABLE bookmarks_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- url TEXT NOT NULL,
- title TEXT NOT NULL,
- description TEXT,
- public INTEGER NOT NULL DEFAULT 0,
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-
--- Populate from old table, assigning orphan rows to the first user
-INSERT INTO bookmarks_new (id, url, title, description, public, user_id, created_at, updated_at)
-SELECT
- id,
- url,
- title,
- description,
- COALESCE(public, 0),
- COALESCE(user_id, (SELECT id FROM users ORDER BY id LIMIT 1)),
- created_at,
- updated_at
-FROM bookmarks;
-
--- Replace tables
-DROP TABLE bookmarks;
-ALTER TABLE bookmarks_new RENAME TO bookmarks;
-
--- Indices
-DROP INDEX IF EXISTS idx_bookmarks_user_url;
-CREATE UNIQUE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, url);
-
-PRAGMA foreign_keys = ON;
diff --git a/internal/user_route_test.go b/internal/user_route_test.go
@@ -0,0 +1,38 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestUsernameRoute404WhenMissing(t *testing.T) {
+ t.Parallel()
+ db, err := OpenAtPath(t.Context(), t.TempDir()+"/r.db")
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ h, err := NewHandler(db)
+ if err != nil {
+ t.Fatalf("handler: %v", err)
+ }
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // Missing user should 404
+ resp, _ := http.Get(srv.URL + "/nouser")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("expected 404 for /nouser, got %d", resp.StatusCode)
+ }
+
+ // Create a user and verify /alice returns 200 (serves SPA index)
+ _, err = db.CreateUser(t.Context(), "alice", "pw")
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ resp2, _ := http.Get(srv.URL + "/alice")
+ if resp2.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200 for /alice, got %d", resp2.StatusCode)
+ }
+}