marky

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

api.go (45525B)


      1 package internal
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"encoding/json"
      7 	"encoding/xml"
      8 	"fmt"
      9 	"log"
     10 	"net/http"
     11 	"net/url"
     12 	"os"
     13 	"os/exec"
     14 	"path"
     15 	"strconv"
     16 	"strings"
     17 	"time"
     18 )
     19 
     20 // HandlerConfig controls optional features exposed by the HTTP handler.
     21 type HandlerConfig struct {
     22 	DisableSignup bool
     23 	BaseURL       string
     24 }
     25 
     26 // NewHandler constructs the HTTP API handler with default settings.
     27 func NewHandler(db *DB) (http.Handler, error) { return NewHandlerWithConfig(db, HandlerConfig{}) }
     28 
     29 // NewHandlerWithConfig constructs the HTTP API handler, including the embedded web UI.
     30 var baseURLOverride string
     31 
     32 func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
     33 	if strings.TrimSpace(cfg.BaseURL) != "" {
     34 		baseURLOverride = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
     35 	}
     36 	mux := http.NewServeMux()
     37 
     38 	// Auth endpoints
     39 	if !cfg.DisableSignup {
     40 		mux.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
     41 			if r.Method != http.MethodPost {
     42 				http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
     43 				return
     44 			}
     45 			var req struct{ Username, Password string }
     46 			if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" {
     47 				http.Error(w, "invalid payload", http.StatusBadRequest)
     48 				return
     49 			}
     50 			if err := ValidateUsername(req.Username); err != nil {
     51 				http.Error(w, err.Error(), http.StatusBadRequest)
     52 				return
     53 			}
     54 			id, err := db.CreateUser(r.Context(), req.Username, req.Password)
     55 			if err != nil {
     56 				http.Error(w, "cannot create user", http.StatusConflict)
     57 				return
     58 			}
     59 			SetSession(w, id)
     60 			w.Header().Set("Content-Type", "application/json")
     61 			_ = json.NewEncoder(w).Encode(map[string]any{"username": req.Username})
     62 		})
     63 	}
     64 	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
     65 		if r.Method != http.MethodPost {
     66 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
     67 			return
     68 		}
     69 		var req struct{ Username, Password string }
     70 		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
     71 			http.Error(w, "invalid payload", http.StatusBadRequest)
     72 			return
     73 		}
     74 		id, ok, err := db.VerifyUserPassword(r.Context(), req.Username, req.Password)
     75 		if err != nil || !ok {
     76 			http.Error(w, "invalid credentials", http.StatusUnauthorized)
     77 			return
     78 		}
     79 		SetSession(w, id)
     80 		w.Header().Set("Content-Type", "application/json")
     81 		_ = json.NewEncoder(w).Encode(map[string]any{"username": req.Username})
     82 	})
     83 	mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
     84 		if r.Method != http.MethodPost {
     85 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
     86 			return
     87 		}
     88 		ClearSession(w)
     89 		w.WriteHeader(http.StatusNoContent)
     90 	})
     91 
     92 	// Bookmarklet generator and popup form
     93 	mux.HandleFunc("/bookmarklet", func(w http.ResponseWriter, r *http.Request) {
     94 		if r.Method != http.MethodGet {
     95 			w.Header().Set("Allow", http.MethodGet)
     96 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
     97 			return
     98 		}
     99 		uid, hasSession := CurrentUserID(r)
    100 		var user *User
    101 		var err error
    102 		if hasSession {
    103 			if user, err = db.GetUserByID(r.Context(), uid); err != nil {
    104 				user = nil
    105 				hasSession = false
    106 			}
    107 		}
    108 		// Resolve origin to use in links opened by the bookmarklet
    109 		origin := originFromRequest(r)
    110 		// Fetch API token (if present) for interpolation
    111 		var token string
    112 		var present bool
    113 		if hasSession {
    114 			token, present, err = db.GetAPIToken(r.Context(), uid)
    115 		}
    116 		if err != nil {
    117 			log.Printf("get api token error: %v", err)
    118 		}
    119 		// Determine mode: installer (no u param) or popup (with u)
    120 		q := r.URL.Query()
    121 		pageURL := strings.TrimSpace(q.Get("u"))
    122 		// Helpers for embedding JSON strings into HTML templates
    123 		enc := func(s string) string { b, _ := json.Marshal(s); return string(b) }
    124 		if pageURL == "" {
    125 			// No installer page anymore; redirect to settings (or root when unauth)
    126 			if !hasSession || user == nil {
    127 				http.Redirect(w, r, "/", http.StatusFound)
    128 			} else {
    129 				http.Redirect(w, r, "/settings", http.StatusFound)
    130 			}
    131 			return
    132 		}
    133 		// Popup form page
    134 		pageTitle := q.Get("t")
    135 		desc := q.Get("d")
    136 		refParam := q.Get("r")
    137 		pub := ""
    138 		if q.Get("p") != "" {
    139 			pub = q.Get("p")
    140 		}
    141 		// Escape text content for HTML attribute/text node usage.
    142 		htmlEscape := func(s string) string {
    143 			r := strings.NewReplacer(
    144 				"&", "&",
    145 				"<", "&lt;",
    146 				">", "&gt;",
    147 				`"`, "&quot;",
    148 				"'", "&#39;",
    149 			)
    150 			return r.Replace(s)
    151 		}
    152 		// Prefer token from query string for incognito usage
    153 		qTok := q.Get("tok")
    154 		qUser := q.Get("user")
    155 		jsToken := "null"
    156 		if strings.TrimSpace(qTok) != "" {
    157 			jsToken = enc(qTok)
    158 		} else if present {
    159 			jsToken = enc(token)
    160 		}
    161 		jsUser := "null"
    162 		if strings.TrimSpace(qUser) != "" {
    163 			jsUser = enc(qUser)
    164 		} else if user != nil {
    165 			jsUser = enc(user.Username)
    166 		}
    167 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
    168 		fmt.Fprintf(w, `<!doctype html>
    169 		<html lang="en">
    170 		<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
    171 		<title>Add Bookmark · Marky</title>
    172 		<style>
    173 		body{font:15px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:16px;color:#222}
    174 		label{display:block;margin:8px 0}
    175 		input[type=text], textarea{width:100%%;box-sizing:border-box;padding:8px;border:1px solid #ccc;border-radius:6px}
    176 		textarea{min-height:100px;resize:vertical}
    177 		.inline-row{display:flex;gap:8px;align-items:center}
    178 		.inline-row input{flex:1}
    179 		.actions{margin-top:12px;display:flex;gap:8px}
    180 		button{background:#222;color:#fff;border:0;border-radius:6px;padding:8px 12px;cursor:pointer}
    181 		button.secondary{background:#eee;color:#222}
    182 		button.inline{padding:8px 12px}
    183 		.small{color:#666;font-size:12px;margin-top:6px}
    184 		.success{color:green}
    185 		.error{color:#b00020}
    186 		</style>
    187 		<h2>Add Bookmark</h2>
    188 		<form id="f">
    189 			<label>URL
    190 				<input id="url" type="text" required value="%[1]s">
    191 			</label>
    192 			<label>Title
    193 				<input id="title" type="text" required value="%[2]s">
    194 			</label>
    195 			<label>Description
    196 				<textarea id="desc">%[3]s</textarea>
    197 			</label>
    198 			<label>Referrer
    199 				<span class="inline-row">
    200 					<input id="ref" type="text" value="%[8]s" placeholder="https://site-that-linked-here.example">
    201 					<button id="refClear" class="secondary inline" type="button">Clear</button>
    202 					<button id="refPaste" class="secondary inline" type="button">Paste</button>
    203 				</span>
    204 			</label>
    205 			<label><input id="pub" type="checkbox" %[4]s> Public</label>
    206 			<div class="actions">
    207 				<button id="saveBtn" type="submit">Save</button>
    208 				<button class="secondary" type="button" onclick="window.close()">Cancel</button>
    209 			</div>
    210 			<p class="small">Saves to %[5]s</p>
    211 			<p id="msg" class="small"></p>
    212 		</form>
    213 		<script>
    214 		(function(){
    215 		  const token = %[6]s;
    216 		  const uname = %[7]s;
    217 		  const form = document.getElementById('f');
    218 		  const msg = document.getElementById('msg');
    219 		  const saveBtn = document.getElementById('saveBtn');
    220 		  const refInput = document.getElementById('ref');
    221 		  const refClear = document.getElementById('refClear');
    222 		  const refPaste = document.getElementById('refPaste');
    223 		  refClear.addEventListener('click', function(){
    224 		    refInput.value = '';
    225 		    refInput.focus();
    226 		  });
    227 		  refPaste.addEventListener('click', async function(){
    228 		    try {
    229 		      const text = await navigator.clipboard.readText();
    230 		      refInput.value = (text || '').trim();
    231 		      refInput.focus();
    232 		    } catch (err) {
    233 		      alert('Paste failed');
    234 		    }
    235 		  });
    236 		  form.addEventListener('submit', async function(e){
    237 		    e.preventDefault();
    238 		    msg.textContent = '';
    239 		    if (saveBtn) {
    240 		      saveBtn.disabled = true;
    241 		      saveBtn.textContent = 'Saving ...';
    242 		    }
    243 		    const payload = {
    244 		      url: document.getElementById('url').value.trim(),
    245 		      title: document.getElementById('title').value.trim(),
    246           description: (function(){const v=document.getElementById('desc').value; return v.trim() ? v : null;})(),
    247           referrer: (function(){const v=document.getElementById('ref').value; return v.trim() ? v : null;})(),
    248           public: document.getElementById('pub').checked
    249         };
    250 		    try {
    251 		      const res = await fetch('%[5]s/bookmarks', {
    252 		        method: 'POST',
    253 		        headers: (function(){
    254 		          const h = {'Content-Type':'application/json'};
    255 		          if (token && uname) {
    256 		            const creds = btoa(uname + ':' + token);
    257 		            h['Authorization'] = 'Basic ' + creds;
    258 		          }
    259 		          return h;
    260 		        })(),
    261 		        body: JSON.stringify(payload)
    262 		      });
    263 		      if (!res.ok) {
    264 		        const text = await res.text();
    265 		        throw new Error(text || ('HTTP '+res.status));
    266 		      }
    267 		      msg.textContent = 'Saved! You can close this window.';
    268 		      msg.className = 'small success';
    269 		      setTimeout(function(){ window.close(); }, 800);
    270 		    } catch (err) {
    271 		      msg.textContent = 'Error: '+ err.message;
    272 		      msg.className = 'small error';
    273 		      if (saveBtn) {
    274 		        saveBtn.disabled = false;
    275 		        saveBtn.textContent = 'Save';
    276 		      }
    277 		    }
    278 		  });
    279 		})();
    280 		</script>
    281 		</html>`, htmlEscape(pageURL), htmlEscape(pageTitle), htmlEscape(desc), func() string {
    282 			if strings.EqualFold(pub, "1") || strings.EqualFold(pub, "true") {
    283 				return "checked"
    284 			}
    285 			return ""
    286 		}(), origin, jsToken, jsUser, htmlEscape(refParam))
    287 	})
    288 
    289 	// API token endpoint for logged-in user (regenerate only)
    290 	mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) {
    291 		uid, ok := CurrentUserID(r)
    292 		if !ok {
    293 			http.Error(w, "unauthorized", http.StatusUnauthorized)
    294 			return
    295 		}
    296 		switch r.Method {
    297 		case http.MethodPost:
    298 			tok, err := db.RegenerateAPIToken(r.Context(), uid)
    299 			if err != nil {
    300 				http.Error(w, "internal server error", http.StatusInternalServerError)
    301 				return
    302 			}
    303 			w.Header().Set("Content-Type", "application/json")
    304 			_ = json.NewEncoder(w).Encode(map[string]any{"api_token": tok})
    305 		default:
    306 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    307 		}
    308 	})
    309 
    310 	// Public listing endpoint removed; feeds normalized under /u:{username}(.format)
    311 
    312 	// Static web UI and page routes
    313 	static, err := StaticHandlerWithConfig(cfg)
    314 	if err != nil {
    315 		return nil, err
    316 	}
    317 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    318 		p := strings.TrimPrefix(r.URL.Path, "/")
    319 		// Root
    320 		if p == "" {
    321 			if uid, ok := userIDFromRequest(r, db); ok && uid > 0 {
    322 				if u, err := db.GetUserByID(r.Context(), uid); err == nil && u != nil {
    323 					http.Redirect(w, r, "/u:"+u.Username, http.StatusFound)
    324 					return
    325 				}
    326 			}
    327 			if html, err := RenderPage("login.html", cfg); err == nil {
    328 				html = applyCacheBust(html)
    329 				w.Header().Set("Content-Type", "text/html; charset=utf-8")
    330 				_, _ = w.Write(html)
    331 				return
    332 			}
    333 		}
    334 		// Only GET/HEAD beyond this point
    335 		if r.Method != http.MethodGet && r.Method != http.MethodHead {
    336 			http.NotFound(w, r)
    337 			return
    338 		}
    339 		// Settings page
    340 		if p == "settings" {
    341 			uid, ok := userIDFromRequest(r, db)
    342 			if !ok || uid == 0 {
    343 				http.Redirect(w, r, "/", http.StatusFound)
    344 				return
    345 			}
    346 			if html, err := RenderPage("settings.html", cfg); err == nil {
    347 				if tok, _, err := db.GetAPIToken(r.Context(), uid); err == nil {
    348 					inject := []byte("<script>window.MARKY_TOKEN=" + strconv.Quote(tok) + "</script>\n")
    349 					marker := []byte("<script src=\"/app.js\"></script>")
    350 					if i := bytes.Index(html, marker); i >= 0 {
    351 						out := make([]byte, 0, len(html)+len(inject))
    352 						out = append(out, html[:i]...)
    353 						out = append(out, inject...)
    354 						out = append(out, html[i:]...)
    355 						html = out
    356 					}
    357 				}
    358 				if u, err := db.GetUserByID(r.Context(), uid); err == nil && u != nil {
    359 					inject := []byte("<script>window.MARKY_ME=" + strconv.Quote(u.Username) + "</script>\n")
    360 					marker := []byte("<script src=\"/app.js\"></script>")
    361 					if i := bytes.Index(html, marker); i >= 0 {
    362 						out := make([]byte, 0, len(html)+len(inject))
    363 						out = append(out, html[:i]...)
    364 						out = append(out, inject...)
    365 						out = append(out, html[i:]...)
    366 						html = out
    367 					}
    368 				}
    369 				html = applyCacheBust(html)
    370 				w.Header().Set("Content-Type", "text/html; charset=utf-8")
    371 				_, _ = w.Write(html)
    372 				return
    373 			}
    374 		}
    375 		// Single-segment with trailing dot is invalid
    376 		if !strings.Contains(p, "/") && strings.HasSuffix(p, ".") {
    377 			http.NotFound(w, r)
    378 			return
    379 		}
    380 		// User page and feeds: /u:{username}[.rss|.json]
    381 		if strings.HasPrefix(p, "u:") {
    382 			rest := strings.TrimPrefix(p, "u:")
    383 			if rest == "" || strings.Contains(rest, "/") {
    384 				http.NotFound(w, r)
    385 				return
    386 			}
    387 			format := ""
    388 			uname := rest
    389 			if strings.HasSuffix(rest, ".rss") {
    390 				format = "rss"
    391 				uname = strings.TrimSuffix(uname, ".rss")
    392 			}
    393 			if strings.HasSuffix(rest, ".json") {
    394 				format = "json"
    395 				uname = strings.TrimSuffix(uname, ".json")
    396 			}
    397 			u, err := db.GetUserByUsername(r.Context(), uname)
    398 			if err != nil || u == nil {
    399 				http.NotFound(w, r)
    400 				return
    401 			}
    402 			if format != "" {
    403 				var items []Bookmark
    404 				if isOwnerAuthorized(r, u, db) {
    405 					items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0)
    406 				} else {
    407 					items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0)
    408 				}
    409 				if err != nil {
    410 					http.Error(w, "internal server error", http.StatusInternalServerError)
    411 					return
    412 				}
    413 				resp := make([]bookmarkResponse, 0, len(items))
    414 				for i := range items {
    415 					resp = append(resp, toRespWithArchive(r.Context(), db, &items[i]))
    416 				}
    417 				if format == "json" {
    418 					w.Header().Set("Content-Type", "application/json")
    419 					_ = json.NewEncoder(w).Encode(resp)
    420 				} else {
    421 					w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
    422 					_ = writeRSS(w, r, resp, fmt.Sprintf("%s's Bookmarks", u.Username))
    423 				}
    424 				return
    425 			}
    426 			// HTML user page
    427 			if html, err := RenderPage("user.html", cfg); err == nil {
    428 				// Embed MARKY_ME if session
    429 				if uid, ok := userIDFromRequest(r, db); ok && uid > 0 {
    430 					if cu, err := db.GetUserByID(r.Context(), uid); err == nil && cu != nil {
    431 						meInject := []byte("<script>window.MARKY_ME=" + strconv.Quote(cu.Username) + "</script>\n")
    432 						marker := []byte("<script src=\"/app.js\"></script>")
    433 						if i := bytes.Index(html, marker); i >= 0 {
    434 							out := make([]byte, 0, len(html)+len(meInject))
    435 							out = append(out, html[:i]...)
    436 							out = append(out, meInject...)
    437 							out = append(out, html[i:]...)
    438 							html = out
    439 						}
    440 					}
    441 				}
    442 				// Title
    443 				if ti := bytes.Index(html, []byte("<title>")); ti >= 0 {
    444 					if tj := bytes.Index(html[ti:], []byte("</title>")); tj > 0 {
    445 						start := ti + len("<title>")
    446 						end := ti + tj
    447 						nt := []byte(u.Username + " | Marky")
    448 						nb := make([]byte, 0, len(html)-(end-start)+len(nt))
    449 						nb = append(nb, html[:start]...)
    450 						nb = append(nb, nt...)
    451 						nb = append(nb, html[end:]...)
    452 						html = nb
    453 					}
    454 				}
    455 				// Pagination + search params
    456 				uq := r.URL.Query()
    457 				page := 1
    458 				if pstr := strings.TrimSpace(uq.Get("page")); pstr != "" {
    459 					if pv, e := strconv.Atoi(pstr); e == nil && pv > 0 {
    460 						page = pv
    461 					}
    462 				}
    463 				size := 20
    464 				if sstr := strings.TrimSpace(uq.Get("size")); sstr != "" {
    465 					if sv, e := strconv.Atoi(sstr); e == nil && (sv == 20 || sv == 50 || sv == 100) {
    466 						size = sv
    467 					}
    468 				}
    469 				offset := (page - 1) * size
    470 				query := strings.TrimSpace(uq.Get("q"))
    471 				vis := strings.TrimSpace(uq.Get("vis"))
    472 				if vis == "" {
    473 					vis = "all"
    474 				}
    475 
    476 				// Embed initial results for the current page (search-aware)
    477 				if query != "" {
    478 					var resp []bookmarkResponse
    479 					var listErr error
    480 					if isOwnerAuthorized(r, u, db) {
    481 						if strings.ToLower(vis) == "public" {
    482 							if hits, e := db.SearchPublicBookmarksWithExcerpt(r.Context(), u.ID, query, size, offset); e == nil {
    483 								resp = make([]bookmarkResponse, 0, len(hits))
    484 								for i := range hits {
    485 									h := hits[i]
    486 									br := toRespWithArchive(r.Context(), db, &h.Bookmark)
    487 									if h.Excerpt.Valid {
    488 										ex := h.Excerpt.String
    489 										br.Excerpt = &ex
    490 									}
    491 									resp = append(resp, br)
    492 								}
    493 							} else {
    494 								listErr = e
    495 							}
    496 						} else if strings.ToLower(vis) == "private" {
    497 							if hits, e := db.SearchPrivateBookmarksWithExcerpt(r.Context(), u.ID, query, size, offset); e == nil {
    498 								resp = make([]bookmarkResponse, 0, len(hits))
    499 								for i := range hits {
    500 									h := hits[i]
    501 									br := toRespWithArchive(r.Context(), db, &h.Bookmark)
    502 									if h.Excerpt.Valid {
    503 										ex := h.Excerpt.String
    504 										br.Excerpt = &ex
    505 									}
    506 									resp = append(resp, br)
    507 								}
    508 							} else {
    509 								listErr = e
    510 							}
    511 						} else {
    512 							if hits, e := db.SearchBookmarksWithExcerpt(r.Context(), u.ID, query, size, offset); e == nil {
    513 								resp = make([]bookmarkResponse, 0, len(hits))
    514 								for i := range hits {
    515 									h := hits[i]
    516 									br := toRespWithArchive(r.Context(), db, &h.Bookmark)
    517 									if h.Excerpt.Valid {
    518 										ex := h.Excerpt.String
    519 										br.Excerpt = &ex
    520 									}
    521 									resp = append(resp, br)
    522 								}
    523 							} else {
    524 								listErr = e
    525 							}
    526 						}
    527 					} else {
    528 						if hits, e := db.SearchPublicBookmarksWithExcerpt(r.Context(), u.ID, query, size, offset); e == nil {
    529 							resp = make([]bookmarkResponse, 0, len(hits))
    530 							for i := range hits {
    531 								h := hits[i]
    532 								br := toRespWithArchive(r.Context(), db, &h.Bookmark)
    533 								if h.Excerpt.Valid {
    534 									ex := h.Excerpt.String
    535 									br.Excerpt = &ex
    536 								}
    537 								resp = append(resp, br)
    538 							}
    539 						} else {
    540 							listErr = e
    541 						}
    542 					}
    543 					if listErr == nil {
    544 						if b, err := json.Marshal(resp); err == nil {
    545 							view := "public"
    546 							if isOwnerAuthorized(r, u, db) {
    547 								view = "self"
    548 							}
    549 							inject := []byte("<script>window.MARKY_INITIAL={view:'" + view + "',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n")
    550 							marker := []byte("<script src=\"/app.js\"></script>")
    551 							if i := bytes.Index(html, marker); i >= 0 {
    552 								out := make([]byte, 0, len(html)+len(inject))
    553 								out = append(out, html[:i]...)
    554 								out = append(out, inject...)
    555 								out = append(out, html[i:]...)
    556 								html = out
    557 							}
    558 						}
    559 					}
    560 				} else {
    561 					var items []Bookmark
    562 					if isOwnerAuthorized(r, u, db) {
    563 						switch strings.ToLower(vis) {
    564 						case "public":
    565 							items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset)
    566 						case "private":
    567 							items, err = db.ListPrivateBookmarks(r.Context(), u.ID, size, offset)
    568 						default:
    569 							items, err = db.ListBookmarks(r.Context(), u.ID, size, offset)
    570 						}
    571 					} else {
    572 						items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset)
    573 					}
    574 					if err == nil {
    575 						resp := make([]bookmarkResponse, 0, len(items))
    576 						for i := range items {
    577 							resp = append(resp, toRespWithArchive(r.Context(), db, &items[i]))
    578 						}
    579 						if b, err := json.Marshal(resp); err == nil {
    580 							view := "public"
    581 							if isOwnerAuthorized(r, u, db) {
    582 								view = "self"
    583 							}
    584 							inject := []byte("<script>window.MARKY_INITIAL={view:'" + view + "',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n")
    585 							marker := []byte("<script src=\"/app.js\"></script>")
    586 							if i := bytes.Index(html, marker); i >= 0 {
    587 								out := make([]byte, 0, len(html)+len(inject))
    588 								out = append(out, html[:i]...)
    589 								out = append(out, inject...)
    590 								out = append(out, html[i:]...)
    591 								html = out
    592 							}
    593 						}
    594 					}
    595 				}
    596 				// Embed pagination meta (search-aware)
    597 				total := 0
    598 				if query != "" {
    599 					if isOwnerAuthorized(r, u, db) {
    600 						switch strings.ToLower(vis) {
    601 						case "public":
    602 							if n, e := db.CountSearchPublicBookmarks(r.Context(), u.ID, query); e == nil {
    603 								total = n
    604 							}
    605 						case "private":
    606 							if n, e := db.CountSearchPrivateBookmarks(r.Context(), u.ID, query); e == nil {
    607 								total = n
    608 							}
    609 						default:
    610 							if n, e := db.CountSearchBookmarks(r.Context(), u.ID, query); e == nil {
    611 								total = n
    612 							}
    613 						}
    614 					} else {
    615 						if n, e := db.CountSearchPublicBookmarks(r.Context(), u.ID, query); e == nil {
    616 							total = n
    617 						}
    618 					}
    619 				} else {
    620 					if isOwnerAuthorized(r, u, db) {
    621 						switch strings.ToLower(vis) {
    622 						case "public":
    623 							if n, e := db.CountPublicBookmarks(r.Context(), u.ID); e == nil {
    624 								total = n
    625 							}
    626 						case "private":
    627 							if n, e := db.CountPrivateBookmarks(r.Context(), u.ID); e == nil {
    628 								total = n
    629 							}
    630 						default:
    631 							if n, e := db.CountBookmarks(r.Context(), u.ID); e == nil {
    632 								total = n
    633 							}
    634 						}
    635 					} else {
    636 						if n, e := db.CountPublicBookmarks(r.Context(), u.ID); e == nil {
    637 							total = n
    638 						}
    639 					}
    640 				}
    641 				totalPages := 1
    642 				if size > 0 {
    643 					totalPages = (total + size - 1) / size
    644 				}
    645 				if totalPages < 1 {
    646 					totalPages = 1
    647 				}
    648 				pageObj := map[string]any{"page": page, "size": size, "total": totalPages, "count": total, "username": u.Username, "owner": isOwnerAuthorized(r, u, db)}
    649 				if query != "" {
    650 					pageObj["query"] = query
    651 					pageObj["queryEncoded"] = url.QueryEscape(query)
    652 				}
    653 				if isOwnerAuthorized(r, u, db) {
    654 					pageObj["vis"] = vis
    655 				}
    656 				if pb, e := json.Marshal(pageObj); e == nil {
    657 					pinject := []byte("<script>window.MARKY_PAGINATION=" + string(pb) + "</script>\n")
    658 					marker := []byte("<script src=\"/app.js\"></script>")
    659 					if i := bytes.Index(html, marker); i >= 0 {
    660 						out := make([]byte, 0, len(html)+len(pinject))
    661 						out = append(out, html[:i]...)
    662 						out = append(out, pinject...)
    663 						out = append(out, html[i:]...)
    664 						html = out
    665 					}
    666 				}
    667 
    668 				html = applyCacheBust(html)
    669 				w.Header().Set("Content-Type", "text/html; charset=utf-8")
    670 				_, _ = w.Write(html)
    671 				return
    672 			}
    673 			http.NotFound(w, r)
    674 			return
    675 		}
    676 		// Legacy feeds like /{username}.json or /{username}.rss → 404
    677 		if !strings.Contains(p, "/") && (strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss")) {
    678 			http.NotFound(w, r)
    679 			return
    680 		}
    681 		// Unknown single segment that isn't a file → 404
    682 		if !strings.Contains(p, "/") && !strings.Contains(p, ".") {
    683 			http.NotFound(w, r)
    684 			return
    685 		}
    686 		// Fallback to static server
    687 		static.ServeHTTP(w, r)
    688 	})
    689 
    690 	// Shared handler for JSON and RSS
    691 	bookmarksHandler := func(w http.ResponseWriter, r *http.Request) {
    692 		switch r.Method {
    693 		case http.MethodPost:
    694 			uid, ok := userIDFromRequest(r, db)
    695 			if !ok {
    696 				http.Error(w, "unauthorized", http.StatusUnauthorized)
    697 				return
    698 			}
    699 			if r.Header.Get("Content-Type") != "" {
    700 				ct := r.Header.Get("Content-Type")
    701 				if ct != "application/json" && ct != "application/json; charset=utf-8" && ct != "application/json;charset=utf-8" {
    702 					if ct != "" && len(ct) >= 16 && ct[:16] != "application/json" {
    703 						http.Error(w, "unsupported media type", http.StatusUnsupportedMediaType)
    704 						return
    705 					}
    706 				}
    707 			}
    708 			decoder := json.NewDecoder(r.Body)
    709 			decoder.DisallowUnknownFields()
    710 			var req createBookmarkRequest
    711 			if err := decoder.Decode(&req); err != nil {
    712 				http.Error(w, "invalid JSON payload", http.StatusBadRequest)
    713 				return
    714 			}
    715 			if req.URL == "" {
    716 				http.Error(w, "url is required", http.StatusBadRequest)
    717 				return
    718 			}
    719 
    720 			if _, err := url.ParseRequestURI(req.URL); err != nil {
    721 				http.Error(w, "invalid url", http.StatusBadRequest)
    722 				return
    723 			}
    724 			// Strip common tracking params (utm*, gclid, fbclid, etc.) from the URL before processing
    725 			sanitizedURL := stripTrackingParams(req.URL)
    726 			req.URL = sanitizedURL
    727 			ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
    728 			defer cancel()
    729 			isHTML := IsHTMLURL(ctx, req.URL)
    730 			var fetchedTitle, txt string
    731 			var err error
    732 			if isHTML {
    733 				fetchedTitle, txt, err = TitleAndText(ctx, req.URL)
    734 				if err != nil {
    735 					// Fallback: if HTML fetch fails, try w3m directly to get plaintext
    736 					if path, err2 := exec.LookPath("w3m"); err2 == nil && path != "" {
    737 						if out, err3 := w3mDump(ctx, req.URL); err3 == nil && strings.TrimSpace(out) != "" {
    738 							txt = strings.TrimSpace(out)
    739 							fetchedTitle = ""
    740 							err = nil
    741 						}
    742 					}
    743 				}
    744 				if err != nil {
    745 					http.Error(w, "failed to archive URL", http.StatusUnprocessableEntity)
    746 					return
    747 				}
    748 			}
    749 			title := req.Title
    750 			if strings.TrimSpace(title) == "" {
    751 				if strings.TrimSpace(fetchedTitle) != "" {
    752 					title = fetchedTitle
    753 				} else if fn := fileNameFromURL(req.URL); fn != "" {
    754 					title = fn
    755 				} else {
    756 					title = req.URL
    757 				}
    758 			}
    759 			pub := false
    760 			if req.Public != nil {
    761 				pub = *req.Public
    762 			}
    763 			ref := ""
    764 			if req.Referrer != nil {
    765 				ref = *req.Referrer
    766 			}
    767 
    768 			if existing, err := db.GetBookmarkByURL(r.Context(), uid, req.URL); err != nil {
    769 				log.Printf("lookup by url error: %v", err)
    770 				http.Error(w, "internal server error", http.StatusInternalServerError)
    771 				return
    772 			} else if existing != nil {
    773 				// Merge metadata updates per request: title (if non-blank),
    774 				// description (if provided and non-blank; otherwise preserve),
    775 				// and public (if provided; otherwise preserve).
    776 				newTitle := existing.Title
    777 				if strings.TrimSpace(req.Title) != "" {
    778 					newTitle = req.Title
    779 				}
    780 				var newDesc *string
    781 				if req.Description == nil || strings.TrimSpace(*req.Description) == "" {
    782 					if existing.Description.Valid {
    783 						d := existing.Description.String
    784 						newDesc = &d
    785 					} else {
    786 						newDesc = nil
    787 					}
    788 				} else {
    789 					newDesc = req.Description
    790 				}
    791 				newPub := existing.Public
    792 				if req.Public != nil {
    793 					newPub = *req.Public
    794 				}
    795 
    796 				// Apply metadata update first so archive reindex uses latest fields.
    797 				// Include referrer when provided; otherwise preserve existing.
    798 				newRef := existing.Referrer.String
    799 				if req.Referrer != nil {
    800 					newRef = *req.Referrer
    801 				}
    802 				updated, err := db.UpdateBookmarkWithReferrer(r.Context(), existing.ID, existing.URL, newTitle, newDesc, newPub, newRef)
    803 				if err != nil || updated == nil {
    804 					if err != nil {
    805 						log.Printf("update existing bookmark error: %v", err)
    806 					}
    807 					http.Error(w, "internal server error", http.StatusInternalServerError)
    808 					return
    809 				}
    810 				if isHTML {
    811 					if err := db.SaveArchive(r.Context(), existing.ID, txt); err != nil {
    812 						log.Printf("save archive error: %v", err)
    813 						http.Error(w, "internal server error", http.StatusInternalServerError)
    814 						return
    815 					}
    816 				}
    817 				w.Header().Set("Content-Type", "application/json")
    818 				_ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, updated))
    819 				return
    820 			} else {
    821 				var created *Bookmark
    822 				if isHTML {
    823 					created, err = db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
    824 				} else {
    825 					created, err = db.CreateBookmarkNoArchive(r.Context(), uid, req.URL, title, req.Description, pub)
    826 				}
    827 				if err != nil {
    828 					log.Printf("create bookmark with archive error: %v", err)
    829 					http.Error(w, "internal server error", http.StatusInternalServerError)
    830 					return
    831 				}
    832 				if strings.TrimSpace(ref) != "" {
    833 					// Persist referrer via a focused update to avoid changing other fields
    834 					_, _ = db.sql.ExecContext(r.Context(), `UPDATE bookmarks SET referrer = ? WHERE id = ?`, ref, created.ID)
    835 					// Reload to include referrer in response
    836 					if b2, _ := db.GetBookmark(r.Context(), created.ID); b2 != nil {
    837 						created = b2
    838 					}
    839 				}
    840 				w.Header().Set("Content-Type", "application/json")
    841 				w.WriteHeader(http.StatusCreated)
    842 				_ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, created))
    843 			}
    844 
    845 		case http.MethodGet:
    846 			uid, _ := userIDFromRequest(r, db)
    847 			if uid == 0 {
    848 				http.Error(w, "unauthorized", http.StatusUnauthorized)
    849 				return
    850 			}
    851 			q := r.URL.Query()
    852 			limit := 50
    853 			offset := 0
    854 			query := strings.TrimSpace(q.Get("q"))
    855 			if s := q.Get("limit"); s != "" {
    856 				if v, err := strconv.Atoi(s); err == nil && v > 0 {
    857 					if v > 200 {
    858 						v = 200
    859 					}
    860 					limit = v
    861 				} else if s != "" {
    862 					http.Error(w, "invalid limit", http.StatusBadRequest)
    863 					return
    864 				}
    865 			}
    866 			if s := q.Get("offset"); s != "" {
    867 				if v, err := strconv.Atoi(s); err == nil && v >= 0 {
    868 					offset = v
    869 				} else {
    870 					http.Error(w, "invalid offset", http.StatusBadRequest)
    871 					return
    872 				}
    873 			}
    874 
    875 			var resp []bookmarkResponse
    876 			if query != "" {
    877 				hits, err := db.SearchBookmarksWithExcerpt(r.Context(), uid, query, limit, offset)
    878 				if err != nil {
    879 					log.Printf("list bookmarks error: %v", err)
    880 					http.Error(w, "internal server error", http.StatusInternalServerError)
    881 					return
    882 				}
    883 				resp = make([]bookmarkResponse, 0, len(hits))
    884 				for i := range hits {
    885 					h := hits[i]
    886 					br := toRespWithArchive(r.Context(), db, &h.Bookmark)
    887 					if h.Excerpt.Valid {
    888 						ex := h.Excerpt.String
    889 						br.Excerpt = &ex
    890 					}
    891 					resp = append(resp, br)
    892 				}
    893 			} else {
    894 				items, err := db.ListBookmarks(r.Context(), uid, limit, offset)
    895 				if err != nil {
    896 					log.Printf("list bookmarks error: %v", err)
    897 					http.Error(w, "internal server error", http.StatusInternalServerError)
    898 					return
    899 				}
    900 				resp = make([]bookmarkResponse, 0, len(items))
    901 				for i := range items {
    902 					b := items[i]
    903 					resp = append(resp, toRespWithArchive(r.Context(), db, &b))
    904 				}
    905 			}
    906 
    907 			wantsRSS := r.URL.Path == "/bookmarks.xml" || strings.EqualFold(r.URL.Query().Get("format"), "rss") || strings.Contains(r.Header.Get("Accept"), "application/rss+xml")
    908 			if wantsRSS {
    909 				w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
    910 				feedTitle := "Bookmarks"
    911 				if uid > 0 {
    912 					if u, err := db.GetUserByID(r.Context(), uid); err == nil && u != nil {
    913 						feedTitle = fmt.Sprintf("%s's Bookmarks", u.Username)
    914 					}
    915 				}
    916 				if err := writeRSS(w, r, resp, feedTitle); err != nil {
    917 					log.Printf("write rss error: %v", err)
    918 					http.Error(w, "internal server error", http.StatusInternalServerError)
    919 				}
    920 				return
    921 			}
    922 			w.Header().Set("Content-Type", "application/json")
    923 			_ = json.NewEncoder(w).Encode(resp)
    924 
    925 		default:
    926 			w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
    927 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    928 		}
    929 	}
    930 	mux.HandleFunc("/bookmarks", bookmarksHandler)
    931 	mux.HandleFunc("/bookmarks.xml", bookmarksHandler)
    932 
    933 	// Archive, PUT, DELETE under /bookmarks/{id}
    934 	mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
    935 		path := r.URL.Path
    936 		rest := strings.TrimPrefix(path, "/bookmarks/")
    937 		if rest == "" || strings.HasPrefix(rest, "/") {
    938 			http.NotFound(w, r)
    939 			return
    940 		}
    941 		if strings.HasSuffix(rest, "/archive") {
    942 			idPart := strings.TrimSuffix(rest, "/archive")
    943 			idPart = strings.TrimSuffix(idPart, "/")
    944 			id64, err := strconv.ParseInt(idPart, 10, 64)
    945 			if err != nil || id64 <= 0 {
    946 				http.Error(w, "invalid id", http.StatusBadRequest)
    947 				return
    948 			}
    949 			switch r.Method {
    950 			case http.MethodGet:
    951 				// Enforce public/private visibility
    952 				b, err := db.GetBookmark(r.Context(), id64)
    953 				if err != nil {
    954 					log.Printf("lookup bookmark error: %v", err)
    955 					http.Error(w, "internal server error", http.StatusInternalServerError)
    956 					return
    957 				}
    958 				if b == nil {
    959 					http.NotFound(w, r)
    960 					return
    961 				}
    962 				uid, _ := userIDFromRequest(r, db)
    963 				isOwner := (uid > 0 && uid == b.UserID)
    964 				if !b.Public && !isOwner {
    965 					http.NotFound(w, r)
    966 					return
    967 				}
    968 
    969 				content, _, ok, err := db.GetArchive(r.Context(), id64)
    970 				if err != nil {
    971 					log.Printf("get archive error: %v", err)
    972 					http.Error(w, "internal server error", http.StatusInternalServerError)
    973 					return
    974 				}
    975 				if !ok {
    976 					// Only the owner may fetch on-demand
    977 					if isOwner && (r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true") {
    978 						ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
    979 						defer cancel()
    980 						if !IsHTMLURL(ctx, b.URL) {
    981 							http.NotFound(w, r)
    982 							return
    983 						}
    984 						txt, err := ArchiveToText(ctx, b.URL)
    985 						if err != nil {
    986 							http.Error(w, "failed to fetch archive", http.StatusUnprocessableEntity)
    987 							return
    988 						}
    989 						if err := db.SaveArchive(ctx, id64, txt); err != nil {
    990 							log.Printf("save archive error: %v", err)
    991 							http.Error(w, "internal server error", http.StatusInternalServerError)
    992 							return
    993 						}
    994 						content = txt
    995 						ok = true
    996 					}
    997 				}
    998 				if !ok {
    999 					http.NotFound(w, r)
   1000 					return
   1001 				}
   1002 				w.Header().Set("Content-Type", "text/plain; charset=utf-8")
   1003 				_, _ = w.Write([]byte(content))
   1004 			default:
   1005 				w.Header().Set("Allow", http.MethodGet)
   1006 				http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
   1007 			}
   1008 			return
   1009 		}
   1010 
   1011 		idPart := strings.TrimSuffix(rest, "/")
   1012 		if strings.Contains(idPart, "/") {
   1013 			http.NotFound(w, r)
   1014 			return
   1015 		}
   1016 		id64, err := strconv.ParseInt(idPart, 10, 64)
   1017 		if err != nil || id64 <= 0 {
   1018 			http.Error(w, "invalid id", http.StatusBadRequest)
   1019 			return
   1020 		}
   1021 		switch r.Method {
   1022 		case http.MethodPut:
   1023 			if _, ok := userIDFromRequest(r, db); !ok {
   1024 				http.Error(w, "unauthorized", http.StatusUnauthorized)
   1025 				return
   1026 			}
   1027 			decoder := json.NewDecoder(r.Body)
   1028 			decoder.DisallowUnknownFields()
   1029 			var req createBookmarkRequest
   1030 			if err := decoder.Decode(&req); err != nil {
   1031 				http.Error(w, "invalid JSON payload", http.StatusBadRequest)
   1032 				return
   1033 			}
   1034 			if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" {
   1035 				http.Error(w, "url and title are required", http.StatusBadRequest)
   1036 				return
   1037 			}
   1038 			// Load existing to compare URL and preserve fields.
   1039 			existing, err := db.GetBookmark(r.Context(), id64)
   1040 			if err != nil {
   1041 				log.Printf("get bookmark error: %v", err)
   1042 				http.Error(w, "internal server error", http.StatusInternalServerError)
   1043 				return
   1044 			}
   1045 			if existing == nil {
   1046 				http.NotFound(w, r)
   1047 				return
   1048 			}
   1049 			desc := req.Description
   1050 			var pub bool
   1051 			if desc == nil && existing.Description.Valid {
   1052 				d := existing.Description.String
   1053 				desc = &d
   1054 			}
   1055 			if req.Public == nil {
   1056 				pub = existing.Public
   1057 			}
   1058 			if req.Public != nil {
   1059 				pub = *req.Public
   1060 			}
   1061 
   1062 			// If URL changed, try to refetch archive for the new URL before updating.
   1063 			urlChanged := strings.TrimSpace(req.URL) != existing.URL
   1064 			var newArchive string
   1065 			var shouldArchive bool
   1066 			if urlChanged {
   1067 				ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
   1068 				defer cancel()
   1069 				if IsHTMLURL(ctx, req.URL) {
   1070 					shouldArchive = true
   1071 					_, txt, err := TitleAndText(ctx, req.URL)
   1072 					if err != nil {
   1073 						http.Error(w, "failed to archive URL", http.StatusUnprocessableEntity)
   1074 						return
   1075 					}
   1076 					newArchive = txt
   1077 				} else {
   1078 					shouldArchive = false
   1079 				}
   1080 			}
   1081 
   1082 			// Sanitize URL and include referrer when provided; otherwise preserve existing value
   1083 			req.URL = stripTrackingParams(req.URL)
   1084 			newRef := existing.Referrer.String
   1085 			if req.Referrer != nil {
   1086 				newRef = *req.Referrer
   1087 			}
   1088 			updated, err := db.UpdateBookmarkWithReferrer(r.Context(), id64, req.URL, req.Title, desc, pub, newRef)
   1089 			if err != nil {
   1090 				log.Printf("update bookmark error: %v", err)
   1091 				http.Error(w, "internal server error", http.StatusInternalServerError)
   1092 				return
   1093 			}
   1094 			if updated == nil {
   1095 				http.NotFound(w, r)
   1096 				return
   1097 			}
   1098 			// Apply archive changes if URL changed
   1099 			if urlChanged {
   1100 				if shouldArchive {
   1101 					if err := db.SaveArchive(r.Context(), id64, newArchive); err != nil {
   1102 						log.Printf("save archive after URL change error: %v", err)
   1103 						http.Error(w, "internal server error", http.StatusInternalServerError)
   1104 						return
   1105 					}
   1106 				} else {
   1107 					if err := db.DeleteArchive(r.Context(), id64); err != nil {
   1108 						log.Printf("delete archive after URL change error: %v", err)
   1109 						http.Error(w, "internal server error", http.StatusInternalServerError)
   1110 						return
   1111 					}
   1112 				}
   1113 			}
   1114 			w.Header().Set("Content-Type", "application/json")
   1115 			_ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, updated))
   1116 		case http.MethodDelete:
   1117 			if _, ok := userIDFromRequest(r, db); !ok {
   1118 				http.Error(w, "unauthorized", http.StatusUnauthorized)
   1119 				return
   1120 			}
   1121 			ok, err := db.DeleteBookmark(r.Context(), id64)
   1122 			if err != nil {
   1123 				log.Printf("delete bookmark error: %v", err)
   1124 				http.Error(w, "internal server error", http.StatusInternalServerError)
   1125 				return
   1126 			}
   1127 			if !ok {
   1128 				http.NotFound(w, r)
   1129 				return
   1130 			}
   1131 			w.WriteHeader(http.StatusNoContent)
   1132 		default:
   1133 			w.Header().Set("Allow", http.MethodPut+", "+http.MethodDelete+", "+http.MethodGet)
   1134 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
   1135 		}
   1136 	})
   1137 
   1138 	return mux, nil
   1139 }
   1140 
   1141 // originFromRequest determines the request origin (scheme://host), honoring
   1142 // standard reverse-proxy headers when present. This supports deployments
   1143 // behind TLS-terminating proxies like Cloudflare or nginx.
   1144 func originFromRequest(r *http.Request) string {
   1145 	// CLI-configured override takes highest precedence
   1146 	if baseURLOverride != "" {
   1147 		return baseURLOverride
   1148 	}
   1149 	// Explicit override via environment, e.g. MARKY_BASE_URL=https://example.com
   1150 	if base := strings.TrimSpace(os.Getenv("MARKY_BASE_URL")); base != "" {
   1151 		return strings.TrimRight(base, "/")
   1152 	}
   1153 	// Default scheme based on connection
   1154 	scheme := "http"
   1155 	if r.TLS != nil {
   1156 		scheme = "https"
   1157 	}
   1158 
   1159 	// Prefer standard proxy headers when present
   1160 	if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" {
   1161 		// May be a comma-separated list; take the first value
   1162 		first := strings.ToLower(strings.TrimSpace(strings.Split(xfp, ",")[0]))
   1163 		if first == "https" || first == "http" {
   1164 			scheme = first
   1165 		}
   1166 	} else if cfv := r.Header.Get("Cf-Visitor"); cfv != "" { // Cloudflare
   1167 		l := strings.ToLower(cfv)
   1168 		if strings.Contains(l, "\"https\"") || strings.Contains(l, "https") { // {"scheme":"https"}
   1169 			scheme = "https"
   1170 		} else if strings.Contains(l, "http") {
   1171 			scheme = "http"
   1172 		}
   1173 	} else if xs := r.Header.Get("X-Forwarded-Scheme"); xs != "" { // some proxies
   1174 		first := strings.ToLower(strings.TrimSpace(strings.Split(xs, ",")[0]))
   1175 		if first == "https" || first == "http" {
   1176 			scheme = first
   1177 		}
   1178 	} else if xfssl := r.Header.Get("X-Forwarded-Ssl"); xfssl != "" { // nginx/common: on/off
   1179 		if strings.EqualFold(strings.TrimSpace(strings.Split(xfssl, ",")[0]), "on") {
   1180 			scheme = "https"
   1181 		}
   1182 	} else if fwd := r.Header.Get("Forwarded"); fwd != "" {
   1183 		// Parse Forwarded header (RFC 7239), look for proto=
   1184 		// Example: Forwarded: for=1.2.3.4; proto=https; host=example.com
   1185 		// The header may contain multiple entries separated by commas; use the first
   1186 		first := strings.Split(fwd, ",")[0]
   1187 		for _, part := range strings.Split(first, ";") {
   1188 			kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
   1189 			if len(kv) == 2 && strings.ToLower(strings.TrimSpace(kv[0])) == "proto" {
   1190 				v := strings.Trim(kv[1], "\"')")
   1191 				v = strings.ToLower(strings.TrimSpace(v))
   1192 				if v == "https" || v == "http" {
   1193 					scheme = v
   1194 				}
   1195 				break
   1196 			}
   1197 		}
   1198 	}
   1199 
   1200 	host := r.Host
   1201 	if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" {
   1202 		host = strings.TrimSpace(strings.Split(xfh, ",")[0])
   1203 	} else if fwd := r.Header.Get("Forwarded"); fwd != "" {
   1204 		// Parse host= from Forwarded header
   1205 		first := strings.Split(fwd, ",")[0]
   1206 		for _, part := range strings.Split(first, ";") {
   1207 			kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
   1208 			if len(kv) == 2 && strings.ToLower(strings.TrimSpace(kv[0])) == "host" {
   1209 				v := strings.Trim(kv[1], "\"')")
   1210 				if v != "" {
   1211 					host = v
   1212 				}
   1213 				break
   1214 			}
   1215 		}
   1216 	}
   1217 	return scheme + "://" + host
   1218 }
   1219 
   1220 // userIDFromRequest resolves the user ID using either a signed session cookie
   1221 // or HTTP Basic auth where the username is the account username and the
   1222 // password is the API token.
   1223 func userIDFromRequest(r *http.Request, db *DB) (int64, bool) {
   1224 	if uid, ok := CurrentUserID(r); ok {
   1225 		return uid, true
   1226 	}
   1227 	if u, p, ok := r.BasicAuth(); ok {
   1228 		if u == "" || p == "" {
   1229 			return 0, false
   1230 		}
   1231 		usr, err := db.GetUserByUsername(r.Context(), u)
   1232 		if err != nil || usr == nil {
   1233 			return 0, false
   1234 		}
   1235 		if usr.APIToken != nil && *usr.APIToken == p {
   1236 			return usr.ID, true
   1237 		}
   1238 	}
   1239 	return 0, false
   1240 }
   1241 
   1242 // isOwnerAuthorized checks if the request authenticates as the given user via
   1243 // (1) session cookie, (2) HTTP Basic (username + API token), (3) X-API-Token
   1244 // header, or (4) token query parameter.
   1245 func isOwnerAuthorized(r *http.Request, u *User, db *DB) bool {
   1246 	if uid, ok := CurrentUserID(r); ok && uid == u.ID {
   1247 		return true
   1248 	}
   1249 	if user, pass, ok := r.BasicAuth(); ok {
   1250 		if user == u.Username && u.APIToken != nil && *u.APIToken == pass {
   1251 			return true
   1252 		}
   1253 	}
   1254 	if tok := r.Header.Get("X-API-Token"); tok != "" && u.APIToken != nil && *u.APIToken == tok {
   1255 		return true
   1256 	}
   1257 	if tok := r.URL.Query().Get("token"); tok != "" && u.APIToken != nil && *u.APIToken == tok {
   1258 		return true
   1259 	}
   1260 	return false
   1261 }
   1262 
   1263 type createBookmarkRequest struct {
   1264 	URL         string  `json:"url"`
   1265 	Title       string  `json:"title"`
   1266 	Description *string `json:"description,omitempty"`
   1267 	Public      *bool   `json:"public,omitempty"`
   1268 	Referrer    *string `json:"referrer,omitempty"`
   1269 }
   1270 
   1271 type bookmarkResponse struct {
   1272 	ID          int64     `json:"id"`
   1273 	URL         string    `json:"url"`
   1274 	Title       string    `json:"title"`
   1275 	Description *string   `json:"description,omitempty"`
   1276 	CreatedAt   time.Time `json:"created_at"`
   1277 	UpdatedAt   time.Time `json:"updated_at"`
   1278 	Excerpt     *string   `json:"excerpt,omitempty"`
   1279 	Public      bool      `json:"public"`
   1280 	HasArchive  bool      `json:"has_archive"`
   1281 	Referrer    *string   `json:"referrer,omitempty"`
   1282 }
   1283 
   1284 func toResp(b *Bookmark) bookmarkResponse {
   1285 	var desc *string
   1286 	if b.Description.Valid {
   1287 		desc = &b.Description.String
   1288 	}
   1289 	var ref *string
   1290 	if b.Referrer.Valid && strings.TrimSpace(b.Referrer.String) != "" {
   1291 		s := b.Referrer.String
   1292 		ref = &s
   1293 	}
   1294 	return bookmarkResponse{
   1295 		ID:          b.ID,
   1296 		URL:         b.URL,
   1297 		Title:       b.Title,
   1298 		Description: desc,
   1299 		CreatedAt:   b.CreatedAt,
   1300 		UpdatedAt:   b.UpdatedAt,
   1301 		Public:      b.Public,
   1302 		Referrer:    ref,
   1303 	}
   1304 }
   1305 
   1306 func toRespWithArchive(ctx context.Context, db *DB, b *Bookmark) bookmarkResponse {
   1307 	br := toResp(b)
   1308 	if ok, err := db.HasArchive(ctx, b.ID); err == nil {
   1309 		br.HasArchive = ok
   1310 	}
   1311 	return br
   1312 }
   1313 
   1314 // stripTrackingParams removes common tracking parameters from a URL's query string.
   1315 // It strips keys that start with "utm" (case-insensitive) and a denylist of known trackers
   1316 // such as gclid, fbclid, ueid, mc_cid, mc_eid, mkt_tok, _hsmi, _hsenc, igshid, etc.
   1317 func stripTrackingParams(raw string) string {
   1318 	u, err := url.Parse(raw)
   1319 	if err != nil {
   1320 		return raw
   1321 	}
   1322 	if u.RawQuery == "" {
   1323 		return raw
   1324 	}
   1325 	q := u.Query()
   1326 	changed := false
   1327 	deny := map[string]struct{}{
   1328 		// Google / Facebook / Mailchimp / HubSpot / others
   1329 		"gclid": {}, "fbclid": {}, "ueid": {},
   1330 		"mc_cid": {}, "mc_eid": {},
   1331 		"mkt_tok": {}, "_hsmi": {}, "_hsenc": {},
   1332 		"igshid": {},
   1333 		// common generic trackers
   1334 		"utm": {}, // handled by prefix match, keep for clarity
   1335 	}
   1336 	for key := range q {
   1337 		lk := strings.ToLower(key)
   1338 		if strings.HasPrefix(lk, "utm") {
   1339 			q.Del(key)
   1340 			changed = true
   1341 			continue
   1342 		}
   1343 		if _, ok := deny[lk]; ok {
   1344 			q.Del(key)
   1345 			changed = true
   1346 			continue
   1347 		}
   1348 	}
   1349 	if !changed {
   1350 		return raw
   1351 	}
   1352 	u.RawQuery = q.Encode()
   1353 	// If nothing left, ensure the URL doesn't end with a bare '?'
   1354 	return u.String()
   1355 }
   1356 
   1357 // RSS structures and writer
   1358 type rssFeed struct {
   1359 	XMLName xml.Name   `xml:"rss"`
   1360 	Version string     `xml:"version,attr"`
   1361 	Channel rssChannel `xml:"channel"`
   1362 }
   1363 type rssChannel struct {
   1364 	Title       string    `xml:"title"`
   1365 	Link        string    `xml:"link"`
   1366 	Description string    `xml:"description"`
   1367 	Items       []rssItem `xml:"item"`
   1368 }
   1369 type rssItem struct {
   1370 	Title       string `xml:"title"`
   1371 	Link        string `xml:"link"`
   1372 	GUID        string `xml:"guid"`
   1373 	Description string `xml:"description,omitempty"`
   1374 	PubDate     string `xml:"pubDate"`
   1375 }
   1376 
   1377 func writeRSS(w http.ResponseWriter, r *http.Request, items []bookmarkResponse, title string) error {
   1378 	chanTitle := strings.TrimSpace(title)
   1379 	if chanTitle == "" {
   1380 		chanTitle = "Bookmarks"
   1381 	}
   1382 	chanLink := ""
   1383 	if r != nil && r.Host != "" {
   1384 		chanLink = strings.TrimRight(originFromRequest(r), "/") + "/bookmarks"
   1385 	}
   1386 	chanDesc := chanTitle
   1387 	out := rssFeed{Version: "2.0", Channel: rssChannel{Title: chanTitle, Link: chanLink, Description: chanDesc}}
   1388 	out.Channel.Items = make([]rssItem, 0, len(items))
   1389 	for _, b := range items {
   1390 		desc := ""
   1391 		if b.Excerpt != nil && *b.Excerpt != "" {
   1392 			desc = *b.Excerpt
   1393 		} else if b.Description != nil {
   1394 			desc = *b.Description
   1395 		}
   1396 		guid := fmt.Sprintf("marky:%d", b.ID)
   1397 		if chanLink != "" {
   1398 			guid = fmt.Sprintf("%s/%d", strings.TrimRight(chanLink, "/"), b.ID)
   1399 		}
   1400 		out.Channel.Items = append(out.Channel.Items, rssItem{
   1401 			Title:       b.Title,
   1402 			Link:        b.URL,
   1403 			GUID:        guid,
   1404 			Description: desc,
   1405 			PubDate:     b.UpdatedAt.Format(time.RFC1123Z),
   1406 		})
   1407 	}
   1408 	enc := xml.NewEncoder(w)
   1409 	enc.Indent("", "  ")
   1410 	return enc.Encode(out)
   1411 }
   1412 
   1413 // fileNameFromURL extracts a human-friendly filename from a URL path.
   1414 // It returns an empty string when no filename can be determined.
   1415 func fileNameFromURL(raw string) string {
   1416 	u, err := url.Parse(raw)
   1417 	if err != nil || u == nil {
   1418 		return ""
   1419 	}
   1420 	p := strings.TrimSuffix(u.Path, "/")
   1421 	base := path.Base(p)
   1422 	if base == "." || base == "/" || base == "" {
   1423 		return ""
   1424 	}
   1425 	if un, err := url.PathUnescape(base); err == nil && un != "" {
   1426 		base = un
   1427 	}
   1428 	return base
   1429 }