marky

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

commit fbe219a287d77fea096917f7262afe2e58712236
parent e1c5f7327f0d2d58c50bca3b16371bca7b85a393
Author: David Eisinger <[email protected]>
Date:   Thu,  9 Oct 2025 14:35:11 -0400

Fix formatting

Diffstat:
Minternal/api.go | 274++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/database_test.go | 6+++---
Minternal/static.go | 224++++++++++++++++++++++++++++++++++++++++---------------------------------------
Minternal/user_db.go | 56++++++++++++++++++++++++++++----------------------------
Minternal/user_route_test.go | 30+++++++++++++++---------------
5 files changed, 296 insertions(+), 294 deletions(-)

diff --git a/internal/api.go b/internal/api.go @@ -252,26 +252,26 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { }(), origin, jsToken, jsUser) }) - // API token endpoint for logged-in user (regenerate only) - mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) { - uid, ok := CurrentUserID(r) - if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - switch r.Method { - case http.MethodPost: - tok, err := db.RegenerateAPIToken(r.Context(), uid) - if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"api_token": tok}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - }) + // API token endpoint for logged-in user (regenerate only) + mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) { + uid, ok := CurrentUserID(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + switch r.Method { + case http.MethodPost: + tok, err := db.RegenerateAPIToken(r.Context(), uid) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"api_token": tok}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) // Public listing by username (JSON) mux.HandleFunc("/u/", func(w http.ResponseWriter, r *http.Request) { @@ -342,115 +342,115 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { return } } - // Settings page (requires auth) - if p == "settings" { - uid, ok := userIDFromRequest(r, db) - if !ok || uid == 0 { - http.Redirect(w, r, "/", http.StatusFound) - return - } - if html, err := RenderPage("settings.html", cfg); err == nil { - // Embed API token directly for settings hydration - if tok, _, err := db.GetAPIToken(r.Context(), uid); err == nil { - inject := []byte("<script>window.MARKY_TOKEN=" + strconv.Quote(tok) + "</script>\n") - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(html, marker); i >= 0 { - out := make([]byte, 0, len(html)+len(inject)) - out = append(out, html[:i]...) - out = append(out, inject...) - out = append(out, html[i:]...) - html = out - } - } - html = applyCacheBust(html) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(html) - return - } - } - if p != "" { - // Only allow static/page handling for GET/HEAD; other methods 404 here - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.NotFound(w, r) - return - } - // If the path is a single segment ending with a trailing dot, treat it - // as an invalid username route and return 404. - if !strings.Contains(p, "/") && strings.HasSuffix(p, ".") { - http.NotFound(w, r) - return - } + // Settings page (requires auth) + if p == "settings" { + uid, ok := userIDFromRequest(r, db) + if !ok || uid == 0 { + http.Redirect(w, r, "/", http.StatusFound) + return + } + if html, err := RenderPage("settings.html", cfg); err == nil { + // Embed API token directly for settings hydration + if tok, _, err := db.GetAPIToken(r.Context(), uid); err == nil { + inject := []byte("<script>window.MARKY_TOKEN=" + strconv.Quote(tok) + "</script>\n") + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(html, marker); i >= 0 { + out := make([]byte, 0, len(html)+len(inject)) + out = append(out, html[:i]...) + out = append(out, inject...) + out = append(out, html[i:]...) + html = out + } + } + html = applyCacheBust(html) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(html) + return + } + } + if p != "" { + // Only allow static/page handling for GET/HEAD; other methods 404 here + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + // If the path is a single segment ending with a trailing dot, treat it + // as an invalid username route and return 404. + if !strings.Contains(p, "/") && strings.HasSuffix(p, ".") { + http.NotFound(w, r) + return + } // New user page route: /u:{username} - if strings.HasPrefix(p, "u:") { - uname := strings.TrimPrefix(p, "u:") - if uname == "" || strings.Contains(uname, "/") { - http.NotFound(w, r) - return - } - if u, err := db.GetUserByUsername(r.Context(), uname); err == nil && u != nil { - if html, err := RenderPage("user.html", cfg); err == nil { - // Set <title> to "{username} | Marky" by replacing inner text only - if ti := bytes.Index(html, []byte("<title>")); ti >= 0 { - if tj := bytes.Index(html[ti:], []byte("</title>")); tj > 0 { - start := ti + len("<title>") - end := ti + tj - nt := []byte(u.Username + " | Marky") - nb := make([]byte, 0, len(html)-(end-start)+len(nt)) - nb = append(nb, html[:start]...) - nb = append(nb, nt...) - nb = append(nb, html[end:]...) - html = nb - } - } - // Embed initial bookmarks to avoid client fetch - if isOwnerAuthorized(r, u, db) { - items, err := db.ListBookmarks(r.Context(), u.ID, 50, 0) - if err == nil { - resp := make([]bookmarkResponse, 0, len(items)) - for i := range items { - resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) - } - if b, err := json.Marshal(resp); err == nil { - inject := []byte("<script>window.MARKY_INITIAL={view:'self',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(html, marker); i >= 0 { - out := make([]byte, 0, len(html)+len(inject)) - out = append(out, html[:i]...) - out = append(out, inject...) - out = append(out, html[i:]...) - html = out - } - } - } - } else { - items, err := db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) - if err == nil { - resp := make([]bookmarkResponse, 0, len(items)) - for i := range items { - resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) - } - if b, err := json.Marshal(resp); err == nil { - inject := []byte("<script>window.MARKY_INITIAL={view:'public',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(html, marker); i >= 0 { - out := make([]byte, 0, len(html)+len(inject)) - out = append(out, html[:i]...) - out = append(out, inject...) - out = append(out, html[i:]...) - html = out - } - } - } - } - html = applyCacheBust(html) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(html) - return - } - } - http.NotFound(w, r) - return - } + if strings.HasPrefix(p, "u:") { + uname := strings.TrimPrefix(p, "u:") + if uname == "" || strings.Contains(uname, "/") { + http.NotFound(w, r) + return + } + if u, err := db.GetUserByUsername(r.Context(), uname); err == nil && u != nil { + if html, err := RenderPage("user.html", cfg); err == nil { + // Set <title> to "{username} | Marky" by replacing inner text only + if ti := bytes.Index(html, []byte("<title>")); ti >= 0 { + if tj := bytes.Index(html[ti:], []byte("</title>")); tj > 0 { + start := ti + len("<title>") + end := ti + tj + nt := []byte(u.Username + " | Marky") + nb := make([]byte, 0, len(html)-(end-start)+len(nt)) + nb = append(nb, html[:start]...) + nb = append(nb, nt...) + nb = append(nb, html[end:]...) + html = nb + } + } + // Embed initial bookmarks to avoid client fetch + if isOwnerAuthorized(r, u, db) { + items, err := db.ListBookmarks(r.Context(), u.ID, 50, 0) + if err == nil { + resp := make([]bookmarkResponse, 0, len(items)) + for i := range items { + resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) + } + if b, err := json.Marshal(resp); err == nil { + inject := []byte("<script>window.MARKY_INITIAL={view:'self',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(html, marker); i >= 0 { + out := make([]byte, 0, len(html)+len(inject)) + out = append(out, html[:i]...) + out = append(out, inject...) + out = append(out, html[i:]...) + html = out + } + } + } + } else { + items, err := db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) + if err == nil { + resp := make([]bookmarkResponse, 0, len(items)) + for i := range items { + resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) + } + if b, err := json.Marshal(resp); err == nil { + inject := []byte("<script>window.MARKY_INITIAL={view:'public',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(html, marker); i >= 0 { + out := make([]byte, 0, len(html)+len(inject)) + out = append(out, html[:i]...) + out = append(out, inject...) + out = append(out, html[i:]...) + html = out + } + } + } + } + html = applyCacheBust(html) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(html) + return + } + } + http.NotFound(w, r) + return + } if strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss") { // Extract username and format format := "json" @@ -491,15 +491,15 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { return } } - } - // Legacy username route removed: any non-file single segment (not feeds) 404s - if !strings.Contains(p, "/") && !strings.Contains(p, ".") && !strings.HasPrefix(p, "u:") { - http.NotFound(w, r) - return - } - } - static.ServeHTTP(w, r) - }) + } + // Legacy username route removed: any non-file single segment (not feeds) 404s + if !strings.Contains(p, "/") && !strings.Contains(p, ".") && !strings.HasPrefix(p, "u:") { + http.NotFound(w, r) + return + } + } + static.ServeHTTP(w, r) + }) // Shared handler for JSON and RSS bookmarksHandler := func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/database_test.go b/internal/database_test.go @@ -20,7 +20,7 @@ func TestMigrationsCRUDAndSearch(t *testing.T) { // Create user uid := int64(1) - _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, api_token, created_at, updated_at) VALUES(1, 'tester', 'x', 'tok', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`) + _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, api_token, created_at, updated_at) VALUES(1, 'tester', 'x', 'tok', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`) if err != nil { t.Fatalf("insert user: %v", err) } @@ -81,7 +81,7 @@ func TestUniqueURL(t *testing.T) { // Create user uid := int64(1) - _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, api_token, created_at, updated_at) VALUES(1, 'tester', 'x', 'tok', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`) + _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, api_token, created_at, updated_at) VALUES(1, 'tester', 'x', 'tok', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`) if err != nil { t.Fatalf("insert user: %v", err) } @@ -106,7 +106,7 @@ func TestFTSIndexUpdatesOnArchive(t *testing.T) { // Create user uid := int64(1) - _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, api_token, created_at, updated_at) VALUES(1, 'tester', 'x', 'tok', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`) + _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, api_token, created_at, updated_at) VALUES(1, 'tester', 'x', 'tok', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`) if err != nil { t.Fatalf("insert user: %v", err) } diff --git a/internal/static.go b/internal/static.go @@ -1,14 +1,14 @@ package internal import ( - "bytes" - "embed" - "crypto/sha256" - "fmt" - "io/fs" - "net/http" - "path" - "strings" + "bytes" + "crypto/sha256" + "embed" + "fmt" + "io/fs" + "net/http" + "path" + "strings" ) //go:embed web/* @@ -20,118 +20,120 @@ func StaticHandler() (http.Handler, error) { return StaticHandlerWithConfig(Hand // StaticHandlerWithConfig serves the embedded UI, injecting runtime config. func StaticHandlerWithConfig(cfg HandlerConfig) (http.Handler, error) { - sub, err := fs.Sub(webAssets, "web") - if err != nil { - return nil, err - } - fsys := http.FS(sub) - fileServer := http.FileServer(fsys) - indexBytes, err := fs.ReadFile(sub, "index.html") - if err != nil { - return nil, err - } - // Inject shared header into index.html placeholder - if hb, err := fs.ReadFile(sub, "header.html"); err == nil { - if bytes.Contains(indexBytes, []byte("{{HEADER}}")) { - indexBytes = bytes.ReplaceAll(indexBytes, []byte("{{HEADER}}"), hb) - } - } - // Inject a small config script when features are toggled. - if cfg.DisableSignup { - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(indexBytes, marker); i >= 0 { - inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n") - nb := make([]byte, 0, len(indexBytes)+len(inject)) - nb = append(nb, indexBytes[:i]...) - nb = append(nb, inject...) - nb = append(nb, indexBytes[i:]...) - indexBytes = nb - } - } - // Apply cache-busting query params to static asset URLs - indexBytes = applyCacheBust(indexBytes) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - p := strings.TrimPrefix(r.URL.Path, "/") - if p == "" { - // Serve index directly to avoid redirects - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(indexBytes) - return - } - // Try to stat the requested file - if f, err := fs.Stat(sub, path.Clean(p)); err == nil && !f.IsDir() { - fileServer.ServeHTTP(w, r) - return - } - // Fallback to index.html for SPA routes - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(indexBytes) - }), nil + sub, err := fs.Sub(webAssets, "web") + if err != nil { + return nil, err + } + fsys := http.FS(sub) + fileServer := http.FileServer(fsys) + indexBytes, err := fs.ReadFile(sub, "index.html") + if err != nil { + return nil, err + } + // Inject shared header into index.html placeholder + if hb, err := fs.ReadFile(sub, "header.html"); err == nil { + if bytes.Contains(indexBytes, []byte("{{HEADER}}")) { + indexBytes = bytes.ReplaceAll(indexBytes, []byte("{{HEADER}}"), hb) + } + } + // Inject a small config script when features are toggled. + if cfg.DisableSignup { + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(indexBytes, marker); i >= 0 { + inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n") + nb := make([]byte, 0, len(indexBytes)+len(inject)) + nb = append(nb, indexBytes[:i]...) + nb = append(nb, inject...) + nb = append(nb, indexBytes[i:]...) + indexBytes = nb + } + } + // Apply cache-busting query params to static asset URLs + indexBytes = applyCacheBust(indexBytes) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if p == "" { + // Serve index directly to avoid redirects + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexBytes) + return + } + // Try to stat the requested file + if f, err := fs.Stat(sub, path.Clean(p)); err == nil && !f.IsDir() { + fileServer.ServeHTTP(w, r) + return + } + // Fallback to index.html for SPA routes + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexBytes) + }), nil } // RenderPage reads an embedded HTML page under internal/web and injects the // shared header and optional runtime config. func RenderPage(name string, cfg HandlerConfig) ([]byte, error) { - sub, err := fs.Sub(webAssets, "web") - if err != nil { - return nil, err - } - pageBytes, err := fs.ReadFile(sub, name) - if err != nil { - return nil, err - } - if hb, err := fs.ReadFile(sub, "header.html"); err == nil { - if bytes.Contains(pageBytes, []byte("{{HEADER}}")) { - pageBytes = bytes.ReplaceAll(pageBytes, []byte("{{HEADER}}"), hb) - } - } - if cfg.DisableSignup { - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(pageBytes, marker); i >= 0 { - inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n") - nb := make([]byte, 0, len(pageBytes)+len(inject)) - nb = append(nb, pageBytes[:i]...) - nb = append(nb, inject...) - nb = append(nb, pageBytes[i:]...) - pageBytes = nb - } - } - return pageBytes, nil + sub, err := fs.Sub(webAssets, "web") + if err != nil { + return nil, err + } + pageBytes, err := fs.ReadFile(sub, name) + if err != nil { + return nil, err + } + if hb, err := fs.ReadFile(sub, "header.html"); err == nil { + if bytes.Contains(pageBytes, []byte("{{HEADER}}")) { + pageBytes = bytes.ReplaceAll(pageBytes, []byte("{{HEADER}}"), hb) + } + } + if cfg.DisableSignup { + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(pageBytes, marker); i >= 0 { + inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n") + nb := make([]byte, 0, len(pageBytes)+len(inject)) + nb = append(nb, pageBytes[:i]...) + nb = append(nb, inject...) + nb = append(nb, pageBytes[i:]...) + pageBytes = nb + } + } + return pageBytes, nil } // applyCacheBust appends a short content hash to known asset URLs in HTML, // e.g., /app.js?v=abcd1234. This helps CDNs like Cloudflare pick up changes // without manual purges. func applyCacheBust(html []byte) []byte { - sub, err := fs.Sub(webAssets, "web") - if err != nil { return html } - // Build fingerprint map for assets we serve locally - assets := []string{ - "app.js", - "styles.css", - "favicon.svg", - "vendor/vue.global.prod.js", - "vendor/marked.min.js", - "vendor/purify.min.js", - } - bust := map[string]string{} - for _, p := range assets { - if b, err := fs.ReadFile(sub, p); err == nil { - sum := sha256.Sum256(b) - bust[p] = fmt.Sprintf("%x", sum)[:10] - } - } - // Replace src/href occurrences - out := html - for p, v := range bust { - // src="/path" - from := []byte("src=\"/"+p+"\"") - to := []byte("src=\"/"+p+"?v="+v+"\"") - out = bytes.ReplaceAll(out, from, to) - // href="/path" - fromH := []byte("href=\"/"+p+"\"") - toH := []byte("href=\"/"+p+"?v="+v+"\"") - out = bytes.ReplaceAll(out, fromH, toH) - } - return out + sub, err := fs.Sub(webAssets, "web") + if err != nil { + return html + } + // Build fingerprint map for assets we serve locally + assets := []string{ + "app.js", + "styles.css", + "favicon.svg", + "vendor/vue.global.prod.js", + "vendor/marked.min.js", + "vendor/purify.min.js", + } + bust := map[string]string{} + for _, p := range assets { + if b, err := fs.ReadFile(sub, p); err == nil { + sum := sha256.Sum256(b) + bust[p] = fmt.Sprintf("%x", sum)[:10] + } + } + // Replace src/href occurrences + out := html + for p, v := range bust { + // src="/path" + from := []byte("src=\"/" + p + "\"") + to := []byte("src=\"/" + p + "?v=" + v + "\"") + out = bytes.ReplaceAll(out, from, to) + // href="/path" + fromH := []byte("href=\"/" + p + "\"") + toH := []byte("href=\"/" + p + "?v=" + v + "\"") + out = bytes.ReplaceAll(out, fromH, toH) + } + return out } diff --git a/internal/user_db.go b/internal/user_db.go @@ -1,43 +1,43 @@ package internal import ( - "context" - "crypto/rand" - "database/sql" - "encoding/base64" - "fmt" - "time" + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "time" "golang.org/x/crypto/bcrypt" ) // CreateUser inserts a new user with a bcrypt-hashed password and returns the new ID. func (db *DB) CreateUser(ctx context.Context, username, password string) (int64, error) { - if err := ValidateUsername(username); err != nil { - return 0, err - } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return 0, fmt.Errorf("hash password: %w", err) - } - // Generate an API token on creation - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return 0, fmt.Errorf("rand: %w", err) - } - token := base64.RawURLEncoding.EncodeToString(b) - now := time.Now().UTC().Format(time.RFC3339Nano) - res, err := db.sql.ExecContext(ctx, ` + if err := ValidateUsername(username); err != nil { + return 0, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return 0, fmt.Errorf("hash password: %w", err) + } + // Generate an API token on creation + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return 0, fmt.Errorf("rand: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(b) + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := db.sql.ExecContext(ctx, ` INSERT INTO users(username, encrypted_password, api_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?) `, username, string(hash), token, now, now) - if err != nil { - return 0, fmt.Errorf("insert user: %w", err) - } - id, err := res.LastInsertId() - if err != nil { - return 0, fmt.Errorf("last id: %w", err) - } + if err != nil { + return 0, fmt.Errorf("insert user: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("last id: %w", err) + } // Backfill: assign any orphan bookmarks (pre-user era) to the first created user if _, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET user_id = ? WHERE user_id IS NULL`, id); err != nil { return 0, fmt.Errorf("backfill bookmarks: %w", err) diff --git a/internal/user_route_test.go b/internal/user_route_test.go @@ -26,19 +26,19 @@ func TestUsernameRoute404WhenMissing(t *testing.T) { t.Fatalf("expected 404 for /nouser, got %d", resp.StatusCode) } - // Create a user - _, err = db.CreateUser(t.Context(), "alice", "pw") - if err != nil { - t.Fatalf("create user: %v", err) - } - // Legacy /{username} route removed: should 404 - resp2, _ := http.Get(srv.URL + "/alice") - if resp2.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404 for /alice, got %d", resp2.StatusCode) - } - // New route /u:{username} should serve the page - resp3, _ := http.Get(srv.URL + "/u:alice") - if resp3.StatusCode != http.StatusOK { - t.Fatalf("expected 200 for /u:alice, got %d", resp3.StatusCode) - } + // Create a user + _, err = db.CreateUser(t.Context(), "alice", "pw") + if err != nil { + t.Fatalf("create user: %v", err) + } + // Legacy /{username} route removed: should 404 + resp2, _ := http.Get(srv.URL + "/alice") + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for /alice, got %d", resp2.StatusCode) + } + // New route /u:{username} should serve the page + resp3, _ := http.Get(srv.URL + "/u:alice") + if resp3.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /u:alice, got %d", resp3.StatusCode) + } }