marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit de8cfea0fbc4d28e7b700ed98ac511d86bedc6b5
parent 182840e210c0863d6ad3d46b2ac488b93c370670
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 23:28:00 -0400

Consolidate migrations

Diffstat:
Minternal/api.go | 124++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Minternal/database.go | 4++--
Dinternal/migrations/0001_init.sql | 17-----------------
Ainternal/migrations/0001_users.sql | 15+++++++++++++++
Dinternal/migrations/0002_archives.sql | 10----------
Ainternal/migrations/0002_bookmarks.sql | 15+++++++++++++++
Ainternal/migrations/0003_archives.sql | 9+++++++++
Dinternal/migrations/0003_fts.sql | 9---------
Ainternal/migrations/0004_fts.sql | 8++++++++
Dinternal/migrations/0004_unique_url.sql | 4----
Dinternal/migrations/0005_public.sql | 3---
Dinternal/migrations/0006_users.sql | 12------------
Dinternal/migrations/0007_bookmarks_user.sql | 7-------
Dinternal/migrations/0008_users_api_token_unique.sql | 5-----
Dinternal/migrations/0009_bookmarks_user_not_null.sql | 37-------------------------------------
Ainternal/user_route_test.go | 38++++++++++++++++++++++++++++++++++++++
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) + } +}