commit 3294091290e07bee9d5f9fdace3aebce016c9b23
parent a40f5b88b37468af2b01bb41187829c49d159730
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 22:09:28 -0400
logged-in/-out views working
Diffstat:
13 files changed, 509 insertions(+), 88 deletions(-)
diff --git a/go.mod b/go.mod
@@ -4,7 +4,10 @@ go 1.24.5
require modernc.org/sqlite v1.33.0
-require golang.org/x/net v0.44.0
+require (
+ golang.org/x/crypto v0.42.0
+ golang.org/x/net v0.44.0
+)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
diff --git a/go.sum b/go.sum
@@ -14,6 +14,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
+golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
diff --git a/internal/api.go b/internal/api.go
@@ -15,19 +15,108 @@ import (
// NewHandler constructs the HTTP API handler, including the embedded web UI.
func NewHandler(db *DB) (http.Handler, error) {
- mux := http.NewServeMux()
+ mux := http.NewServeMux()
- // Static web UI at "/"
- if h, err := StaticHandler(); err != nil {
- return nil, err
- } else {
- mux.Handle("/", h)
- }
+ // Auth endpoints
+ mux.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return }
+ var req struct{ Username, Password string }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Username)=="" || strings.TrimSpace(req.Password)=="" {
+ http.Error(w, "invalid payload", http.StatusBadRequest); return
+ }
+ if err := ValidateUsername(req.Username); err != nil { http.Error(w, err.Error(), http.StatusBadRequest); return }
+ id, err := db.CreateUser(r.Context(), req.Username, req.Password)
+ if err != nil { http.Error(w, "cannot create user", http.StatusConflict); return }
+ SetSession(w, id)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"username": req.Username})
+ })
+ mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return }
+ var req struct{ Username, Password string }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest); return }
+ id, ok, err := db.VerifyUserPassword(r.Context(), req.Username, req.Password)
+ if err != nil || !ok { http.Error(w, "invalid credentials", http.StatusUnauthorized); return }
+ SetSession(w, id)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"username": req.Username})
+ })
+ mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return }
+ ClearSession(w); w.WriteHeader(http.StatusNoContent)
+ })
+ mux.HandleFunc("/me", func(w http.ResponseWriter, r *http.Request) {
+ if uid, ok := CurrentUserID(r); ok {
+ if u, err := db.GetUserByID(r.Context(), uid); err==nil && u!=nil {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"username": u.Username})
+ return
+ }
+ }
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ })
+
+ // Public listing by username (JSON)
+ mux.HandleFunc("/u/", func(w http.ResponseWriter, r *http.Request) {
+ uname := strings.TrimPrefix(r.URL.Path, "/u/")
+ if uname == "" || strings.Contains(uname, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ u, err := db.GetUserByUsername(r.Context(), uname)
+ if err != nil || u == nil {
+ http.NotFound(w, r)
+ return
+ }
+ // Pagination
+ q := r.URL.Query()
+ limit, offset := 50, 0
+ if s := q.Get("limit"); s != "" {
+ if v, err := strconv.Atoi(s); err == nil && v > 0 {
+ if v > 200 {
+ v = 200
+ }
+ limit = v
+ } else {
+ http.Error(w, "invalid limit", http.StatusBadRequest)
+ return
+ }
+ }
+ if s := q.Get("offset"); s != "" {
+ if v, err := strconv.Atoi(s); err == nil && v >= 0 {
+ offset = v
+ } else {
+ http.Error(w, "invalid offset", http.StatusBadRequest)
+ return
+ }
+ }
+ items, err := db.ListPublicBookmarks(r.Context(), u.ID, limit, offset)
+ if err != nil {
+ log.Printf("list public bookmarks error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ resp := make([]bookmarkResponse, 0, len(items))
+ for i := range items {
+ resp = append(resp, toResp(&items[i]))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ })
+
+ // Static web UI at "/"
+ if h, err := StaticHandler(); err != nil {
+ return nil, err
+ } else {
+ mux.Handle("/", h)
+ }
// Shared handler for JSON and RSS
bookmarksHandler := func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
- case http.MethodPost:
+ case http.MethodPost:
+ uid, ok := CurrentUserID(r)
+ if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
if r.Header.Get("Content-Type") != "" {
ct := r.Header.Get("Content-Type")
if ct != "application/json" && ct != "application/json; charset=utf-8" && ct != "application/json;charset=utf-8" {
@@ -73,7 +162,7 @@ func NewHandler(db *DB) (http.Handler, error) {
pub = *req.Public
}
- if existing, err := db.GetBookmarkByURL(r.Context(), req.URL); err != nil {
+ if existing, err := db.GetBookmarkByURL(r.Context(), uid, req.URL); err != nil {
log.Printf("lookup by url error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
@@ -100,7 +189,7 @@ func NewHandler(db *DB) (http.Handler, error) {
_ = json.NewEncoder(w).Encode(toResp(updated))
return
} else {
- created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, title, req.Description, pub, txt)
+ created, err := db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
if err != nil {
log.Printf("create bookmark with archive error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -112,7 +201,8 @@ func NewHandler(db *DB) (http.Handler, error) {
}
case http.MethodGet:
- q := r.URL.Query()
+ uid, _ := CurrentUserID(r)
+ q := r.URL.Query()
limit := 50
offset := 0
query := strings.TrimSpace(q.Get("q"))
@@ -137,8 +227,8 @@ func NewHandler(db *DB) (http.Handler, error) {
}
var resp []bookmarkResponse
- if query != "" {
- hits, err := db.SearchBookmarksWithExcerpt(r.Context(), query, limit, offset)
+ if query != "" {
+ hits, err := db.SearchBookmarksWithExcerpt(r.Context(), uid, query, limit, offset)
if err != nil {
log.Printf("list bookmarks error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -154,8 +244,8 @@ func NewHandler(db *DB) (http.Handler, error) {
}
resp = append(resp, br)
}
- } else {
- items, err := db.ListBookmarks(r.Context(), limit, offset)
+ } else {
+ items, err := db.ListBookmarks(r.Context(), uid, limit, offset)
if err != nil {
log.Printf("list bookmarks error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
diff --git a/internal/auth.go b/internal/auth.go
@@ -0,0 +1,70 @@
+package internal
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var secretKey []byte
+
+func init() {
+ if k := os.Getenv("MARKY_SECRET"); k != "" {
+ secretKey = []byte(k)
+ } else {
+ secretKey = []byte("dev-secret-change-me")
+ }
+}
+
+// SetSession sets a signed cookie identifying the user ID.
+func SetSession(w http.ResponseWriter, userID int64) {
+ exp := time.Now().Add(30 * 24 * time.Hour).Unix()
+ payload := strconv.FormatInt(userID, 10) + ":" + strconv.FormatInt(exp, 10)
+ sig := sign(payload)
+ val := payload + ":" + sig
+ http.SetCookie(w, &http.Cookie{
+ Name: "marky_session",
+ Value: base64.StdEncoding.EncodeToString([]byte(val)),
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: int(30 * 24 * time.Hour / time.Second),
+ })
+}
+
+// ClearSession removes the session cookie.
+func ClearSession(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: "marky_session", Value: "", Path: "/", MaxAge: -1})
+}
+
+// CurrentUserID extracts the user ID from the signed cookie, if valid.
+func CurrentUserID(r *http.Request) (int64, bool) {
+ c, err := r.Cookie("marky_session")
+ if err != nil || c.Value == "" {
+ return 0, false
+ }
+ raw, err := base64.StdEncoding.DecodeString(c.Value)
+ if err != nil { return 0, false }
+ parts := strings.Split(string(raw), ":")
+ if len(parts) != 3 { return 0, false }
+ payload := parts[0] + ":" + parts[1]
+ if !hmac.Equal([]byte(sign(payload)), []byte(parts[2])) { return 0, false }
+ exp, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil || exp < time.Now().Unix() { return 0, false }
+ uid, err := strconv.ParseInt(parts[0], 10, 64)
+ if err != nil || uid <= 0 { return 0, false }
+ return uid, true
+}
+
+func sign(s string) string {
+ mac := hmac.New(sha256.New, secretKey)
+ mac.Write([]byte(s))
+ sum := mac.Sum(nil)
+ return base64.StdEncoding.EncodeToString(sum[:16])
+}
+
diff --git a/internal/database.go b/internal/database.go
@@ -26,6 +26,7 @@ type Bookmark struct {
Title string
Description sql.NullString
Public bool
+ UserID int64
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -167,7 +168,7 @@ func applyMigrations(ctx context.Context, sqldb *sql.DB) error {
}
// CreateBookmark inserts a new bookmark and returns it.
-func (db *DB) CreateBookmark(ctx context.Context, url, title string, description *string) (*Bookmark, error) {
+func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title string, description *string) (*Bookmark, error) {
if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
return nil, fmt.Errorf("url and title are required")
}
@@ -177,9 +178,9 @@ func (db *DB) CreateBookmark(ctx context.Context, url, title string, description
desc = sql.NullString{String: *description, Valid: true}
}
res, err := db.sql.ExecContext(ctx, `
- INSERT INTO bookmarks(url, title, description, public, created_at, updated_at)
- VALUES(?, ?, ?, 0, ?, ?)
- `, url, title, nullString(desc), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
+ INSERT INTO bookmarks(url, title, description, public, user_id, created_at, updated_at)
+ VALUES(?, ?, ?, 0, ?, ?, ?)
+ `, url, title, nullString(desc), userID, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return nil, fmt.Errorf("insert bookmark: %w", err)
}
@@ -201,6 +202,7 @@ func (db *DB) CreateBookmark(ctx context.Context, url, title string, description
Title: title,
Description: desc,
Public: false,
+ UserID: userID,
CreatedAt: now,
UpdatedAt: now,
}, nil
@@ -230,13 +232,13 @@ func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
}
// GetBookmarkByURL returns a bookmark by exact URL.
-func (db *DB) GetBookmarkByURL(ctx context.Context, u string) (*Bookmark, error) {
+func (db *DB) GetBookmarkByURL(ctx context.Context, userID int64, u string) (*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
- FROM bookmarks WHERE url = ?
- `, u).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
+ SELECT id, url, title, description, public, user_id, created_at, updated_at
+ FROM bookmarks WHERE url = ? AND user_id = ?
+ `, u, userID).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
}
@@ -253,16 +255,17 @@ func (db *DB) GetBookmarkByURL(ctx context.Context, u string) (*Bookmark, error)
}
// ListBookmarks returns bookmarks ordered by created_at desc.
-func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark, error) {
+func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int) ([]Bookmark, error) {
if limit <= 0 {
limit = 50
}
rows, err := db.sql.QueryContext(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 user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
- `, limit, offset)
+ `, userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("query bookmarks: %w", err)
}
@@ -271,7 +274,7 @@ func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark,
for rows.Next() {
var b Bookmark
var created, updated string
- if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
return nil, fmt.Errorf("scan bookmark: %w", err)
}
if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
@@ -371,7 +374,7 @@ func publicToInt(v bool) int {
// CreateBookmarkWithArchive creates a bookmark and saves its plaintext archive
// within a single transaction to ensure consistency.
-func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string, description *string, public bool, archiveContent string) (*Bookmark, error) {
+func (db *DB) CreateBookmarkWithArchive(ctx context.Context, userID int64, url, title string, description *string, public bool, archiveContent string) (*Bookmark, error) {
if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
return nil, fmt.Errorf("url and title are required")
}
@@ -386,9 +389,9 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string,
return nil, fmt.Errorf("begin tx: %w", err)
}
res, err := tx.ExecContext(ctx, `
- INSERT INTO bookmarks(url, title, description, public, created_at, updated_at)
- VALUES(?, ?, ?, ?, ?, ?)
- `, url, title, nullString(desc), publicToInt(public), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
+ INSERT INTO bookmarks(url, title, description, public, user_id, created_at, updated_at)
+ VALUES(?, ?, ?, ?, ?, ?, ?)
+ `, url, title, nullString(desc), publicToInt(public), userID, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("insert bookmark: %w", err)
@@ -419,6 +422,7 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string,
Title: title,
Description: desc,
Public: public,
+ UserID: userID,
CreatedAt: now,
UpdatedAt: now,
}, nil
@@ -472,18 +476,18 @@ func (db *DB) GetArchive(ctx context.Context, bookmarkID int64) (content string,
// SearchBookmarks performs a full-text search using FTS5 and returns matching bookmarks
// ordered by a simple relevance score derived from offsets() length.
-func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset int) ([]Bookmark, error) {
+func (db *DB) SearchBookmarks(ctx context.Context, userID int64, query string, limit, offset int) ([]Bookmark, error) {
if limit <= 0 {
limit = 50
}
rows, err := db.sql.QueryContext(ctx, `
- SELECT b.id, b.url, b.title, b.description, b.public, b.created_at, b.updated_at
+ SELECT b.id, b.url, b.title, b.description, b.public, b.user_id, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
- WHERE bookmarks_fts MATCH ?
+ WHERE bookmarks_fts MATCH ? AND b.user_id = ?
ORDER BY bm25(bookmarks_fts)
LIMIT ? OFFSET ?
- `, query, limit, offset)
+ `, query, userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("fts query: %w", err)
}
@@ -492,7 +496,7 @@ func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset i
for rows.Next() {
var b Bookmark
var created, updated string
- if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
return nil, fmt.Errorf("scan fts row: %w", err)
}
if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
@@ -511,18 +515,18 @@ func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset i
// SearchBookmarksWithExcerpt performs an FTS5 search and also returns a snippet excerpt
// from the archived content column highlighting matches.
-func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limit, offset int) ([]SearchResult, error) {
+func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, query string, limit, offset int) ([]SearchResult, error) {
if limit <= 0 {
limit = 50
}
rows, err := db.sql.QueryContext(ctx, `
- SELECT b.id, b.url, b.title, b.description, b.public, b.created_at, b.updated_at
+ SELECT b.id, b.url, b.title, b.description, b.public, b.user_id, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
- WHERE bookmarks_fts MATCH ?
+ WHERE bookmarks_fts MATCH ? AND b.user_id = ?
ORDER BY bm25(bookmarks_fts)
LIMIT ? OFFSET ?
- `, query, limit, offset)
+ `, query, userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("fts query: %w", err)
}
@@ -531,7 +535,7 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limi
for rows.Next() {
var b Bookmark
var created, updated string
- if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
return nil, fmt.Errorf("scan fts row: %w", err)
}
if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
diff --git a/internal/database_test.go b/internal/database_test.go
@@ -18,8 +18,12 @@ func TestMigrationsCRUDAndSearch(t *testing.T) {
}
defer db.Close()
- // Create with archive content
- b, err := db.CreateBookmarkWithArchive(ctx, "http://example.local/1", "Example 1", nil, false, "The city of Durham has great food.")
+ // Create user
+ uid := int64(1)
+ _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, created_at, updated_at) VALUES(1, 'tester', 'x', 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) }
+ // Create with archive content
+ b, err := db.CreateBookmarkWithArchive(ctx, uid, "http://example.local/1", "Example 1", nil, false, "The city of Durham has great food.")
if err != nil {
t.Fatalf("create with archive: %v", err)
}
@@ -37,7 +41,7 @@ func TestMigrationsCRUDAndSearch(t *testing.T) {
}
// Search with excerpt
- hits, err := db.SearchBookmarksWithExcerpt(ctx, "durham", 10, 0)
+ hits, err := db.SearchBookmarksWithExcerpt(ctx, uid, "durham", 10, 0)
if err != nil {
t.Fatalf("search: %v", err)
}
@@ -53,7 +57,7 @@ func TestMigrationsCRUDAndSearch(t *testing.T) {
if err != nil || !okDel {
t.Fatalf("delete: ok=%v err=%v", okDel, err)
}
- hits, err = db.SearchBookmarksWithExcerpt(ctx, "durham", 10, 0)
+ hits, err = db.SearchBookmarksWithExcerpt(ctx, uid, "durham", 10, 0)
if err != nil {
t.Fatalf("search after delete: %v", err)
}
@@ -73,12 +77,16 @@ func TestUniqueURL(t *testing.T) {
}
defer db.Close()
- if _, err := db.CreateBookmark(ctx, "http://dupe.local", "One", nil); err != nil {
- t.Fatalf("first create: %v", err)
- }
- if _, err := db.CreateBookmark(ctx, "http://dupe.local", "Two", nil); err == nil {
- t.Fatalf("expected unique constraint error, got nil")
- }
+ // Create user
+ uid := int64(1)
+ _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, created_at, updated_at) VALUES(1, 'tester', 'x', 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) }
+ if _, err := db.CreateBookmark(ctx, uid, "http://dupe.local", "One", nil); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ if _, err := db.CreateBookmark(ctx, uid, "http://dupe.local", "Two", nil); err == nil {
+ t.Fatalf("expected unique constraint error, got nil")
+ }
}
func TestFTSIndexUpdatesOnArchive(t *testing.T) {
@@ -92,12 +100,16 @@ func TestFTSIndexUpdatesOnArchive(t *testing.T) {
}
defer db.Close()
- b, err := db.CreateBookmark(ctx, "http://no-archive.local", "No Archive", nil)
+ // Create user
+ uid := int64(1)
+ _, err = db.sql.ExecContext(ctx, `INSERT INTO users(id, username, encrypted_password, created_at, updated_at) VALUES(1, 'tester', 'x', 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) }
+ b, err := db.CreateBookmark(ctx, uid, "http://no-archive.local", "No Archive", nil)
if err != nil {
t.Fatalf("create: %v", err)
}
// Should not find term before archive content exists
- hits, err := db.SearchBookmarksWithExcerpt(ctx, "needle", 5, 0)
+ hits, err := db.SearchBookmarksWithExcerpt(ctx, uid, "needle", 5, 0)
if err != nil {
t.Fatalf("search pre-archive: %v", err)
}
@@ -108,7 +120,7 @@ func TestFTSIndexUpdatesOnArchive(t *testing.T) {
if err := db.SaveArchive(ctx, b.ID, "finding a needle in a haystack"); err != nil {
t.Fatalf("save archive: %v", err)
}
- hits, err = db.SearchBookmarksWithExcerpt(ctx, "needle", 5, 0)
+ hits, err = db.SearchBookmarksWithExcerpt(ctx, uid, "needle", 5, 0)
if err != nil {
t.Fatalf("search post-archive: %v", err)
}
diff --git a/internal/e2e_test.go b/internal/e2e_test.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/http/cookiejar"
"strings"
"testing"
)
@@ -33,12 +34,18 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
if err != nil {
t.Fatalf("new handler: %v", err)
}
- srv := httptest.NewServer(h)
- defer srv.Close()
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // Auth: sign up and retain cookie
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar}
+ signupPayload := map[string]string{"username":"e2euser","password":"pass"}
+ _ = postJSONWithClient[map[string]any](t, client, srv.URL+"/signup", signupPayload, 200)
// 1) Create bookmark without title -> title inferred; archive stored
- body := map[string]any{"url": page.URL}
- b := postJSON[map[string]any](t, srv.URL+"/bookmarks", body, 201)
+ body := map[string]any{"url": page.URL}
+ b := postJSONWithClient[map[string]any](t, client, srv.URL+"/bookmarks", body, 201)
idFloat, _ := b["id"].(float64)
if idFloat <= 0 {
t.Fatalf("expected id > 0, got %v", idFloat)
@@ -50,7 +57,7 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
id := int(idFloat)
// 2) RSS feed
- resp := get(t, srv.URL+"/bookmarks.xml", 200)
+ resp := getWithClient(t, client, srv.URL+"/bookmarks.xml", 200)
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/rss+xml") {
t.Fatalf("expected rss content-type, got %q", ct)
}
@@ -61,7 +68,7 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
}
// 3) Search JSON with excerpt
- list := getJSON[[]map[string]any](t, srv.URL+"/bookmarks?q=durham", 200)
+ list := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/bookmarks?q=durham", 200)
if len(list) != 1 {
t.Fatalf("expected 1 result, got %d", len(list))
}
@@ -70,7 +77,7 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
}
// 4) GET archive
- arch := get(t, srv.URL+"/bookmarks/"+itoa(id)+"/archive", 200)
+ arch := getWithClient(t, client, srv.URL+"/bookmarks/"+itoa(id)+"/archive", 200)
archBody, _ := io.ReadAll(arch.Body)
_ = arch.Body.Close()
if !bytes.Contains(bytes.ToLower(archBody), []byte("hello")) {
@@ -79,14 +86,14 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
// 5) Update title via PUT
upd := map[string]any{"url": page.URL, "title": "New Title"}
- b2 := putJSON[map[string]any](t, srv.URL+"/bookmarks/"+itoa(id), upd, 200)
+ b2 := putJSONWithClient[map[string]any](t, client, srv.URL+"/bookmarks/"+itoa(id), upd, 200)
if b2["title"].(string) != "New Title" {
t.Fatalf("expected updated title, got %q", b2["title"])
}
// 6) Delete
- del(t, srv.URL+"/bookmarks/"+itoa(id), 204)
- list2 := getJSON[[]map[string]any](t, srv.URL+"/bookmarks", 200)
+ delWithClient(t, client, srv.URL+"/bookmarks/"+itoa(id), 204)
+ list2 := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/bookmarks", 200)
if len(list2) != 0 {
t.Fatalf("expected 0 bookmarks after delete, got %d", len(list2))
}
@@ -95,10 +102,10 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
// Helpers
func itoa(i int) string { return strconvItoa(i) }
-func postJSON[T any](t *testing.T, url string, v any, want int) T {
+func postJSONWithClient[T any](t *testing.T, client *http.Client, url string, v any, want int) T {
t.Helper()
b, _ := json.Marshal(v)
- resp, err := http.Post(url, "application/json", bytes.NewReader(b))
+ resp, err := client.Post(url, "application/json", bytes.NewReader(b))
if err != nil {
t.Fatalf("post json: %v", err)
}
@@ -113,12 +120,12 @@ func postJSON[T any](t *testing.T, url string, v any, want int) T {
return out
}
-func putJSON[T any](t *testing.T, url string, v any, want int) T {
+func putJSONWithClient[T any](t *testing.T, client *http.Client, url string, v any, want int) T {
t.Helper()
b, _ := json.Marshal(v)
req, _ := http.NewRequest(http.MethodPut, url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
- resp, err := http.DefaultClient.Do(req)
+ resp, err := client.Do(req)
if err != nil {
t.Fatalf("put json: %v", err)
}
@@ -133,9 +140,9 @@ func putJSON[T any](t *testing.T, url string, v any, want int) T {
return out
}
-func getJSON[T any](t *testing.T, url string, want int) T {
+func getJSONWithClient[T any](t *testing.T, client *http.Client, url string, want int) T {
t.Helper()
- resp := get(t, url, want)
+ resp := getWithClient(t, client, url, want)
defer resp.Body.Close()
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
@@ -144,9 +151,9 @@ func getJSON[T any](t *testing.T, url string, want int) T {
return out
}
-func get(t *testing.T, url string, want int) *http.Response {
+func getWithClient(t *testing.T, client *http.Client, url string, want int) *http.Response {
t.Helper()
- resp, err := http.Get(url)
+ resp, err := client.Get(url)
if err != nil {
t.Fatalf("get: %v", err)
}
@@ -156,10 +163,10 @@ func get(t *testing.T, url string, want int) *http.Response {
return resp
}
-func del(t *testing.T, url string, want int) {
+func delWithClient(t *testing.T, client *http.Client, url string, want int) {
t.Helper()
req, _ := http.NewRequest(http.MethodDelete, url, nil)
- resp, err := http.DefaultClient.Do(req)
+ resp, err := client.Do(req)
if err != nil {
t.Fatalf("delete: %v", err)
}
diff --git a/internal/migrations/0007_bookmarks_user.sql b/internal/migrations/0007_bookmarks_user.sql
@@ -0,0 +1,7 @@
+-- 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/static.go b/internal/static.go
@@ -1,19 +1,44 @@
package internal
import (
- "embed"
- "io/fs"
- "net/http"
+ "embed"
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
)
//go:embed web/*
var webAssets embed.FS
-// StaticHandler returns an http.Handler serving the embedded web UI.
+// StaticHandler returns an http.Handler serving the embedded web UI with
+// history-fallback to index.html for client-side routes like "/{username}".
func StaticHandler() (http.Handler, error) {
- sub, err := fs.Sub(webAssets, "web")
- if err != nil {
- return nil, err
- }
- return http.FileServer(http.FS(sub)), 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
+ }
+ 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
}
diff --git a/internal/user_db.go b/internal/user_db.go
@@ -0,0 +1,98 @@
+package internal
+
+import (
+ "context"
+ "database/sql"
+ "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) }
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ res, err := db.sql.ExecContext(ctx, `
+ INSERT INTO users(username, encrypted_password, created_at, updated_at)
+ VALUES(?, ?, ?, ?)
+ `, username, string(hash), 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) }
+ // 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)
+ }
+ return id, nil
+}
+
+// VerifyUserPassword checks credentials and returns (userID, ok, err).
+func (db *DB) VerifyUserPassword(ctx context.Context, username, password string) (int64, bool, error) {
+ var id int64
+ var hash string
+ err := db.sql.QueryRowContext(ctx, `SELECT id, encrypted_password FROM users WHERE username = ?`, username).Scan(&id, &hash)
+ if err != nil {
+ if err == sql.ErrNoRows { return 0, false, nil }
+ return 0, false, fmt.Errorf("lookup user: %w", err)
+ }
+ if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return 0, false, nil }
+ return id, true, nil
+}
+
+func (db *DB) GetUserByID(ctx context.Context, id int64) (*User, error) {
+ var u User
+ var apiToken sql.NullString
+ if err := db.sql.QueryRowContext(ctx, `
+ SELECT id, username, encrypted_password, api_token FROM users WHERE id = ?
+ `, id).Scan(&u.ID, &u.Username, &u.EncryptedPassword, &apiToken); err != nil {
+ if err == sql.ErrNoRows { return nil, nil }
+ return nil, fmt.Errorf("get user: %w", err)
+ }
+ if apiToken.Valid { s := apiToken.String; u.APIToken = &s }
+ return &u, nil
+}
+
+func (db *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) {
+ var u User
+ var apiToken sql.NullString
+ if err := db.sql.QueryRowContext(ctx, `
+ SELECT id, username, encrypted_password, api_token FROM users WHERE username = ?
+ `, username).Scan(&u.ID, &u.Username, &u.EncryptedPassword, &apiToken); err != nil {
+ if err == sql.ErrNoRows { return nil, nil }
+ return nil, fmt.Errorf("get user by username: %w", err)
+ }
+ if apiToken.Valid { s := apiToken.String; u.APIToken = &s }
+ return &u, nil
+}
+
+// ListPublicBookmarks lists only public bookmarks for a given user.
+func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offset int) ([]Bookmark, error) {
+ if limit <= 0 { limit = 50 }
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT id, url, title, description, public, user_id, created_at, updated_at
+ FROM bookmarks WHERE user_id = ? AND public = 1
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ `, userID, limit, offset)
+ if err != nil { return nil, fmt.Errorf("query public bookmarks: %w", err) }
+ defer rows.Close()
+ out := make([]Bookmark, 0, limit)
+ for rows.Next() {
+ var b Bookmark
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan: %w", err)
+ }
+ var e error
+ if b.CreatedAt, e = time.Parse(time.RFC3339Nano, created); e != nil { return nil, e }
+ if b.UpdatedAt, e = time.Parse(time.RFC3339Nano, updated); e != nil { return nil, e }
+ out = append(out, b)
+ }
+ if err := rows.Err(); err != nil { return nil, err }
+ return out, nil
+}
diff --git a/internal/web/app.js b/internal/web/app.js
@@ -10,13 +10,68 @@ createApp({
q: "",
busySearch: false,
results: [],
+ view: 'login', // 'login' | 'signup' | 'self' | 'public'
+ me: null,
+ viewUser: '',
+ auth: { username: '', password: '' },
};
},
mounted() {
- // Initial list
- this.search();
+ this.initView();
},
methods: {
+ async initView() {
+ // Determine logged-in user
+ try {
+ const res = await fetch('/me');
+ if (res.ok) this.me = await res.json();
+ } catch {}
+ const pathUser = location.pathname.replace(/^\/+/, '').split('/')[0] || '';
+ if (!pathUser) {
+ if (this.me && this.me.username) {
+ location.replace('/' + this.me.username);
+ return;
+ }
+ this.view = 'login';
+ return;
+ }
+ this.viewUser = pathUser;
+ if (this.me && this.me.username === this.viewUser) {
+ this.view = 'self';
+ this.search();
+ } else {
+ this.view = 'public';
+ this.loadPublic();
+ }
+ },
+ async login() {
+ this.error = '';
+ this.busy = true;
+ try {
+ const res = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.auth) });
+ if (!res.ok) throw new Error(await res.text());
+ const me = await res.json();
+ location.replace('/' + me.username);
+ } catch (e) {
+ this.error = e.message || String(e);
+ } finally { this.busy = false; }
+ },
+ async signup() {
+ this.error = '';
+ this.busy = true;
+ try {
+ const res = await fetch('/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.auth) });
+ if (!res.ok) throw new Error(await res.text());
+ const me = await res.json();
+ location.replace('/' + me.username);
+ } catch (e) {
+ this.error = e.message || String(e);
+ } finally { this.busy = false; }
+ },
+ async logout() {
+ await fetch('/logout', { method: 'POST' }).catch(()=>{});
+ location.replace('/');
+ },
startEdit(b) {
b.editing = true;
b.editUrl = b.url;
@@ -115,6 +170,14 @@ createApp({
this.busySearch = false;
}
},
+ async loadPublic() {
+ try {
+ const res = await fetch(`/u/${encodeURIComponent(this.viewUser)}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const arr = await res.json();
+ this.results = arr.map((x)=>({ ...x, description: x.description ?? null, public: true }));
+ } catch (e) { console.error(e); }
+ },
formatDate(iso) {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
},
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -22,7 +22,33 @@
</header>
<main class="container grid">
- <section>
+ <section v-if="view==='login'">
+ <h2>Login</h2>
+ <form @submit.prevent="login">
+ <label>Username <input v-model.trim="auth.username" autocomplete="username" required /></label>
+ <label>Password <input v-model="auth.password" type="password" autocomplete="current-password" required /></label>
+ <div class="actions">
+ <button :disabled="busy">{{ busy ? 'Working…' : 'Login' }}</button>
+ </div>
+ <p v-if="error" class="error">{{ error }}</p>
+ <p class="auth-links">No account? <a href="#" @click.prevent="view='signup'">Create one</a></p>
+ </form>
+ </section>
+
+ <section v-if="view==='signup'">
+ <h2>Sign Up</h2>
+ <form @submit.prevent="signup">
+ <label>Username <input v-model.trim="auth.username" autocomplete="username" required /></label>
+ <label>Password <input v-model="auth.password" type="password" autocomplete="new-password" required /></label>
+ <div class="actions">
+ <button :disabled="busy">{{ busy ? 'Working…' : 'Create Account' }}</button>
+ </div>
+ <p v-if="error" class="error">{{ error }}</p>
+ <p class="auth-links">Have an account? <a href="#" @click.prevent="view='login'">Log in</a></p>
+ </form>
+ </section>
+
+ <section v-if="view==='self'">
<h2>Add Bookmark</h2>
<form @submit.prevent="createBookmark">
<label>
@@ -43,10 +69,11 @@
<button :disabled="busy">{{ busy ? 'Working…' : 'Save' }}</button>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="message" class="ok">{{ message }}</p>
+ <p class="auth-links"><a href="#" @click.prevent="logout">Logout</a></p>
</form>
</section>
- <section>
+ <section v-if="view==='self'">
<h2>Search</h2>
<form @submit.prevent="search">
<input v-model.trim="q" type="search" placeholder="Search…" />
@@ -81,6 +108,18 @@
</article>
</div>
</section>
+
+ <section v-if="view==='public'">
+ <h2>{{ viewUser }}’s Public Bookmarks</h2>
+ <div class="results">
+ <article v-for="b in results" :key="b.id" class="card">
+ <h3><a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a></h3>
+ <p class="url">{{ b.url }}</p>
+ <p class="meta">Updated: {{ formatDate(b.updated_at) }}</p>
+ <p v-if="b.description" class="desc">{{ b.description }}</p>
+ </article>
+ </div>
+ </section>
</main>
</div>
diff --git a/internal/web/styles.css b/internal/web/styles.css
@@ -40,3 +40,4 @@ button { padding: 8px 12px; font-size: 14px; border-radius: 6px; border: 1px sol
mark { background: #fff3b0; color: inherit; padding: 0 2px; border-radius: 2px; }
.meta { color: #777; font-size: 12px; margin: 8px 0 0 0; }
.desc { margin: 6px 0 0; color: #444; }
+.auth-links { margin-top: 8px; font-size: 13px; }