commit 4792b18ac7660694e730545e0006e3dd91067aed
parent f4131c992e7e60795191bae870b3a3f5788942b3
Author: David Eisinger <[email protected]>
Date: Tue, 7 Oct 2025 00:36:58 -0400
Non-HTML bookmarks
Diffstat:
5 files changed, 236 insertions(+), 20 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -8,6 +8,7 @@ import (
"log"
"net/http"
"net/url"
+ "path"
"strconv"
"strings"
"time"
@@ -169,7 +170,7 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
resp := make([]bookmarkResponse, 0, len(items))
for i := range items {
- resp = append(resp, toResp(&items[i]))
+ resp = append(resp, toRespWithArchive(r.Context(), db, &items[i]))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
@@ -211,7 +212,7 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
resp := make([]bookmarkResponse, 0, len(items))
for i := range items {
- resp = append(resp, toResp(&items[i]))
+ resp = append(resp, toRespWithArchive(r.Context(), db, &items[i]))
}
if format == "json" {
w.Header().Set("Content-Type", "application/json")
@@ -273,15 +274,22 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
- fetchedTitle, txt, err := TitleAndText(ctx, req.URL)
- if err != nil {
- http.Error(w, "failed to archive URL", http.StatusBadGateway)
- return
+ isHTML := IsHTMLURL(ctx, req.URL)
+ var fetchedTitle, txt string
+ var err error
+ if isHTML {
+ fetchedTitle, txt, err = TitleAndText(ctx, req.URL)
+ if err != nil {
+ http.Error(w, "failed to archive URL", http.StatusBadGateway)
+ return
+ }
}
title := req.Title
if strings.TrimSpace(title) == "" {
if strings.TrimSpace(fetchedTitle) != "" {
title = fetchedTitle
+ } else if fn := fileNameFromURL(req.URL); fn != "" {
+ title = fn
} else {
title = req.URL
}
@@ -296,13 +304,15 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if existing != nil {
- if err := db.SaveArchive(r.Context(), existing.ID, txt); err != nil {
- log.Printf("save archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
+ if isHTML {
+ if err := db.SaveArchive(r.Context(), existing.ID, txt); err != nil {
+ log.Printf("save archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
}
if err := db.TouchBookmark(r.Context(), existing.ID); err != nil {
- log.Printf("touch bookmark error: %v", err)
+ log.Printf("save archive error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -315,10 +325,15 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
return
}
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(toResp(updated))
+ _ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, updated))
return
} else {
- created, err := db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
+ 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)
@@ -326,7 +341,7 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(w).Encode(toResp(created))
+ _ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, created))
}
case http.MethodGet:
@@ -370,7 +385,7 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
resp = make([]bookmarkResponse, 0, len(hits))
for i := range hits {
h := hits[i]
- br := toResp(&h.Bookmark)
+ br := toRespWithArchive(r.Context(), db, &h.Bookmark)
if h.Excerpt.Valid {
ex := h.Excerpt.String
br.Excerpt = &ex
@@ -387,7 +402,7 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
resp = make([]bookmarkResponse, 0, len(items))
for i := range items {
b := items[i]
- resp = append(resp, toResp(&b))
+ resp = append(resp, toRespWithArchive(r.Context(), db, &b))
}
}
@@ -458,6 +473,10 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
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()
+ if !IsHTMLURL(ctx, b.URL) {
+ http.NotFound(w, r)
+ return
+ }
txt, err := ArchiveToText(ctx, b.URL)
if err != nil {
http.Error(w, "failed to fetch archive", http.StatusBadGateway)
@@ -548,7 +567,7 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
return
}
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(toResp(updated))
+ _ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, updated))
case http.MethodDelete:
if _, ok := userIDFromRequest(r, db); !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -633,6 +652,7 @@ type bookmarkResponse struct {
UpdatedAt time.Time `json:"updated_at"`
Excerpt *string `json:"excerpt,omitempty"`
Public bool `json:"public"`
+ HasArchive bool `json:"has_archive"`
}
func toResp(b *Bookmark) bookmarkResponse {
@@ -651,6 +671,14 @@ func toResp(b *Bookmark) bookmarkResponse {
}
}
+func toRespWithArchive(ctx context.Context, db *DB, b *Bookmark) bookmarkResponse {
+ br := toResp(b)
+ if ok, err := db.HasArchive(ctx, b.ID); err == nil {
+ br.HasArchive = ok
+ }
+ return br
+}
+
// RSS structures and writer
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
@@ -703,3 +731,21 @@ func writeRSS(w http.ResponseWriter, r *http.Request, items []bookmarkResponse)
enc.Indent("", " ")
return enc.Encode(out)
}
+
+// fileNameFromURL extracts a human-friendly filename from a URL path.
+// It returns an empty string when no filename can be determined.
+func fileNameFromURL(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil || u == nil {
+ return ""
+ }
+ p := strings.TrimSuffix(u.Path, "/")
+ base := path.Base(p)
+ if base == "." || base == "/" || base == "" {
+ return ""
+ }
+ if un, err := url.PathUnescape(base); err == nil && un != "" {
+ base = un
+ }
+ return base
+}
diff --git a/internal/archiver.go b/internal/archiver.go
@@ -48,6 +48,31 @@ func TitleAndText(ctx context.Context, rawURL string) (string, string, error) {
return title, txt, nil
}
+// IsHTMLURL attempts a HEAD request and inspects Content-Type to determine if
+// the resource is HTML. If Content-Type is empty or HEAD fails, we assume HTML
+// to avoid false negatives on servers that mishandle HEAD.
+func IsHTMLURL(ctx context.Context, rawURL string) bool {
+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
+ if err != nil {
+ return true
+ }
+ req.Header.Set("User-Agent", "marky-archiver/1.0 (+https://example.invalid)")
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return true
+ }
+ defer resp.Body.Close()
+ ct := strings.ToLower(resp.Header.Get("Content-Type"))
+ if ct == "" {
+ return true
+ }
+ if strings.Contains(ct, "text/html") || strings.Contains(ct, "+html") || strings.Contains(ct, "application/xhtml+xml") {
+ return true
+ }
+ return false
+}
+
// w3mDump runs `w3m -dump` to render the given URL to plaintext.
func w3mDump(ctx context.Context, url string) (string, error) {
cmd := exec.CommandContext(ctx, "w3m", "-dump", "-T", "text/html", "-o", "display_link_number=1", url)
diff --git a/internal/database.go b/internal/database.go
@@ -208,6 +208,44 @@ func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title strin
}, nil
}
+// CreateBookmarkNoArchive inserts a new bookmark with the desired public flag
+// and initializes the FTS row with empty content (no archive entry).
+func (db *DB) CreateBookmarkNoArchive(ctx context.Context, userID int64, url, title string, description *string, public bool) (*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}
+ }
+ res, err := db.sql.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))
+ if err != nil {
+ return nil, fmt.Errorf("insert bookmark: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, fmt.Errorf("last insert id: %w", err)
+ }
+ // Initialize FTS row with empty content (no archive).
+ if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ""); err != nil {
+ return nil, fmt.Errorf("fts replace after create(no-archive): %w", err)
+ }
+ return &Bookmark{
+ ID: id,
+ URL: url,
+ Title: title,
+ Description: desc,
+ Public: public,
+ 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
@@ -358,6 +396,19 @@ func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) {
return n > 0, nil
}
+// HasArchive reports whether an archive row exists for the given bookmark ID.
+func (db *DB) HasArchive(ctx context.Context, id int64) (bool, error) {
+ var one int
+ err := db.sql.QueryRowContext(ctx, `SELECT 1 FROM archives WHERE bookmark_id = ? LIMIT 1`, id).Scan(&one)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return false, fmt.Errorf("has archive: %w", err)
+ }
+ return true, nil
+}
+
func nullString(ns sql.NullString) any {
if ns.Valid {
return ns.String
diff --git a/internal/non_html_archive_test.go b/internal/non_html_archive_test.go
@@ -0,0 +1,90 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "testing"
+)
+
+// Ensure that non-HTML URLs (e.g., PDFs) do not create archives and listings
+// show has_archive=false. Also verify archive endpoint returns 404 even with fetch=1.
+func TestNonHTML_NoArchiveAndNoLink(t *testing.T) {
+ t.Parallel()
+
+ // Content server that reports application/pdf for HEAD.
+ pdf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/pdf")
+ // No body needed.
+ if r.Method == http.MethodHead {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer pdf.Close()
+
+ // API server
+ db, err := OpenAtPath(t.Context(), t.TempDir()+"/nonhtml.db")
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ h, err := NewHandler(db)
+ if err != nil {
+ t.Fatalf("handler: %v", err)
+ }
+ 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": "pdfuser", "password": "pw"}
+ _ = postJSONWithClient[map[string]any](t, client, srv.URL+"/signup", signupPayload, 200)
+
+ // Create a public bookmark pointing to the PDF URL
+ body := map[string]any{"url": pdf.URL + "/doc.pdf", "public": true}
+ 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)
+ }
+ if ha, ok := b["has_archive"].(bool); !ok || ha {
+ t.Fatalf("expected has_archive=false at creation, got %v", b["has_archive"])
+ }
+
+ // DB must report no archive
+ has, err := db.HasArchive(t.Context(), int64(idFloat))
+ if err != nil {
+ t.Fatalf("has archive: %v", err)
+ }
+ if has {
+ t.Fatalf("expected no archive row for non-HTML URL")
+ }
+
+ // Logged-in listing includes has_archive=false
+ list := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/bookmarks", 200)
+ if len(list) != 1 {
+ t.Fatalf("expected 1 bookmark, got %d", len(list))
+ }
+ if ha, ok := list[0]["has_archive"].(bool); !ok || ha {
+ t.Fatalf("expected has_archive=false in listing, got %v", list[0]["has_archive"])
+ }
+
+ // Public listing for user shows same
+ pub := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/u/pdfuser", 200)
+ if len(pub) != 1 {
+ t.Fatalf("expected 1 public bookmark, got %d", len(pub))
+ }
+ if ha, ok := pub[0]["has_archive"].(bool); !ok || ha {
+ t.Fatalf("expected has_archive=false in public listing, got %v", pub[0]["has_archive"])
+ }
+
+ // Archive endpoint returns 404, even with fetch=1 as owner
+ arch := getWithClient(t, client, srv.URL+"/bookmarks/"+itoa(int(idFloat))+"/archive", 404)
+ _ = arch.Body.Close()
+
+ arch2 := getWithClient(t, client, srv.URL+"/bookmarks/"+itoa(int(idFloat))+"/archive?fetch=1", 404)
+ _ = arch2.Body.Close()
+}
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -130,8 +130,10 @@
<p v-else-if="b.excerpt" class="excerpt" v-html="formatExcerpt(b.excerpt)"></p>
<p class="meta">Updated: {{ formatDate(b.updated_at) }}</p>
<p class="action-links">
- <a :href="`/bookmarks/${b.id}/archive`" target="_blank" title="View archived text">Archive</a>
- <span class="sep">|</span>
+ <template v-if="b.has_archive">
+ <a :href="`/bookmarks/${b.id}/archive`" target="_blank" title="View archived text">Archive</a>
+ <span class="sep">|</span>
+ </template>
<a href="#" @click.prevent="startEdit(b)">Edit</a>
<span class="sep">|</span>
<a href="#" @click.prevent="confirmDelete(b)">Delete</a>
@@ -155,7 +157,9 @@
<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 class="action-links">
+ <a v-if="b.has_archive" :href="`/bookmarks/${b.id}/archive`" target="_blank">Archive</a>
+ </p>
<p v-if="b.description" class="desc">{{ b.description }}</p>
</article>
</div>