commit 1bea21100843fda05707fad0b7a69977f4bd4654
parent 809baab415f7c2462a6cbaf3db303393cb7733f2
Author: David Eisinger <[email protected]>
Date: Tue, 7 Oct 2025 15:08:31 -0400
Bookmarklet
Diffstat:
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("&", "&", "<", "<", ">", ">")
+ 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'">