marky

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

commit bc157087a19ad59f109ce7c0fa98e725fd9b66a0
parent a8dd728f2e9a1be1ee223da5d646459ec7bcec98
Author: David Eisinger <[email protected]>
Date:   Thu,  9 Oct 2025 22:28:49 -0400

Normalize user URLs

Diffstat:
MREADME.md | 17+++++++----------
Minternal/api.go | 287+++++++++++++++++++++++++++++++------------------------------------------------
Minternal/auth_e2e_test.go | 14+++++++-------
Minternal/non_html_archive_test.go | 4++--
Minternal/web/app.js | 14++------------
Minternal/web/index.html | 8++++----
Minternal/web/user.html | 8++++----
7 files changed, 140 insertions(+), 212 deletions(-)

diff --git a/README.md b/README.md @@ -171,26 +171,23 @@ curl -i -X DELETE http://localhost:8080/bookmarks/1 ### Per‑user feeds -Public JSON/RSS feeds are available at `/{username}.json` and `/{username}.rss`. +Feeds are normalized under `/u:{username}(.format)` where `format` is `json` or `rss`. When no format is provided, HTML is served. +- Examples (include private when authenticated): + - Basic: `curl -u 'dce:API_TOKEN' http://localhost:8080/u:dce.rss` + - Header: `curl -H 'X-API-Token: API_TOKEN' http://localhost:8080/u:dce.json` + - Query: `curl 'http://localhost:8080/u:dce.json?token=API_TOKEN'` + +Notes: - Unauthenticated or non‑owner requests return only public bookmarks. - Owner (session, Basic auth, header, or query token) sees all bookmarks (public + private). -Examples (include private when authenticated): -- Basic: `curl -u 'dce:API_TOKEN' http://localhost:8080/dce.rss` -- Header: `curl -H 'X-API-Token: API_TOKEN' http://localhost:8080/dce.json` -- Query: `curl 'http://localhost:8080/dce.json?token=API_TOKEN'` - ### Bookmarklet - Open `/settings` while logged in to get a “Save to Marky” bookmarklet you can drag to your bookmarks bar. - Clicking the bookmarklet on any page opens a small popup with URL and title prefilled; you can adjust description and toggle public/private. - The popup submits to `/bookmarks` using your session or API token (embedded when present). -### Public JSON listing - -- `GET /u/{username}` → JSON array of that user’s public bookmarks (always public‑only). - ## Data Model Table: `bookmarks` diff --git a/internal/api.go b/internal/api.go @@ -263,62 +263,16 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { } }) - // Public listing by username (JSON) - mux.HandleFunc("/u/", func(w http.ResponseWriter, r *http.Request) { - uname := strings.TrimPrefix(r.URL.Path, "/u/") - if uname == "" || strings.Contains(uname, "/") { - http.NotFound(w, r) - return - } - u, err := db.GetUserByUsername(r.Context(), uname) - if err != nil || u == nil { - http.NotFound(w, r) - return - } - // Pagination - q := r.URL.Query() - limit, offset := 50, 0 - if s := q.Get("limit"); s != "" { - if v, err := strconv.Atoi(s); err == nil && v > 0 { - if v > 200 { - v = 200 - } - limit = v - } else { - http.Error(w, "invalid limit", http.StatusBadRequest) - return - } - } - if s := q.Get("offset"); s != "" { - if v, err := strconv.Atoi(s); err == nil && v >= 0 { - offset = v - } else { - http.Error(w, "invalid offset", http.StatusBadRequest) - return - } - } - items, err := db.ListPublicBookmarks(r.Context(), u.ID, limit, offset) - if err != nil { - log.Printf("list public bookmarks error: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - resp := make([]bookmarkResponse, 0, len(items)) - for i := range items { - resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - }) + // Public listing endpoint removed; feeds normalized under /u:{username}(.format) - // Static web UI at "/" with user feeds overlay (/{username}.json and /{username}.rss) + // Static web UI and page routes static, err := StaticHandlerWithConfig(cfg) if err != nil { return nil, err } mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") - // Root: show login page when unauthenticated; redirect to /u:{username} when logged in + // Root if p == "" { if uid, ok := userIDFromRequest(r, db); ok && uid > 0 { if u, err := db.GetUserByID(r.Context(), uid); err == nil && u != nil { @@ -333,7 +287,12 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { return } } - // Settings page (requires auth) + // Only GET/HEAD beyond this point + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + // Settings page if p == "settings" { uid, ok := userIDFromRequest(r, db) if !ok || uid == 0 { @@ -341,7 +300,6 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { return } if html, err := RenderPage("settings.html", cfg); err == nil { - // Embed API token and current username for hydration if tok, _, err := db.GetAPIToken(r.Context(), uid); err == nil { inject := []byte("<script>window.MARKY_TOKEN=" + strconv.Quote(tok) + "</script>\n") marker := []byte("<script src=\"/app.js\"></script>") @@ -370,150 +328,133 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { return } } - if p != "" { - // Only allow static/page handling for GET/HEAD; other methods 404 here - if r.Method != http.MethodGet && r.Method != http.MethodHead { + // Single-segment with trailing dot is invalid + if !strings.Contains(p, "/") && strings.HasSuffix(p, ".") { + http.NotFound(w, r) + return + } + // User page and feeds: /u:{username}[.rss|.json] + if strings.HasPrefix(p, "u:") { + rest := strings.TrimPrefix(p, "u:") + if rest == "" || strings.Contains(rest, "/") { http.NotFound(w, r) return } - // If the path is a single segment ending with a trailing dot, treat it - // as an invalid username route and return 404. - if !strings.Contains(p, "/") && strings.HasSuffix(p, ".") { + format := "" + uname := rest + if strings.HasSuffix(rest, ".rss") { + format = "rss" + uname = strings.TrimSuffix(uname, ".rss") + } + if strings.HasSuffix(rest, ".json") { + format = "json" + uname = strings.TrimSuffix(uname, ".json") + } + u, err := db.GetUserByUsername(r.Context(), uname) + if err != nil || u == nil { http.NotFound(w, r) return } - // New user page route: /u:{username} - if strings.HasPrefix(p, "u:") { - uname := strings.TrimPrefix(p, "u:") - if uname == "" || strings.Contains(uname, "/") { - http.NotFound(w, r) + if format != "" { + var items []Bookmark + if isOwnerAuthorized(r, u, db) { + items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) + } else { + items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) + } + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) return } - if u, err := db.GetUserByUsername(r.Context(), uname); err == nil && u != nil { - if html, err := RenderPage("user.html", cfg); err == nil { - // Embed current username (if logged in) - if uid, ok := userIDFromRequest(r, db); ok && uid > 0 { - if cu, err := db.GetUserByID(r.Context(), uid); err == nil && cu != nil { - meInject := []byte("<script>window.MARKY_ME=" + strconv.Quote(cu.Username) + "</script>\n") - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(html, marker); i >= 0 { - out := make([]byte, 0, len(html)+len(meInject)) - out = append(out, html[:i]...) - out = append(out, meInject...) - out = append(out, html[i:]...) - html = out - } - } - } - // Set <title> to "{username} | Marky" by replacing inner text only - if ti := bytes.Index(html, []byte("<title>")); ti >= 0 { - if tj := bytes.Index(html[ti:], []byte("</title>")); tj > 0 { - start := ti + len("<title>") - end := ti + tj - nt := []byte(u.Username + " | Marky") - nb := make([]byte, 0, len(html)-(end-start)+len(nt)) - nb = append(nb, html[:start]...) - nb = append(nb, nt...) - nb = append(nb, html[end:]...) - html = nb - } - } - // Embed initial bookmarks to avoid client fetch - if isOwnerAuthorized(r, u, db) { - items, err := db.ListBookmarks(r.Context(), u.ID, 50, 0) - if err == nil { - resp := make([]bookmarkResponse, 0, len(items)) - for i := range items { - resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) - } - if b, err := json.Marshal(resp); err == nil { - inject := []byte("<script>window.MARKY_INITIAL={view:'self',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(html, marker); i >= 0 { - out := make([]byte, 0, len(html)+len(inject)) - out = append(out, html[:i]...) - out = append(out, inject...) - out = append(out, html[i:]...) - html = out - } - } - } - } else { - items, err := db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) - if err == nil { - resp := make([]bookmarkResponse, 0, len(items)) - for i := range items { - resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) - } - if b, err := json.Marshal(resp); err == nil { - inject := []byte("<script>window.MARKY_INITIAL={view:'public',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") - marker := []byte("<script src=\"/app.js\"></script>") - if i := bytes.Index(html, marker); i >= 0 { - out := make([]byte, 0, len(html)+len(inject)) - out = append(out, html[:i]...) - out = append(out, inject...) - out = append(out, html[i:]...) - html = out - } - } - } - } - html = applyCacheBust(html) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(html) - return - } + resp := make([]bookmarkResponse, 0, len(items)) + for i := range items { + resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) + } + if format == "json" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } else { + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + _ = writeRSS(w, r, resp, fmt.Sprintf("%s's Bookmarks", u.Username)) } - http.NotFound(w, r) return } - if strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss") { - // Extract username and format - format := "json" - if strings.HasSuffix(p, ".rss") { - format = "rss" + // HTML user page + if html, err := RenderPage("user.html", cfg); err == nil { + // Embed MARKY_ME if session + if uid, ok := userIDFromRequest(r, db); ok && uid > 0 { + if cu, err := db.GetUserByID(r.Context(), uid); err == nil && cu != nil { + meInject := []byte("<script>window.MARKY_ME=" + strconv.Quote(cu.Username) + "</script>\n") + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(html, marker); i >= 0 { + out := make([]byte, 0, len(html)+len(meInject)) + out = append(out, html[:i]...) + out = append(out, meInject...) + out = append(out, html[i:]...) + html = out + } + } } - name := p - if format == "json" { - name = strings.TrimSuffix(name, ".json") + // Title + if ti := bytes.Index(html, []byte("<title>")); ti >= 0 { + if tj := bytes.Index(html[ti:], []byte("</title>")); tj > 0 { + start := ti + len("<title>") + end := ti + tj + nt := []byte(u.Username + " | Marky") + nb := make([]byte, 0, len(html)-(end-start)+len(nt)) + nb = append(nb, html[:start]...) + nb = append(nb, nt...) + nb = append(nb, html[end:]...) + html = nb + } + } + // Embed initial results + var items []Bookmark + if isOwnerAuthorized(r, u, db) { + items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) } else { - name = strings.TrimSuffix(name, ".rss") + items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) } - // deny nested paths - if name != "" && !strings.Contains(name, "/") { - if u, err := db.GetUserByUsername(r.Context(), name); err == nil && u != nil { - // Show private items if this is the owner's feed (session, basic, header token, or query token) - var items []Bookmark + if err == nil { + resp := make([]bookmarkResponse, 0, len(items)) + for i := range items { + resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) + } + if b, err := json.Marshal(resp); err == nil { + view := "public" if isOwnerAuthorized(r, u, db) { - items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) - } else { - items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) + view = "self" } - if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - resp := make([]bookmarkResponse, 0, len(items)) - for i := range items { - resp = append(resp, toRespWithArchive(r.Context(), db, &items[i])) - } - if format == "json" { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - } else { - w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") - _ = writeRSS(w, r, resp, fmt.Sprintf("%s's Bookmarks", u.Username)) + inject := []byte("<script>window.MARKY_INITIAL={view:'" + view + "',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n") + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(html, marker); i >= 0 { + out := make([]byte, 0, len(html)+len(inject)) + out = append(out, html[:i]...) + out = append(out, inject...) + out = append(out, html[i:]...) + html = out } - return } } - } - // Legacy username route removed: any non-file single segment (not feeds) 404s - if !strings.Contains(p, "/") && !strings.Contains(p, ".") && !strings.HasPrefix(p, "u:") { - http.NotFound(w, r) + html = applyCacheBust(html) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(html) return } + http.NotFound(w, r) + return + } + // Legacy feeds like /{username}.json or /{username}.rss → 404 + if !strings.Contains(p, "/") && (strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss")) { + http.NotFound(w, r) + return + } + // Unknown single segment that isn't a file → 404 + if !strings.Contains(p, "/") && !strings.Contains(p, ".") { + http.NotFound(w, r) + return } + // Fallback to static server static.ServeHTTP(w, r) }) diff --git a/internal/auth_e2e_test.go b/internal/auth_e2e_test.go @@ -46,8 +46,8 @@ func TestFeedsAuthWithToken(t *testing.T) { postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL, "public": true, "title": "Pub"}, 201) postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL + "/p", "public": false, "title": "Priv"}, 201) - // Unauthenticated caller sees only public entries in /alice.json - pubOnly := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json", 200) + // Unauthenticated caller sees only public entries in /u:alice.json + pubOnly := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/u:alice.json", 200) if len(pubOnly) != 1 { t.Fatalf("expected 1 public item, got %d", len(pubOnly)) } @@ -60,7 +60,7 @@ func TestFeedsAuthWithToken(t *testing.T) { } // With header token, see both - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil) + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/u:alice.json", nil) req.Header.Set("X-API-Token", token) res, err := http.DefaultClient.Do(req) if err != nil { @@ -77,13 +77,13 @@ func TestFeedsAuthWithToken(t *testing.T) { } // With query token, see both - both2 := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json?token="+token, 200) + both2 := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/u:alice.json?token="+token, 200) if len(both2) != 2 { t.Fatalf("expected 2 items with query token, got %d", len(both2)) } // With Basic auth, see both - req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil) + req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/u:alice.json", nil) req2.SetBasicAuth("alice", token) res2, err := http.DefaultClient.Do(req2) if err != nil { @@ -100,14 +100,14 @@ func TestFeedsAuthWithToken(t *testing.T) { } // RSS unauthenticated: only public (<item> once) - r1, _ := http.Get(srv.URL + "/alice.rss") + r1, _ := http.Get(srv.URL + "/u:alice.rss") b1, _ := io.ReadAll(r1.Body) _ = r1.Body.Close() if c := countRSSItems(b1); c != 1 { t.Fatalf("expected 1 RSS item unauth, got %d", c) } // RSS with token header: both - rreq, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.rss", nil) + rreq, _ := http.NewRequest(http.MethodGet, srv.URL+"/u:alice.rss", nil) rreq.Header.Set("X-API-Token", token) rres, _ := http.DefaultClient.Do(rreq) rb, _ := io.ReadAll(rres.Body) diff --git a/internal/non_html_archive_test.go b/internal/non_html_archive_test.go @@ -72,8 +72,8 @@ func TestNonHTML_NoArchiveAndNoLink(t *testing.T) { 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) + // Public listing for user via JSON feed under /u:{username}.json shows same + pub := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/u:pdfuser.json", 200) if len(pub) != 1 { t.Fatalf("expected 1 public bookmark, got %d", len(pub)) } diff --git a/internal/web/app.js b/internal/web/app.js @@ -56,15 +56,12 @@ createApp({ // Do not auto-fetch all bookmarks on initial load } else { this.view = 'public'; - // Initialize from server-embedded data when available; otherwise fetch - let hydrated = false; + // Initialize from server-embedded data when available; no network fallback try { if (window.MARKY_INITIAL && window.MARKY_INITIAL.view === 'public' && window.MARKY_INITIAL.user === this.viewUser) { this.results = Array.isArray(window.MARKY_INITIAL.results) ? window.MARKY_INITIAL.results : []; - hydrated = true; } } catch {} - if (!hydrated) this.loadPublic(); } }, @@ -221,14 +218,7 @@ createApp({ this.busySearch = false; } }, - async loadPublic() { - try { - const res = await fetch(`/u/${encodeURIComponent(this.viewUser)}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const arr = await res.json(); - this.results = arr.map((x)=>({ ...x, description: x.description ?? null, public: true })); - } catch (e) { console.error(e); } - }, + // loadPublic removed; server embeds data for public view bookmarkletHref() { // Build the bookmarklet installer link dynamically const origin = location.origin; diff --git a/internal/web/index.html b/internal/web/index.html @@ -71,9 +71,9 @@ <h2> Bookmarks <span class="small-links" v-if="me && me.username"> - <a :href="`/${me.username}.json`" title="Your JSON feed (includes private if logged in)">JSON</a> + <a :href="`/u:${me.username}.json`" title="Your JSON feed (includes private if logged in)">JSON</a> <span class="sep">|</span> - <a :href="`/${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a> + <a :href="`/u:${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a> </span> </h2> <form @submit.prevent="search"> @@ -118,9 +118,9 @@ <h2> {{ viewUser }} <span class="small-links"> - <a :href="`/${viewUser}.json`" title="User JSON feed">JSON</a> + <a :href="`/u:${viewUser}.json`" title="User JSON feed">JSON</a> <span class="sep">|</span> - <a :href="`/${viewUser}.rss`" title="User RSS feed">RSS</a> + <a :href="`/u:${viewUser}.rss`" title="User RSS feed">RSS</a> </span> </h2> <div class="results"> diff --git a/internal/web/user.html b/internal/web/user.html @@ -33,9 +33,9 @@ <h2> Bookmarks <span class="small-links" v-if="me && me.username"> - <a :href="`/${me.username}.json`" title="Your JSON feed (includes private if logged in)">JSON</a> + <a :href="`/u:${me.username}.json`" title="Your JSON feed (includes private if logged in)">JSON</a> <span class="sep">|</span> - <a :href="`/${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a> + <a :href="`/u:${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a> </span> </h2> <form @submit.prevent="search"> @@ -77,9 +77,9 @@ <h2> {{ viewUser }} <span class="small-links"> - <a :href="`/${viewUser}.json`" title="User JSON feed">JSON</a> + <a :href="`/u:${viewUser}.json`" title="User JSON feed">JSON</a> <span class="sep">|</span> - <a :href="`/${viewUser}.rss`" title="User RSS feed">RSS</a> + <a :href="`/u:${viewUser}.rss`" title="User RSS feed">RSS</a> </span> </h2> <div class="results">