marky

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

commit 1bea21100843fda05707fad0b7a69977f4bd4654
parent 809baab415f7c2462a6cbaf3db303393cb7733f2
Author: David Eisinger <[email protected]>
Date:   Tue,  7 Oct 2025 15:08:31 -0400

Bookmarklet

Diffstat:
MREADME.md | 6++++++
Minternal/api.go | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/static.go | 6++++++
Ainternal/web/bookmarklet.html | 46++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/header.html | 37+++++++++++++++++++++++++++++++++++++
Minternal/web/index.html | 38+-------------------------------------
6 files changed, 253 insertions(+), 37 deletions(-)

diff --git a/README.md b/README.md @@ -182,6 +182,12 @@ Examples (include private when authenticated): - Header: `curl -H 'X-API-Token: API_TOKEN' http://localhost:8080/dce.json` - Query: `curl 'http://localhost:8080/dce.json?token=API_TOKEN'` +### Bookmarklet + +- Visit `/bookmarklet` while logged in to get a “Save to Marky” bookmarklet you can drag to your bookmarks bar. +- Clicking the bookmarklet on any page opens a small popup with URL and title prefilled; you can adjust description and toggle public/private. +- The popup submits to `/bookmarks` using your API token (embedded for your session). + ### Public JSON listing - `GET /u/{username}` → JSON array of that user’s public bookmarks (always public‑only). diff --git a/internal/api.go b/internal/api.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "io/fs" "log" "net/http" "net/url" @@ -90,6 +91,162 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { http.Error(w, "unauthorized", http.StatusUnauthorized) }) + // Bookmarklet generator and popup form + mux.HandleFunc("/bookmarklet", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + uid, ok := CurrentUserID(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + user, err := db.GetUserByID(r.Context(), uid) + if err != nil || user == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + // Resolve origin to use in links opened by the bookmarklet + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + origin := scheme + "://" + r.Host + // Fetch API token (if present) for interpolation + token, present, err := db.GetAPIToken(r.Context(), uid) + if err != nil { + log.Printf("get api token error: %v", err) + } + // Determine mode: installer (no u param) or popup (with u) + q := r.URL.Query() + pageURL := strings.TrimSpace(q.Get("u")) + // Helpers for embedding JSON strings into HTML templates + enc := func(s string) string { b, _ := json.Marshal(s); return string(b) } + if pageURL == "" { + // Installer page: assemble from template with shared header + w.Header().Set("Content-Type", "text/html; charset=utf-8") + headTpl, err := fs.ReadFile(webAssets, "web/header.html") + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + headerHTML := string(headTpl) + pageTpl, err := fs.ReadFile(webAssets, "web/bookmarklet.html") + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + page := string(pageTpl) + page = strings.ReplaceAll(page, "{{HEADER}}", headerHTML) + page = strings.ReplaceAll(page, "{{ORIGIN}}", origin) + _, _ = w.Write([]byte(page)) + return + } + // Popup form page + pageTitle := q.Get("t") + desc := q.Get("d") + pub := "" + if q.Get("p") != "" { + pub = q.Get("p") + } + // Escape text content for HTML text node + htmlEscape := func(s string) string { + r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;") + return r.Replace(s) + } + jsToken := "null" + if present { + jsToken = enc(token) + } + jsUser := enc(user.Username) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `<!doctype html> + <html lang="en"> + <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Add Bookmark · Marky</title> + <style> + body{font:15px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:16px;color:#222} + label{display:block;margin:8px 0} + input[type=text], textarea{width:100%%;box-sizing:border-box;padding:8px;border:1px solid #ccc;border-radius:6px} + textarea{min-height:100px;resize:vertical} + .actions{margin-top:12px;display:flex;gap:8px} + button{background:#222;color:#fff;border:0;border-radius:6px;padding:8px 12px;cursor:pointer} + button.secondary{background:#eee;color:#222} + .small{color:#666;font-size:12px;margin-top:6px} + .success{color:green} + .error{color:#b00020} + </style> + <h2>Add Bookmark</h2> + <form id="f"> + <label>URL + <input id="url" type="text" required value=%[1]s> + </label> + <label>Title + <input id="title" type="text" required value=%[2]s> + </label> + <label>Description + <textarea id="desc">%[3]s</textarea> + </label> + <label><input id="pub" type="checkbox" %[4]s> Public</label> + <div class="actions"> + <button type="submit">Save</button> + <button class="secondary" type="button" onclick="window.close()">Cancel</button> + </div> + <p class="small">Saves to %[5]s</p> + <p id="msg" class="small"></p> + </form> + <script> + (function(){ + const token = %[6]s; + const uname = %[7]s; + const form = document.getElementById('f'); + const msg = document.getElementById('msg'); + form.addEventListener('submit', async function(e){ + e.preventDefault(); + msg.textContent = ''; + const payload = { + url: document.getElementById('url').value.trim(), + title: document.getElementById('title').value.trim(), + description: (function(){const v=document.getElementById('desc').value; return v.trim() ? v : null;})(), + public: document.getElementById('pub').checked + }; + try { + const res = await fetch('%[5]s/bookmarks', { + method: 'POST', + headers: (function(){ + const h = {'Content-Type':'application/json'}; + if (token && uname) { + const creds = btoa(uname + ':' + token); + h['Authorization'] = 'Basic ' + creds; + } + return h; + })(), + body: JSON.stringify(payload) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || ('HTTP '+res.status)); + } + msg.textContent = 'Saved! You can close this window.'; + msg.className = 'small success'; + setTimeout(function(){ window.close(); }, 800); + } catch (err) { + msg.textContent = 'Error: '+ err.message; + msg.className = 'small error'; + } + }); + })(); + </script> + </html>`, enc(pageURL), enc(pageTitle), htmlEscape(desc), func() string { + if strings.EqualFold(pub, "1") || strings.EqualFold(pub, "true") { + return "checked" + } + return "" + }(), origin, jsToken, jsUser) + }) + // API token endpoints for logged-in user mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) { uid, ok := CurrentUserID(r) diff --git a/internal/static.go b/internal/static.go @@ -28,6 +28,12 @@ func StaticHandlerWithConfig(cfg HandlerConfig) (http.Handler, error) { if err != nil { return nil, err } + // Inject shared header into index.html placeholder + if hb, err := fs.ReadFile(sub, "header.html"); err == nil { + if bytes.Contains(indexBytes, []byte("{{HEADER}}")) { + indexBytes = bytes.ReplaceAll(indexBytes, []byte("{{HEADER}}"), hb) + } + } // Inject a small config script when features are toggled. if cfg.DisableSignup { marker := []byte("<script src=\"/app.js\"></script>") diff --git a/internal/web/bookmarklet.html b/internal/web/bookmarklet.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Marky Bookmarklet</title> + <link rel="stylesheet" href="/styles.css" /> + <style> + a.btn{display:inline-block;background:#222;color:#fff;text-decoration:none;padding:10px 14px;border-radius:6px} + .notice{margin-top:12px;color:#666} + </style> + <script src="/vendor/vue.global.prod.js"></script> + </head> + <body> + <div id="hdr">{{HEADER}}</div> + <main class="container"> + <h2>Save to Marky</h2> + <p>Drag this to your bookmarks bar:</p> + <p> + <a class="btn" href="javascript:(function(){try{var d=document;var s=encodeURIComponent;var u=location.href;var t=document.title;var sel='';if(d.getSelection){sel=d.getSelection().toString()}else if(window.getSelection){sel=window.getSelection().toString()}else if(d.selection){sel=d.selection.createRange().text}var win=window.open('{{ORIGIN}}/bookmarklet?u='+s(u)+'&t='+s(t)+'&d='+s(sel),'marky','width=520,height=520');if(win){win.focus()}}catch(e){alert('Could not open Marky: '+e)}})();">Save to Marky</a> + </p> + <div class="notice"> + <p>When clicked on any page, this opens a small form prefilled with the page URL and title. On submit, it saves to your Marky account.</p> + </div> + </main> + <script> + const { createApp } = window.Vue; + createApp({ + data(){ return { me:null, api:{ present:false, value:'', show:false, busy:false, copied:false } } }, + async mounted(){ + try { const r = await fetch('/me'); if (r.ok) this.me = await r.json(); } catch {} + if (this.me && this.me.username) { + try { const r = await fetch('/api/token'); if (r.ok) { const j = await r.json(); this.api.present = !!j.present; } } catch {} + } + }, + methods:{ + async logout(){ await fetch('/logout', { method:'POST' }).catch(()=>{}); location.replace('/'); }, + async revealApiKey(){ this.api.busy=true; try { const r=await fetch('/api/token?reveal=1'); if(!r.ok) throw 0; const j=await r.json(); this.api.present=true; this.api.value=j.api_token||''; this.api.show=true; this.api.copied=false; } finally { this.api.busy=false; } }, + async generateApiKey(){ this.api.busy=true; try { const r=await fetch('/api/token',{method:'POST'}); if(!r.ok) throw 0; const j=await r.json(); this.api.present=true; this.api.value=j.api_token||''; this.api.show=true; this.api.copied=false; } finally { this.api.busy=false; } }, + async regenerateApiKey(){ return this.generateApiKey(); }, + async copyApiKey(){ try { await navigator.clipboard.writeText(this.api.value||''); this.api.copied=true; setTimeout(()=>{ this.api.copied=false; },1500); } catch(e){ alert('Failed to copy token'); } }, + } + }).mount('#hdr'); + </script> + </body> + </html> diff --git a/internal/web/header.html b/internal/web/header.html @@ -0,0 +1,37 @@ +<header class="container header-flex"> + <div class="branding"> + <h1><a href="/">marky</a></h1> + <p class="muted">Lightweight bookmarking + plaintext archive</p> + </div> + <nav class="top-links"> + <template v-if="me && me.username"> + <div class="user-line"> + <span>Logged in as {{ me.username }}</span> + <span class="sep">|</span> + <a href="#" @click.prevent="logout">Log out</a> + </div> + <div class="api-key"> + <template v-if="api.present"> + API Token: + <template v-if="!api.show"> + <a href="#" @click.prevent="revealApiKey">reveal</a> + <span class="sep">|</span> + <a href="#" @click.prevent="regenerateApiKey">regenerate</a> + </template> + <template v-else> + <code class="token">{{ api.value }}</code> + <span class="sep">|</span> + <span v-if="api.copied">copied!</span> + <a v-else href="#" @click.prevent="copyApiKey">copy</a> + <span class="sep">|</span> + <a href="#" @click.prevent="regenerateApiKey">regenerate</a> + </template> + </template> + <template v-else> + API Token: none + <span class="sep">|</span><a href="#" @click.prevent="generateApiKey">generate</a> + </template> + </div> + </template> + </nav> +</header> diff --git a/internal/web/index.html b/internal/web/index.html @@ -14,43 +14,7 @@ </head> <body> <div id="app"> - <header class="container header-flex"> - <div class="branding"> - <h1><a href="/">marky</a></h1> - <p class="muted">Lightweight bookmarking + plaintext archive</p> - </div> - <nav class="top-links"> - <template v-if="me && me.username"> - <div class="user-line"> - <span>Logged in as {{ me.username }}</span> - <span class="sep">|</span> - <a href="#" @click.prevent="logout">Log out</a> - </div> - <div class="api-key"> - <template v-if="api.present"> - API Token: - <template v-if="!api.show"> - <a href="#" @click.prevent="revealApiKey">reveal</a> - <span class="sep">|</span> - <a href="#" @click.prevent="regenerateApiKey">regenerate</a> - </template> - <template v-else> - <code class="token">{{ api.value }}</code> - <span class="sep">|</span> - <span v-if="api.copied">copied!</span> - <a v-else href="#" @click.prevent="copyApiKey">copy</a> - <span class="sep">|</span> - <a href="#" @click.prevent="regenerateApiKey">regenerate</a> - </template> - </template> - <template v-else> - API Token: none - <span class="sep">|</span><a href="#" @click.prevent="generateApiKey">generate</a> - </template> - </div> - </template> - </nav> - </header> + {{HEADER}} <main class="container grid"> <section v-if="view==='login'">