commit db82d83e41cfcf97e8b1162c714ee7cea753efc0
parent ed8876859c30b8e0f63ba5732b10ecbf254ef755
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 23:06:31 -0400
Formatting
Diffstat:
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")
}