commit c4329c55cd755a82edcecf14ba5f08bc18f76fd6
parent 8928907e351e1e511ed35302fb0ef86ef48a170c
Author: David Eisinger <[email protected]>
Date: Fri, 10 Oct 2025 10:33:41 -0400
Add referrer to API endpoints
Diffstat:
4 files changed, 137 insertions(+), 80 deletions(-)
diff --git a/README.md b/README.md
@@ -73,7 +73,8 @@ Create a new bookmark.
{
"url": "https://example.com", // required
"title": "Example", // optional; fetched from page <title> if missing
- "description": "Optional description" // optional
+ "description": "Optional description", // optional
+ "referrer": "https://referrer.example" // optional; site that linked here
}
```
- Responses:
@@ -90,6 +91,7 @@ Create a new bookmark.
"url": "https://example.com",
"title": "Example",
"description": "Optional description",
+ "referrer": "https://referrer.example",
"created_at": "2025-01-01T12:34:56Z",
"updated_at": "2025-01-01T12:34:56Z"
}
diff --git a/internal/api.go b/internal/api.go
@@ -514,10 +514,12 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
title = req.URL
}
}
- pub := false
- if req.Public != nil {
- pub = *req.Public
- }
+ pub := false
+ if req.Public != nil {
+ pub = *req.Public
+ }
+ ref := ""
+ if req.Referrer != nil { ref = *req.Referrer }
if existing, err := db.GetBookmarkByURL(r.Context(), uid, req.URL); err != nil {
log.Printf("lookup by url error: %v", err)
@@ -547,8 +549,13 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
newPub = *req.Public
}
- // Apply metadata update first so archive reindex uses latest fields.
- updated, err := db.UpdateBookmark(r.Context(), existing.ID, existing.URL, newTitle, newDesc, newPub)
+ // Apply metadata update first so archive reindex uses latest fields.
+ // Include referrer when provided; otherwise preserve existing.
+ newRef := existing.Referrer.String
+ if req.Referrer != nil {
+ newRef = *req.Referrer
+ }
+ updated, err := db.UpdateBookmarkWithReferrer(r.Context(), existing.ID, existing.URL, newTitle, newDesc, newPub, newRef)
if err != nil || updated == nil {
if err != nil {
log.Printf("update existing bookmark error: %v", err)
@@ -567,20 +574,26 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
_ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, updated))
return
} else {
- var created *Bookmark
- if isHTML {
- created, err = db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
- } else {
- created, err = db.CreateBookmarkNoArchive(r.Context(), uid, req.URL, title, req.Description, pub)
- }
- 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(toRespWithArchive(r.Context(), db, created))
+ var created *Bookmark
+ if isHTML {
+ created, err = db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
+ } else {
+ created, err = db.CreateBookmarkNoArchive(r.Context(), uid, req.URL, title, req.Description, pub)
+ }
+ if err != nil {
+ log.Printf("create bookmark with archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if strings.TrimSpace(ref) != "" {
+ // Persist referrer via a focused update to avoid changing other fields
+ _, _ = db.sql.ExecContext(r.Context(), `UPDATE bookmarks SET referrer = ? WHERE id = ?`, ref, created.ID)
+ // Reload to include referrer in response
+ if b2, _ := db.GetBookmark(r.Context(), created.ID); b2 != nil { created = b2 }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, created))
}
case http.MethodGet:
@@ -996,38 +1009,46 @@ func isOwnerAuthorized(r *http.Request, u *User, db *DB) bool {
}
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"`
+ Referrer *string `json:"referrer,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"`
- HasArchive bool `json:"has_archive"`
+ 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"`
+ HasArchive bool `json:"has_archive"`
+ Referrer *string `json:"referrer,omitempty"`
}
func toResp(b *Bookmark) bookmarkResponse {
- var desc *string
- 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,
- }
+ var desc *string
+ if b.Description.Valid {
+ desc = &b.Description.String
+ }
+ var ref *string
+ if b.Referrer.Valid && strings.TrimSpace(b.Referrer.String) != "" {
+ s := b.Referrer.String
+ ref = &s
+ }
+ return bookmarkResponse{
+ ID: b.ID,
+ URL: b.URL,
+ Title: b.Title,
+ Description: desc,
+ CreatedAt: b.CreatedAt,
+ UpdatedAt: b.UpdatedAt,
+ Public: b.Public,
+ Referrer: ref,
+ }
}
func toRespWithArchive(ctx context.Context, db *DB, b *Bookmark) bookmarkResponse {
diff --git a/internal/database.go b/internal/database.go
@@ -22,14 +22,15 @@ 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
+ Referrer sql.NullString
+ Public bool
+ UserID int64
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// SearchResult is a bookmark match with an optional excerpt snippet.
@@ -178,7 +179,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))
@@ -194,9 +195,9 @@ func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title strin
if c, _, ok, _ := db.GetArchive(ctx, id); ok {
ftsContent = c
}
- if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil {
- return nil, fmt.Errorf("fts replace after create: %w", err)
- }
+ 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,
@@ -252,9 +253,9 @@ func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
var b Bookmark
var created, updated string
if err := db.sql.QueryRowContext(ctx, `
- SELECT id, url, title, description, public, user_id, created_at, updated_at
+ SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
FROM bookmarks WHERE id = ?
- `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
+ `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -275,9 +276,9 @@ func (db *DB) GetBookmarkByURL(ctx context.Context, userID int64, u string) (*Bo
var b Bookmark
var created, updated string
if err := db.sql.QueryRowContext(ctx, `
- SELECT id, url, title, description, public, user_id, created_at, updated_at
+ SELECT id, url, title, description, referrer, 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 {
+ `, u, userID).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -299,7 +300,7 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int
limit = 50
}
rows, err := db.sql.QueryContext(ctx, `
- SELECT id, url, title, description, public, user_id, created_at, updated_at
+ SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
FROM bookmarks
WHERE user_id = ?
ORDER BY updated_at DESC
@@ -312,10 +313,10 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int
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 bookmark: %w", err)
- }
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &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)
}
@@ -370,6 +371,39 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d
return db.GetBookmark(ctx, id)
}
+// UpdateBookmarkWithReferrer updates fields including referrer for a given bookmark.
+func (db *DB) UpdateBookmarkWithReferrer(ctx context.Context, id int64, url, title string, description *string, public bool, referrer string) (*Bookmark, error) {
+ if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
+ return nil, fmt.Errorf("url and title are required")
+ }
+ now := time.Now().UTC()
+ var desc sql.NullString
+ if description != nil {
+ desc = sql.NullString{String: *description, Valid: true}
+ }
+ _, err := db.sql.ExecContext(ctx, `
+ UPDATE bookmarks
+ SET url = ?, title = ?, description = ?, public = ?, referrer = ?, updated_at = ?
+ WHERE id = ?
+ `, url, title, nullString(desc), publicToInt(public), referrer, now.Format(time.RFC3339Nano), id)
+ if err != nil {
+ return nil, fmt.Errorf("update bookmark with referrer: %w", err)
+ }
+ // Update FTS metadata while preserving content
+ 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
+ }
+ if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
+ return nil, fmt.Errorf("fts replace after update: %w", err)
+ }
+ return db.GetBookmark(ctx, id)
+}
+
// TouchBookmark updates only the updated_at timestamp.
func (db *DB) TouchBookmark(ctx context.Context, id int64) error {
_, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339Nano), id)
@@ -591,8 +625,8 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer
if limit <= 0 {
limit = 50
}
- 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
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT b.id, b.url, b.title, b.description, b.referrer, b.public, b.user_id, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
WHERE bookmarks_fts MATCH ? AND b.user_id = ?
@@ -606,10 +640,10 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer
out := make([]SearchResult, 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 fts row: %w", err)
- }
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &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/user_db.go b/internal/user_db.go
@@ -103,8 +103,8 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs
if limit <= 0 {
limit = 50
}
- rows, err := db.sql.QueryContext(ctx, `
- SELECT id, url, title, description, public, user_id, created_at, updated_at
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
FROM bookmarks WHERE user_id = ? AND public = 1
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
@@ -116,10 +116,10 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs
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 created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &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