commit 182840e210c0863d6ad3d46b2ac488b93c370670
parent db82d83e41cfcf97e8b1162c714ee7cea753efc0
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 23:19:34 -0400
Auth for archive endpoints
Diffstat:
3 files changed, 88 insertions(+), 58 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -388,64 +388,56 @@ func NewHandler(db *DB) (http.Handler, error) {
mux.HandleFunc("/bookmarks", bookmarksHandler)
mux.HandleFunc("/bookmarks.xml", bookmarksHandler)
- // Archive, PUT, DELETE under /bookmarks/{id}
- mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
- rest := strings.TrimPrefix(path, "/bookmarks/")
- if rest == "" || strings.HasPrefix(rest, "/") {
- http.NotFound(w, r)
- return
- }
- if strings.HasSuffix(rest, "/archive") {
- idPart := strings.TrimSuffix(rest, "/archive")
- idPart = strings.TrimSuffix(idPart, "/")
- id64, err := strconv.ParseInt(idPart, 10, 64)
- if err != nil || id64 <= 0 {
- http.Error(w, "invalid id", http.StatusBadRequest)
- return
- }
- switch r.Method {
- case http.MethodGet:
- content, _, ok, err := db.GetArchive(r.Context(), id64)
- if err != nil {
- log.Printf("get archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !ok {
- if r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true" {
- b, err := db.GetBookmark(r.Context(), id64)
- if err != nil {
- log.Printf("lookup bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if b == nil {
- http.NotFound(w, r)
- return
- }
- ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
- defer cancel()
- txt, err := ArchiveToText(ctx, b.URL)
- if err != nil {
- http.Error(w, "failed to fetch archive", http.StatusBadGateway)
- return
- }
- if err := db.SaveArchive(ctx, id64, txt); err != nil {
- log.Printf("save archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- content = txt
- ok = true
- }
- }
- if !ok {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- _, _ = w.Write([]byte(content))
+ // Archive, PUT, DELETE under /bookmarks/{id}
+ mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Path
+ rest := strings.TrimPrefix(path, "/bookmarks/")
+ if rest == "" || strings.HasPrefix(rest, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ if strings.HasSuffix(rest, "/archive") {
+ idPart := strings.TrimSuffix(rest, "/archive")
+ idPart = strings.TrimSuffix(idPart, "/")
+ id64, err := strconv.ParseInt(idPart, 10, 64)
+ if err != nil || id64 <= 0 {
+ http.Error(w, "invalid id", http.StatusBadRequest)
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ // Enforce public/private visibility
+ b, err := db.GetBookmark(r.Context(), id64)
+ if err != nil { log.Printf("lookup bookmark error: %v", err); http.Error(w, "internal server error", http.StatusInternalServerError); return }
+ if b == nil { http.NotFound(w, r); return }
+ uid, _ := userIDFromRequest(r, db)
+ isOwner := (uid > 0 && uid == b.UserID)
+ if !b.Public && !isOwner { http.NotFound(w, r); return }
+
+ content, _, ok, err := db.GetArchive(r.Context(), id64)
+ if err != nil {
+ log.Printf("get archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ // Only the owner may fetch on-demand
+ if isOwner && (r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true") {
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ txt, err := ArchiveToText(ctx, b.URL)
+ if err != nil { http.Error(w, "failed to fetch archive", http.StatusBadGateway); return }
+ if err := db.SaveArchive(ctx, id64, txt); err != nil { log.Printf("save archive error: %v", err); http.Error(w, "internal server error", http.StatusInternalServerError); return }
+ content = txt
+ ok = true
+ }
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(content))
default:
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/migrations/0009_bookmarks_user_not_null.sql b/internal/migrations/0009_bookmarks_user_not_null.sql
@@ -0,0 +1,37 @@
+-- Rebuild bookmarks to enforce NOT NULL user_id and proper constraints
+PRAGMA foreign_keys = OFF;
+
+-- Create new table with desired schema
+CREATE TABLE bookmarks_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ public INTEGER NOT NULL DEFAULT 0,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+-- Populate from old table, assigning orphan rows to the first user
+INSERT INTO bookmarks_new (id, url, title, description, public, user_id, created_at, updated_at)
+SELECT
+ id,
+ url,
+ title,
+ description,
+ COALESCE(public, 0),
+ COALESCE(user_id, (SELECT id FROM users ORDER BY id LIMIT 1)),
+ created_at,
+ updated_at
+FROM bookmarks;
+
+-- Replace tables
+DROP TABLE bookmarks;
+ALTER TABLE bookmarks_new RENAME TO bookmarks;
+
+-- Indices
+DROP INDEX IF EXISTS idx_bookmarks_user_url;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, url);
+
+PRAGMA foreign_keys = ON;
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -155,6 +155,7 @@
<h3><a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a></h3>
<p class="url">{{ b.url }}</p>
<p class="meta">Updated: {{ formatDate(b.updated_at) }}</p>
+ <p class="action-links"><a :href="`/bookmarks/${b.id}/archive`" target="_blank">Archive</a></p>
<p v-if="b.description" class="desc">{{ b.description }}</p>
</article>
</div>