marky

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

commit dde33033343519bde55f6d2ab3c93d30aa7eb8ae
parent 3a7ab819aa149935f8d1e24a45e6cc4d2cc5eb42
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 22:34:38 -0400

Auth tokens

Diffstat:
Minternal/api.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Ainternal/migrations/0008_users_api_token_unique.sql | 5+++++
Minternal/user_db.go | 34++++++++++++++++++++++++++++++++++
Minternal/web/app.js | 38++++++++++++++++++++++++++++++++++++++
Minternal/web/index.html | 25++++++++++++++++++++++++-
Minternal/web/styles.css | 2++
6 files changed, 166 insertions(+), 11 deletions(-)

diff --git a/internal/api.go b/internal/api.go @@ -46,7 +46,7 @@ func NewHandler(db *DB) (http.Handler, error) { ClearSession(w); w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/me", func(w http.ResponseWriter, r *http.Request) { - if uid, ok := CurrentUserID(r); ok { + if uid, ok := userIDFromRequest(r, db); ok { if u, err := db.GetUserByID(r.Context(), uid); err==nil && u!=nil { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"username": u.Username}) @@ -56,6 +56,35 @@ func NewHandler(db *DB) (http.Handler, error) { http.Error(w, "unauthorized", http.StatusUnauthorized) }) + // API token endpoints for logged-in user + mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) { + uid, ok := CurrentUserID(r) + if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } + switch r.Method { + case http.MethodGet: + reveal := strings.EqualFold(r.URL.Query().Get("reveal"), "1") || strings.EqualFold(r.URL.Query().Get("reveal"), "true") + tok, present, err := db.GetAPIToken(r.Context(), uid) + if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError); return } + w.Header().Set("Content-Type", "application/json") + if !present { + _ = json.NewEncoder(w).Encode(map[string]any{"present": false}) + return + } + if reveal { + _ = json.NewEncoder(w).Encode(map[string]any{"present": true, "api_token": tok}) + } else { + _ = json.NewEncoder(w).Encode(map[string]any{"present": true}) + } + case http.MethodPost: + tok, err := db.RegenerateAPIToken(r.Context(), uid) + if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError); return } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"api_token": tok}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + // Public listing by username (JSON) mux.HandleFunc("/u/", func(w http.ResponseWriter, r *http.Request) { uname := strings.TrimPrefix(r.URL.Path, "/u/") @@ -119,9 +148,9 @@ func NewHandler(db *DB) (http.Handler, error) { // deny nested paths if name != "" && !strings.Contains(name, "/") { if u, err := db.GetUserByUsername(r.Context(), name); err == nil && u != nil { - // Show private items if this is the logged-in owner's feed + // Show private items if this is the owner's feed (session or API token) var items []Bookmark - if uid, ok := CurrentUserID(r); ok && uid == u.ID { + if uid, ok := userIDFromRequest(r, db); ok && uid == u.ID { items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) } else { items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) @@ -148,7 +177,7 @@ func NewHandler(db *DB) (http.Handler, error) { bookmarksHandler := func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - uid, ok := CurrentUserID(r) + uid, ok := userIDFromRequest(r, db) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } if r.Header.Get("Content-Type") != "" { ct := r.Header.Get("Content-Type") @@ -233,8 +262,8 @@ func NewHandler(db *DB) (http.Handler, error) { _ = json.NewEncoder(w).Encode(toResp(created)) } - case http.MethodGet: - uid, _ := CurrentUserID(r) + case http.MethodGet: + uid, _ := userIDFromRequest(r, db) q := r.URL.Query() limit := 50 offset := 0 @@ -312,7 +341,7 @@ func NewHandler(db *DB) (http.Handler, error) { mux.HandleFunc("/bookmarks.xml", bookmarksHandler) // Archive, PUT, DELETE under /bookmarks/{id} - mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path rest := strings.TrimPrefix(path, "/bookmarks/") if rest == "" || strings.HasPrefix(rest, "/") { @@ -388,6 +417,7 @@ func NewHandler(db *DB) (http.Handler, error) { } switch r.Method { case http.MethodPut: + if _, ok := userIDFromRequest(r, db); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() var req createBookmarkRequest @@ -436,8 +466,9 @@ func NewHandler(db *DB) (http.Handler, error) { } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(toResp(updated)) - case http.MethodDelete: - ok, err := db.DeleteBookmark(r.Context(), id64) + case http.MethodDelete: + if _, ok := userIDFromRequest(r, db); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } + ok, err := db.DeleteBookmark(r.Context(), id64) if err != nil { log.Printf("delete bookmark error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -454,7 +485,29 @@ func NewHandler(db *DB) (http.Handler, error) { } }) - return mux, nil + return mux, nil +} + +// userIDFromRequest resolves the user ID using either a signed session cookie +// or HTTP Basic auth where the username is the account username and the +// password is the API token. +func userIDFromRequest(r *http.Request, db *DB) (int64, bool) { + if uid, ok := CurrentUserID(r); ok { + return uid, true + } + if u, p, ok := r.BasicAuth(); ok { + if u == "" || p == "" { + return 0, false + } + usr, err := db.GetUserByUsername(r.Context(), u) + if err != nil || usr == nil { + return 0, false + } + if usr.APIToken != nil && *usr.APIToken == p { + return usr.ID, true + } + } + return 0, false } type createBookmarkRequest struct { diff --git a/internal/migrations/0008_users_api_token_unique.sql b/internal/migrations/0008_users_api_token_unique.sql @@ -0,0 +1,5 @@ +-- Ensure API tokens are unique per user (allowing multiple NULL/empty) +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token_unique +ON users(api_token) +WHERE api_token IS NOT NULL AND api_token <> ''; + diff --git a/internal/user_db.go b/internal/user_db.go @@ -3,6 +3,8 @@ package internal import ( "context" "database/sql" + "encoding/base64" + "crypto/rand" "fmt" "time" @@ -96,3 +98,35 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs if err := rows.Err(); err != nil { return nil, err } return out, nil } + +// GetAPIToken returns the user's API token if set. +func (db *DB) GetAPIToken(ctx context.Context, userID int64) (string, bool, error) { + var tok sql.NullString + if err := db.sql.QueryRowContext(ctx, `SELECT api_token FROM users WHERE id = ?`, userID).Scan(&tok); err != nil { + if err == sql.ErrNoRows { return "", false, nil } + return "", false, fmt.Errorf("get api token: %w", err) + } + if tok.Valid && tok.String != "" { + return tok.String, true, nil + } + return "", false, nil +} + +// RegenerateAPIToken generates a new unique API token for the user and stores it. +func (db *DB) RegenerateAPIToken(ctx context.Context, userID int64) (string, error) { + // Try a few times in case of uniqueness collision + for i := 0; i < 5; i++ { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(b) + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := db.sql.ExecContext(ctx, `UPDATE users SET api_token = ?, updated_at = ? WHERE id = ?`, token, now, userID) + if err == nil { + return token, nil + } + // If unique conflict, retry + } + return "", fmt.Errorf("failed to generate unique api token after retries") +} diff --git a/internal/web/app.js b/internal/web/app.js @@ -14,6 +14,7 @@ createApp({ me: null, viewUser: '', auth: { username: '', password: '' }, + api: { present: false, value: '', show: false, busy: false }, }; }, mounted() { @@ -38,12 +39,49 @@ createApp({ this.viewUser = pathUser; if (this.me && this.me.username === this.viewUser) { this.view = 'self'; + this.fetchApiPresence(); this.search(); } else { this.view = 'public'; this.loadPublic(); } }, + async fetchApiPresence() { + try { + const res = await fetch('/api/token'); + if (!res.ok) return; + const data = await res.json(); + this.api.present = !!data.present; + if (!this.api.present) { this.api.value=''; this.api.show=false; } + } catch {} + }, + async revealApiKey() { + this.api.busy = true; + try { + const res = await fetch('/api/token?reveal=1'); + if (!res.ok) throw new Error('Failed to reveal'); + const data = await res.json(); + this.api.present = true; + this.api.value = data.api_token || ''; + this.api.show = true; + } catch {} + this.api.busy = false; + }, + async generateApiKey() { + this.api.busy = true; + try { + const res = await fetch('/api/token', { method: 'POST' }); + if (!res.ok) throw new Error('Failed to generate'); + const data = await res.json(); + this.api.present = true; + this.api.value = data.api_token || ''; + this.api.show = true; + } catch {} + this.api.busy = false; + }, + async regenerateApiKey() { + await this.generateApiKey(); + }, async login() { this.error = ''; this.busy = true; diff --git a/internal/web/index.html b/internal/web/index.html @@ -15,7 +15,30 @@ <p class="muted">Lightweight bookmarking + plaintext archive</p> </div> <nav class="top-links"> - <a v-if="me && me.username" href="#" @click.prevent="logout">Log out</a> + <template v-if="me && me.username"> + <span>Logged in as {{ me.username }}</span> + <span class="sep">|</span> + <a href="#" @click.prevent="logout">Log out</a> + <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> + <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/styles.css b/internal/web/styles.css @@ -12,6 +12,8 @@ h1 { margin: 0 0 4px 0; font-size: 24px; } .top-links a { color: inherit; text-decoration: none; } .top-links a:hover { text-decoration: underline; } .top-links .sep { margin: 0 6px; opacity: 0.6; } +.top-links .api-key { margin-top: 4px; font-size: 12px; color: #666; } +.top-links .api-key .token { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; } .small-links { font-size: 12px; margin-left: 8px; color: #666; } .small-links a { color: inherit; text-decoration: none; } .small-links a:hover { text-decoration: underline; }