marky

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

commit 3294091290e07bee9d5f9fdace3aebce016c9b23
parent a40f5b88b37468af2b01bb41187829c49d159730
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 22:09:28 -0400

logged-in/-out views working

Diffstat:
Mgo.mod | 5++++-
Mgo.sum | 2++
Minternal/api.go | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Ainternal/auth.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/database.go | 56++++++++++++++++++++++++++++++--------------------------
Minternal/database_test.go | 38+++++++++++++++++++++++++-------------
Minternal/e2e_test.go | 47+++++++++++++++++++++++++++--------------------
Ainternal/migrations/0007_bookmarks_user.sql | 7+++++++
Minternal/static.go | 43++++++++++++++++++++++++++++++++++---------
Ainternal/user_db.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/app.js | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Minternal/web/index.html | 43+++++++++++++++++++++++++++++++++++++++++--
Minternal/web/styles.css | 1+
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; }