commit dde33033343519bde55f6d2ab3c93d30aa7eb8ae
parent 3a7ab819aa149935f8d1e24a45e6cc4d2cc5eb42
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 22:34:38 -0400
Auth tokens
Diffstat:
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; }