commit bc157087a19ad59f109ce7c0fa98e725fd9b66a0
parent a8dd728f2e9a1be1ee223da5d646459ec7bcec98
Author: David Eisinger <[email protected]>
Date: Thu, 9 Oct 2025 22:28:49 -0400
Normalize user URLs
Diffstat:
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">