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 "<", "<", 146 ">", ">", 147 `"`, """, 148 "'", "'", 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 }