marky

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

commit 182840e210c0863d6ad3d46b2ac488b93c370670
parent db82d83e41cfcf97e8b1162c714ee7cea753efc0
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 23:19:34 -0400

Auth for archive endpoints

Diffstat:
Minternal/api.go | 108+++++++++++++++++++++++++++++++++++++------------------------------------------
Ainternal/migrations/0009_bookmarks_user_not_null.sql | 37+++++++++++++++++++++++++++++++++++++
Minternal/web/index.html | 1+
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>