marky

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

commit db82d83e41cfcf97e8b1162c714ee7cea753efc0
parent ed8876859c30b8e0f63ba5732b10ecbf254ef755
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 23:06:31 -0400

Formatting

Diffstat:
Minternal/api.go | 622+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Minternal/auth.go | 101+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Minternal/auth_e2e_test.go | 256++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Minternal/database.go | 114++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/database_test.go | 56+++++++++++++++++++++++++++++++-------------------------
Minternal/e2e_test.go | 32++++++++++++++++----------------
Minternal/static.go | 64++++++++++++++++++++++++++++++++--------------------------------
Minternal/user.go | 31+++++++++++++++----------------
Minternal/user_db.go | 210+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
9 files changed, 813 insertions(+), 673 deletions(-)

diff --git a/internal/api.go b/internal/api.go @@ -15,170 +15,218 @@ 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() - // 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 := userIDFromRequest(r, db); 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) - }) + // 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 := userIDFromRequest(r, db); 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) + }) - // API token endpoints for logged-in user - 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.MethodGet: - reveal := strings.EqualFold(r.URL.Query().Get("reveal"), "1") || strings.EqualFold(r.URL.Query().Get("reveal"), "true") - tok, present, err := db.GetAPIToken(r.Context(), uid) - if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError); return } - w.Header().Set("Content-Type", "application/json") - if !present { - _ = json.NewEncoder(w).Encode(map[string]any{"present": false}) - return - } - if reveal { - _ = json.NewEncoder(w).Encode(map[string]any{"present": true, "api_token": tok}) - } else { - _ = json.NewEncoder(w).Encode(map[string]any{"present": true}) - } - 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 endpoints for logged-in user + 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.MethodGet: + reveal := strings.EqualFold(r.URL.Query().Get("reveal"), "1") || strings.EqualFold(r.URL.Query().Get("reveal"), "true") + tok, present, err := db.GetAPIToken(r.Context(), uid) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if !present { + _ = json.NewEncoder(w).Encode(map[string]any{"present": false}) + return + } + if reveal { + _ = json.NewEncoder(w).Encode(map[string]any{"present": true, "api_token": tok}) + } else { + _ = json.NewEncoder(w).Encode(map[string]any{"present": true}) + } + 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) { - 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) - }) + // 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 "/" with user feeds overlay (/{username}.json and /{username}.rss) - static, err := StaticHandler() - if err != nil { return nil, err } - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - p := strings.TrimPrefix(r.URL.Path, "/") - if p != "" { - if strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss") { - // Extract username and format - format := "json" - if strings.HasSuffix(p, ".rss") { format = "rss" } - name := p - if format == "json" { name = strings.TrimSuffix(name, ".json") } else { name = strings.TrimSuffix(name, ".rss") } - // deny nested paths - if name != "" && !strings.Contains(name, "/") { - if u, err := db.GetUserByUsername(r.Context(), name); err == nil && u != nil { - // Show private items if this is the owner's feed (session, basic, header token, or query token) - var items []Bookmark - if isOwnerAuthorized(r, u, db) { - items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) - } else { - items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) - } - if err != nil { 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])) } - if format == "json" { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - } else { - w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") - _ = writeRSS(w, r, resp) - } - return - } - } - } - } - static.ServeHTTP(w, r) - }) + // Static web UI at "/" with user feeds overlay (/{username}.json and /{username}.rss) + static, err := StaticHandler() + if err != nil { + return nil, err + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if p != "" { + if strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss") { + // Extract username and format + format := "json" + if strings.HasSuffix(p, ".rss") { + format = "rss" + } + name := p + if format == "json" { + name = strings.TrimSuffix(name, ".json") + } else { + name = strings.TrimSuffix(name, ".rss") + } + // deny nested paths + if name != "" && !strings.Contains(name, "/") { + if u, err := db.GetUserByUsername(r.Context(), name); err == nil && u != nil { + // Show private items if this is the owner's feed (session, basic, header token, or query token) + var items []Bookmark + if isOwnerAuthorized(r, u, db) { + items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) + } else { + items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) + } + if err != nil { + 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])) + } + if format == "json" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } else { + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + _ = writeRSS(w, r, resp) + } + return + } + } + } + } + static.ServeHTTP(w, r) + }) // Shared handler for JSON and RSS bookmarksHandler := func(w http.ResponseWriter, r *http.Request) { switch r.Method { - case http.MethodPost: - uid, ok := userIDFromRequest(r, db) - if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } + case http.MethodPost: + uid, ok := userIDFromRequest(r, db) + 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" { @@ -211,20 +259,20 @@ func NewHandler(db *DB) (http.Handler, error) { http.Error(w, "failed to archive URL", http.StatusBadGateway) return } - title := req.Title - if strings.TrimSpace(title) == "" { - if strings.TrimSpace(fetchedTitle) != "" { - title = fetchedTitle - } else { - title = req.URL - } - } - pub := false - if req.Public != nil { - pub = *req.Public - } + title := req.Title + if strings.TrimSpace(title) == "" { + if strings.TrimSpace(fetchedTitle) != "" { + title = fetchedTitle + } else { + title = req.URL + } + } + pub := false + if req.Public != nil { + pub = *req.Public + } - if existing, err := db.GetBookmarkByURL(r.Context(), uid, 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 @@ -250,21 +298,21 @@ func NewHandler(db *DB) (http.Handler, error) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(toResp(updated)) return - } else { - 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) - return - } + } else { + 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) + return + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(toResp(created)) } - case http.MethodGet: - uid, _ := userIDFromRequest(r, db) - q := r.URL.Query() + case http.MethodGet: + uid, _ := userIDFromRequest(r, db) + q := r.URL.Query() limit := 50 offset := 0 query := strings.TrimSpace(q.Get("q")) @@ -289,8 +337,8 @@ func NewHandler(db *DB) (http.Handler, error) { } var resp []bookmarkResponse - if query != "" { - hits, err := db.SearchBookmarksWithExcerpt(r.Context(), uid, 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) @@ -306,8 +354,8 @@ func NewHandler(db *DB) (http.Handler, error) { } resp = append(resp, br) } - } else { - items, err := db.ListBookmarks(r.Context(), uid, 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) @@ -341,7 +389,7 @@ func NewHandler(db *DB) (http.Handler, error) { mux.HandleFunc("/bookmarks.xml", bookmarksHandler) // Archive, PUT, DELETE under /bookmarks/{id} - mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path rest := strings.TrimPrefix(path, "/bookmarks/") if rest == "" || strings.HasPrefix(rest, "/") { @@ -416,59 +464,65 @@ func NewHandler(db *DB) (http.Handler, error) { return } switch r.Method { - case http.MethodPut: - if _, ok := userIDFromRequest(r, db); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - var req createBookmarkRequest - if err := decoder.Decode(&req); err != nil { - http.Error(w, "invalid JSON payload", http.StatusBadRequest) - return - } - if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" { - http.Error(w, "url and title are required", http.StatusBadRequest) - return - } - desc := req.Description - var pub bool - // Fetch existing if we need to preserve fields - if desc == nil || req.Public == nil { - existing, err := db.GetBookmark(r.Context(), id64) - if err != nil { - log.Printf("get bookmark error: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - if existing == nil { - http.NotFound(w, r) - return - } - if desc == nil && existing.Description.Valid { - d := existing.Description.String - desc = &d - } - if req.Public == nil { - pub = existing.Public - } - } - if req.Public != nil { - pub = *req.Public - } - updated, err := db.UpdateBookmark(r.Context(), id64, req.URL, req.Title, desc, pub) - if err != nil { - log.Printf("update bookmark error: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } + case http.MethodPut: + if _, ok := userIDFromRequest(r, db); !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var req createBookmarkRequest + if err := decoder.Decode(&req); err != nil { + http.Error(w, "invalid JSON payload", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" { + http.Error(w, "url and title are required", http.StatusBadRequest) + return + } + desc := req.Description + var pub bool + // Fetch existing if we need to preserve fields + if desc == nil || req.Public == nil { + existing, err := db.GetBookmark(r.Context(), id64) + if err != nil { + log.Printf("get bookmark error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if existing == nil { + http.NotFound(w, r) + return + } + if desc == nil && existing.Description.Valid { + d := existing.Description.String + desc = &d + } + if req.Public == nil { + pub = existing.Public + } + } + if req.Public != nil { + pub = *req.Public + } + updated, err := db.UpdateBookmark(r.Context(), id64, req.URL, req.Title, desc, pub) + if err != nil { + log.Printf("update bookmark error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } if updated == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(toResp(updated)) - case http.MethodDelete: - if _, ok := userIDFromRequest(r, db); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } - ok, err := db.DeleteBookmark(r.Context(), id64) + case http.MethodDelete: + if _, ok := userIDFromRequest(r, db); !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + ok, err := db.DeleteBookmark(r.Context(), id64) if err != nil { log.Printf("delete bookmark error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -485,68 +539,68 @@ func NewHandler(db *DB) (http.Handler, error) { } }) - return mux, nil + return mux, nil } // userIDFromRequest resolves the user ID using either a signed session cookie // or HTTP Basic auth where the username is the account username and the // password is the API token. func userIDFromRequest(r *http.Request, db *DB) (int64, bool) { - if uid, ok := CurrentUserID(r); ok { - return uid, true - } - if u, p, ok := r.BasicAuth(); ok { - if u == "" || p == "" { - return 0, false - } - usr, err := db.GetUserByUsername(r.Context(), u) - if err != nil || usr == nil { - return 0, false - } - if usr.APIToken != nil && *usr.APIToken == p { - return usr.ID, true - } - } - return 0, false + if uid, ok := CurrentUserID(r); ok { + return uid, true + } + if u, p, ok := r.BasicAuth(); ok { + if u == "" || p == "" { + return 0, false + } + usr, err := db.GetUserByUsername(r.Context(), u) + if err != nil || usr == nil { + return 0, false + } + if usr.APIToken != nil && *usr.APIToken == p { + return usr.ID, true + } + } + return 0, false } // isOwnerAuthorized checks if the request authenticates as the given user via // (1) session cookie, (2) HTTP Basic (username + API token), (3) X-API-Token // header, or (4) token query parameter. func isOwnerAuthorized(r *http.Request, u *User, db *DB) bool { - if uid, ok := CurrentUserID(r); ok && uid == u.ID { - return true - } - if user, pass, ok := r.BasicAuth(); ok { - if user == u.Username && u.APIToken != nil && *u.APIToken == pass { - return true - } - } - if tok := r.Header.Get("X-API-Token"); tok != "" && u.APIToken != nil && *u.APIToken == tok { - return true - } - if tok := r.URL.Query().Get("token"); tok != "" && u.APIToken != nil && *u.APIToken == tok { - return true - } - return false + if uid, ok := CurrentUserID(r); ok && uid == u.ID { + return true + } + if user, pass, ok := r.BasicAuth(); ok { + if user == u.Username && u.APIToken != nil && *u.APIToken == pass { + return true + } + } + if tok := r.Header.Get("X-API-Token"); tok != "" && u.APIToken != nil && *u.APIToken == tok { + return true + } + if tok := r.URL.Query().Get("token"); tok != "" && u.APIToken != nil && *u.APIToken == tok { + return true + } + return false } type createBookmarkRequest struct { - URL string `json:"url"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - Public *bool `json:"public,omitempty"` + URL string `json:"url"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Public *bool `json:"public,omitempty"` } type bookmarkResponse struct { - ID int64 `json:"id"` - URL string `json:"url"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Excerpt *string `json:"excerpt,omitempty"` - Public bool `json:"public"` + ID int64 `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Excerpt *string `json:"excerpt,omitempty"` + Public bool `json:"public"` } func toResp(b *Bookmark) bookmarkResponse { @@ -554,15 +608,15 @@ func toResp(b *Bookmark) bookmarkResponse { if b.Description.Valid { desc = &b.Description.String } - return bookmarkResponse{ - ID: b.ID, - URL: b.URL, - Title: b.Title, - Description: desc, - CreatedAt: b.CreatedAt, - UpdatedAt: b.UpdatedAt, - Public: b.Public, - } + return bookmarkResponse{ + ID: b.ID, + URL: b.URL, + Title: b.Title, + Description: desc, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + Public: b.Public, + } } // RSS structures and writer diff --git a/internal/auth.go b/internal/auth.go @@ -1,70 +1,79 @@ package internal import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "net/http" - "os" - "strconv" - "strings" - "time" + "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") - } + 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), - }) + 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}) + 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 + 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]) + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(s)) + sum := mac.Sum(nil) + return base64.StdEncoding.EncodeToString(sum[:16]) } - diff --git a/internal/auth_e2e_test.go b/internal/auth_e2e_test.go @@ -1,125 +1,165 @@ package internal import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "regexp" - "testing" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "regexp" + "testing" ) func TestFeedsAuthWithToken(t *testing.T) { - t.Parallel() - - // Content server to satisfy archiver - page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write([]byte(`<!doctype html><html><head><title>Feed Test</title></head><body><p>content</p></body></html>`)) - })) - defer page.Close() - - // App server - dir := t.TempDir() - db, err := OpenAtPath(t.Context(), dir+"/auth.db") - if err != nil { t.Fatalf("open db: %v", err) } - t.Cleanup(func(){ _ = db.Close() }) - h, err := NewHandler(db) - if err != nil { t.Fatalf("new handler: %v", err) } - srv := httptest.NewServer(h) - defer srv.Close() - - // Authenticated client (cookie jar) - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - - // Sign up user - postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/signup", map[string]string{"username":"alice","password":"pw"}, 200) - - // Create one public and one private bookmark - postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL, "public": true, "title":"Pub"}, 201) - postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL+"/p", "public": false, "title":"Priv"}, 201) - - // Unauthenticated caller sees only public entries in /alice.json - pubOnly := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json", 200) - if len(pubOnly) != 1 { t.Fatalf("expected 1 public item, got %d", len(pubOnly)) } - - // Generate API token - tokResp := postJSONWithClientAuth[map[string]string](t, client, srv.URL+"/api/token", nil, 200) - token := tokResp["api_token"] - if token == "" { t.Fatalf("empty api token") } - - // With header token, see both - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil) - req.Header.Set("X-API-Token", token) - res, err := http.DefaultClient.Do(req) - if err != nil { t.Fatalf("get header token: %v", err) } - defer res.Body.Close() - if res.StatusCode != 200 { t.Fatalf("status %d", res.StatusCode) } - var both []map[string]any - _ = json.NewDecoder(res.Body).Decode(&both) - if len(both) != 2 { t.Fatalf("expected 2 items with header token, got %d", len(both)) } - - // With query token, see both - both2 := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json?token="+token, 200) - if len(both2) != 2 { t.Fatalf("expected 2 items with query token, got %d", len(both2)) } - - // With Basic auth, see both - req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil) - req2.SetBasicAuth("alice", token) - res2, err := http.DefaultClient.Do(req2) - if err != nil { t.Fatalf("get basic: %v", err) } - defer res2.Body.Close() - if res2.StatusCode != 200 { t.Fatalf("status %d", res2.StatusCode) } - var both3 []map[string]any - _ = json.NewDecoder(res2.Body).Decode(&both3) - if len(both3) != 2 { t.Fatalf("expected 2 items with basic, got %d", len(both3)) } - - // RSS unauthenticated: only public (<item> once) - r1, _ := http.Get(srv.URL+"/alice.rss") - b1, _ := io.ReadAll(r1.Body); _ = r1.Body.Close() - if c := countRSSItems(b1); c != 1 { t.Fatalf("expected 1 RSS item unauth, got %d", c) } - // RSS with token header: both - rreq, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.rss", nil) - rreq.Header.Set("X-API-Token", token) - rres, _ := http.DefaultClient.Do(rreq) - rb, _ := io.ReadAll(rres.Body); _ = rres.Body.Close() - if c := countRSSItems(rb); c != 2 { t.Fatalf("expected 2 RSS items with token, got %d", c) } + t.Parallel() + + // Content server to satisfy archiver + page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`<!doctype html><html><head><title>Feed Test</title></head><body><p>content</p></body></html>`)) + })) + defer page.Close() + + // App server + dir := t.TempDir() + db, err := OpenAtPath(t.Context(), dir+"/auth.db") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + h, err := NewHandler(db) + if err != nil { + t.Fatalf("new handler: %v", err) + } + srv := httptest.NewServer(h) + defer srv.Close() + + // Authenticated client (cookie jar) + jar, _ := cookiejar.New(nil) + client := &http.Client{Jar: jar} + + // Sign up user + postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/signup", map[string]string{"username": "alice", "password": "pw"}, 200) + + // Create one public and one private bookmark + postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL, "public": true, "title": "Pub"}, 201) + postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL + "/p", "public": false, "title": "Priv"}, 201) + + // Unauthenticated caller sees only public entries in /alice.json + pubOnly := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json", 200) + if len(pubOnly) != 1 { + t.Fatalf("expected 1 public item, got %d", len(pubOnly)) + } + + // Generate API token + tokResp := postJSONWithClientAuth[map[string]string](t, client, srv.URL+"/api/token", nil, 200) + token := tokResp["api_token"] + if token == "" { + t.Fatalf("empty api token") + } + + // With header token, see both + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil) + req.Header.Set("X-API-Token", token) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get header token: %v", err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + t.Fatalf("status %d", res.StatusCode) + } + var both []map[string]any + _ = json.NewDecoder(res.Body).Decode(&both) + if len(both) != 2 { + t.Fatalf("expected 2 items with header token, got %d", len(both)) + } + + // With query token, see both + both2 := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json?token="+token, 200) + if len(both2) != 2 { + t.Fatalf("expected 2 items with query token, got %d", len(both2)) + } + + // With Basic auth, see both + req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil) + req2.SetBasicAuth("alice", token) + res2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("get basic: %v", err) + } + defer res2.Body.Close() + if res2.StatusCode != 200 { + t.Fatalf("status %d", res2.StatusCode) + } + var both3 []map[string]any + _ = json.NewDecoder(res2.Body).Decode(&both3) + if len(both3) != 2 { + t.Fatalf("expected 2 items with basic, got %d", len(both3)) + } + + // RSS unauthenticated: only public (<item> once) + r1, _ := http.Get(srv.URL + "/alice.rss") + b1, _ := io.ReadAll(r1.Body) + _ = r1.Body.Close() + if c := countRSSItems(b1); c != 1 { + t.Fatalf("expected 1 RSS item unauth, got %d", c) + } + // RSS with token header: both + rreq, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.rss", nil) + rreq.Header.Set("X-API-Token", token) + rres, _ := http.DefaultClient.Do(rreq) + rb, _ := io.ReadAll(rres.Body) + _ = rres.Body.Close() + if c := countRSSItems(rb); c != 2 { + t.Fatalf("expected 2 RSS items with token, got %d", c) + } } func countRSSItems(b []byte) int { - re := regexp.MustCompile(`<item\b`) - return len(re.FindAllIndex(b, -1)) + re := regexp.MustCompile(`<item\b`) + return len(re.FindAllIndex(b, -1)) } // Helpers copied/adapted from e2e tests func postJSONWithClientAuth[T any](t *testing.T, client *http.Client, url string, v any, want int) T { - t.Helper() - var body io.Reader - if v != nil { - b, _ := json.Marshal(v) - body = bytes.NewReader(b) - } - req, _ := http.NewRequest(http.MethodPost, url, body) - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { t.Fatalf("post json: %v", err) } - defer resp.Body.Close() - if resp.StatusCode != want { t.Fatalf("POST %s: got %d want %d", url, resp.StatusCode, want) } - var out T - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } - return out + t.Helper() + var body io.Reader + if v != nil { + b, _ := json.Marshal(v) + body = bytes.NewReader(b) + } + req, _ := http.NewRequest(http.MethodPost, url, body) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("post json: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("POST %s: got %d want %d", url, resp.StatusCode, want) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + return out } func getJSONWithClientAuth[T any](t *testing.T, client *http.Client, url string, want int) T { - t.Helper() - resp, err := client.Get(url) - if err != nil { t.Fatalf("get: %v", err) } - defer resp.Body.Close() - if resp.StatusCode != want { t.Fatalf("GET %s: got %d want %d", url, resp.StatusCode, want) } - var out T - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } - return out + t.Helper() + resp, err := client.Get(url) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("GET %s: got %d want %d", url, resp.StatusCode, want) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + return out } diff --git a/internal/database.go b/internal/database.go @@ -21,14 +21,14 @@ var migrationsFS embed.FS // Bookmark represents a single bookmark row. type Bookmark struct { - ID int64 - URL string - Title string - Description sql.NullString - Public bool - UserID int64 - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + URL string + Title string + Description sql.NullString + Public bool + UserID int64 + CreatedAt time.Time + UpdatedAt time.Time } // SearchResult is a bookmark match with an optional excerpt snippet. @@ -177,7 +177,7 @@ func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title strin if description != nil { desc = sql.NullString{String: *description, Valid: true} } - res, err := db.sql.ExecContext(ctx, ` + res, err := db.sql.ExecContext(ctx, ` 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)) @@ -196,23 +196,23 @@ func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title strin if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil { return nil, fmt.Errorf("fts replace after create: %w", err) } - return &Bookmark{ - ID: id, - URL: url, - Title: title, - Description: desc, - Public: false, - UserID: userID, - CreatedAt: now, - UpdatedAt: now, - }, nil + return &Bookmark{ + ID: id, + URL: url, + Title: title, + Description: desc, + Public: false, + UserID: userID, + CreatedAt: now, + UpdatedAt: now, + }, nil } // GetBookmark returns a bookmark by ID. func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) { var b Bookmark var created, updated string - if err := db.sql.QueryRowContext(ctx, ` + if err := db.sql.QueryRowContext(ctx, ` SELECT id, url, title, description, public, created_at, updated_at FROM bookmarks WHERE id = ? `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil { @@ -235,7 +235,7 @@ func (db *DB) GetBookmark(ctx context.Context, id int64) (*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, ` + if err := db.sql.QueryRowContext(ctx, ` 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 { @@ -259,7 +259,7 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int if limit <= 0 { limit = 50 } - rows, err := db.sql.QueryContext(ctx, ` + rows, err := db.sql.QueryContext(ctx, ` SELECT id, url, title, description, public, user_id, created_at, updated_at FROM bookmarks WHERE user_id = ? @@ -274,9 +274,9 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int 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 bookmark: %w", err) - } + 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 { return nil, fmt.Errorf("parse created_at: %w", err) } @@ -301,7 +301,7 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d if description != nil { desc = sql.NullString{String: *description, Valid: true} } - res, err := db.sql.ExecContext(ctx, ` + res, err := db.sql.ExecContext(ctx, ` UPDATE bookmarks SET url = ?, title = ?, description = ?, public = ?, updated_at = ? WHERE id = ? @@ -317,10 +317,10 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d return nil, nil } // Update FTS metadata while preserving content - var descForFTS sql.NullString - if description != nil { - descForFTS = sql.NullString{String: *description, Valid: true} - } + var descForFTS sql.NullString + if description != nil { + descForFTS = sql.NullString{String: *description, Valid: true} + } ftsContent := "" if c, _, ok, _ := db.GetArchive(ctx, id); ok { ftsContent = c @@ -359,17 +359,17 @@ func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) { } func nullString(ns sql.NullString) any { - if ns.Valid { - return ns.String - } - return nil + if ns.Valid { + return ns.String + } + return nil } func publicToInt(v bool) int { - if v { - return 1 - } - return 0 + if v { + return 1 + } + return 0 } // CreateBookmarkWithArchive creates a bookmark and saves its plaintext archive @@ -388,7 +388,7 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, userID int64, url, if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } - res, err := tx.ExecContext(ctx, ` + res, err := tx.ExecContext(ctx, ` 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)) @@ -416,16 +416,16 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, userID int64, url, if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit: %w", err) } - return &Bookmark{ - ID: id, - URL: url, - Title: title, - Description: desc, - Public: public, - UserID: userID, - CreatedAt: now, - UpdatedAt: now, - }, nil + return &Bookmark{ + ID: id, + URL: url, + Title: title, + Description: desc, + Public: public, + UserID: userID, + CreatedAt: now, + UpdatedAt: now, + }, nil } // SaveArchive upserts the plaintext archive content for a bookmark. @@ -480,7 +480,7 @@ func (db *DB) SearchBookmarks(ctx context.Context, userID int64, query string, l if limit <= 0 { limit = 50 } - rows, err := db.sql.QueryContext(ctx, ` + rows, err := db.sql.QueryContext(ctx, ` 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 @@ -496,9 +496,9 @@ func (db *DB) SearchBookmarks(ctx context.Context, userID int64, query string, l 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 fts row: %w", err) - } + 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 { return nil, fmt.Errorf("parse created_at: %w", err) } @@ -519,7 +519,7 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer if limit <= 0 { limit = 50 } - rows, err := db.sql.QueryContext(ctx, ` + rows, err := db.sql.QueryContext(ctx, ` 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 @@ -535,9 +535,9 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer 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 fts row: %w", err) - } + 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 { return nil, fmt.Errorf("parse created_at: %w", err) } diff --git a/internal/database_test.go b/internal/database_test.go @@ -18,12 +18,14 @@ func TestMigrationsCRUDAndSearch(t *testing.T) { } defer db.Close() - // 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.") + // 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) } @@ -41,7 +43,7 @@ func TestMigrationsCRUDAndSearch(t *testing.T) { } // Search with excerpt - hits, err := db.SearchBookmarksWithExcerpt(ctx, uid, "durham", 10, 0) + hits, err := db.SearchBookmarksWithExcerpt(ctx, uid, "durham", 10, 0) if err != nil { t.Fatalf("search: %v", err) } @@ -57,7 +59,7 @@ func TestMigrationsCRUDAndSearch(t *testing.T) { if err != nil || !okDel { t.Fatalf("delete: ok=%v err=%v", okDel, err) } - hits, err = db.SearchBookmarksWithExcerpt(ctx, uid, "durham", 10, 0) + hits, err = db.SearchBookmarksWithExcerpt(ctx, uid, "durham", 10, 0) if err != nil { t.Fatalf("search after delete: %v", err) } @@ -77,16 +79,18 @@ func TestUniqueURL(t *testing.T) { } defer db.Close() - // 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") - } + // 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) { @@ -100,16 +104,18 @@ func TestFTSIndexUpdatesOnArchive(t *testing.T) { } defer db.Close() - // 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) + // 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, uid, "needle", 5, 0) + hits, err := db.SearchBookmarksWithExcerpt(ctx, uid, "needle", 5, 0) if err != nil { t.Fatalf("search pre-archive: %v", err) } @@ -120,7 +126,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, uid, "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 @@ -5,8 +5,8 @@ import ( "encoding/json" "io" "net/http" - "net/http/httptest" "net/http/cookiejar" + "net/http/httptest" "strings" "testing" ) @@ -34,18 +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) + // 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 := postJSONWithClient[map[string]any](t, client, 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) @@ -57,7 +57,7 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) { id := int(idFloat) // 2) RSS feed - resp := getWithClient(t, client, 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) } @@ -68,7 +68,7 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) { } // 3) Search JSON with excerpt - list := getJSONWithClient[[]map[string]any](t, client, 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)) } @@ -77,7 +77,7 @@ func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) { } // 4) GET archive - arch := getWithClient(t, client, 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")) { @@ -86,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 := putJSONWithClient[map[string]any](t, client, 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 - delWithClient(t, client, srv.URL+"/bookmarks/"+itoa(id), 204) - list2 := getJSONWithClient[[]map[string]any](t, client, 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)) } diff --git a/internal/static.go b/internal/static.go @@ -1,11 +1,11 @@ package internal import ( - "embed" - "io/fs" - "net/http" - "path" - "strings" + "embed" + "io/fs" + "net/http" + "path" + "strings" ) //go:embed web/* @@ -14,31 +14,31 @@ var webAssets embed.FS // 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 - } - 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 + 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.go b/internal/user.go @@ -1,30 +1,29 @@ package internal import ( - "errors" + "errors" ) // User represents an application user. // The username must be unique and contain only lowercase letters and digits. type User struct { - ID int64 - Username string - EncryptedPassword string - APIToken *string + ID int64 + Username string + EncryptedPassword string + APIToken *string } // ValidateUsername verifies that the username is non-empty and contains only // lowercase ASCII letters and digits. func ValidateUsername(u string) error { - if u == "" { - return errors.New("username is required") - } - for i := 0; i < len(u); i++ { - c := u[i] - if (c < 'a' || c > 'z') && (c < '0' || c > '9') { - return errors.New("username must be lowercase letters and digits only") - } - } - return nil + if u == "" { + return errors.New("username is required") + } + for i := 0; i < len(u); i++ { + c := u[i] + if (c < 'a' || c > 'z') && (c < '0' || c > '9') { + return errors.New("username must be lowercase letters and digits only") + } + } + return nil } - diff --git a/internal/user_db.go b/internal/user_db.go @@ -1,132 +1,164 @@ package internal import ( - "context" - "database/sql" - "encoding/base64" - "crypto/rand" - "fmt" - "time" + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "time" - "golang.org/x/crypto/bcrypt" + "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, ` + 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 + 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 + 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, ` + 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 + 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, ` + 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 + 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, ` + 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 + 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 } // GetAPIToken returns the user's API token if set. func (db *DB) GetAPIToken(ctx context.Context, userID int64) (string, bool, error) { - var tok sql.NullString - if err := db.sql.QueryRowContext(ctx, `SELECT api_token FROM users WHERE id = ?`, userID).Scan(&tok); err != nil { - if err == sql.ErrNoRows { return "", false, nil } - return "", false, fmt.Errorf("get api token: %w", err) - } - if tok.Valid && tok.String != "" { - return tok.String, true, nil - } - return "", false, nil + var tok sql.NullString + if err := db.sql.QueryRowContext(ctx, `SELECT api_token FROM users WHERE id = ?`, userID).Scan(&tok); err != nil { + if err == sql.ErrNoRows { + return "", false, nil + } + return "", false, fmt.Errorf("get api token: %w", err) + } + if tok.Valid && tok.String != "" { + return tok.String, true, nil + } + return "", false, nil } // RegenerateAPIToken generates a new unique API token for the user and stores it. func (db *DB) RegenerateAPIToken(ctx context.Context, userID int64) (string, error) { - // Try a few times in case of uniqueness collision - for i := 0; i < 5; i++ { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("rand: %w", err) - } - token := base64.RawURLEncoding.EncodeToString(b) - now := time.Now().UTC().Format(time.RFC3339Nano) - _, err := db.sql.ExecContext(ctx, `UPDATE users SET api_token = ?, updated_at = ? WHERE id = ?`, token, now, userID) - if err == nil { - return token, nil - } - // If unique conflict, retry - } - return "", fmt.Errorf("failed to generate unique api token after retries") + // Try a few times in case of uniqueness collision + for i := 0; i < 5; i++ { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(b) + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := db.sql.ExecContext(ctx, `UPDATE users SET api_token = ?, updated_at = ? WHERE id = ?`, token, now, userID) + if err == nil { + return token, nil + } + // If unique conflict, retry + } + return "", fmt.Errorf("failed to generate unique api token after retries") }