commit fbe219a287d77fea096917f7262afe2e58712236
parent e1c5f7327f0d2d58c50bca3b16371bca7b85a393
Author: David Eisinger <[email protected]>
Date: Thu, 9 Oct 2025 14:35:11 -0400
Fix formatting
Diffstat:
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)
+ }
}