marky

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

commit 4792b18ac7660694e730545e0006e3dd91067aed
parent f4131c992e7e60795191bae870b3a3f5788942b3
Author: David Eisinger <[email protected]>
Date:   Tue,  7 Oct 2025 00:36:58 -0400

Non-HTML bookmarks

Diffstat:
Minternal/api.go | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Minternal/archiver.go | 25+++++++++++++++++++++++++
Minternal/database.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/non_html_archive_test.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/index.html | 10+++++++---
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>