marky

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

commit 934946a516bc71b0b18e4297f234ae41bceec1e9
parent b082d76c4a8bb402166408e264a17bf575ab22d9
Author: David Eisinger <[email protected]>
Date:   Thu,  9 Oct 2025 14:06:14 -0400

API Token / settings tweaks

Diffstat:
Minternal/migrations/0001_users.sql | 8+++-----
Minternal/user_db.go | 56+++++++++++++++++++++++++++++++-------------------------
Minternal/web/app.js | 2++
Minternal/web/settings.html | 31+++++++------------------------
Minternal/web/styles.css | 10++++++++++
5 files changed, 53 insertions(+), 54 deletions(-)

diff --git a/internal/migrations/0001_users.sql b/internal/migrations/0001_users.sql @@ -3,13 +3,11 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE CHECK (username <> '' AND username GLOB '[a-z0-9]*'), encrypted_password TEXT NOT NULL, - api_token TEXT, + api_token TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); --- Unique API token when present +-- API token is always present and unique CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token_unique -ON users(api_token) -WHERE api_token IS NOT NULL AND api_token <> ''; - +ON users(api_token); diff --git a/internal/user_db.go b/internal/user_db.go @@ -1,37 +1,43 @@ package internal import ( - "context" - "crypto/rand" - "database/sql" - "encoding/base64" - "fmt" - "time" + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "time" "golang.org/x/crypto/bcrypt" ) // CreateUser inserts a new user with a bcrypt-hashed password and returns the new ID. func (db *DB) CreateUser(ctx context.Context, username, password string) (int64, error) { - if err := ValidateUsername(username); err != nil { - return 0, err - } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return 0, fmt.Errorf("hash password: %w", err) - } - now := time.Now().UTC().Format(time.RFC3339Nano) - res, err := db.sql.ExecContext(ctx, ` - INSERT INTO users(username, encrypted_password, created_at, updated_at) - VALUES(?, ?, ?, ?) - `, username, string(hash), now, now) - if err != nil { - return 0, fmt.Errorf("insert user: %w", err) - } - id, err := res.LastInsertId() - if err != nil { - return 0, fmt.Errorf("last id: %w", err) - } + if err := ValidateUsername(username); err != nil { + return 0, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return 0, fmt.Errorf("hash password: %w", err) + } + // Generate an API token on creation + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return 0, fmt.Errorf("rand: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(b) + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := db.sql.ExecContext(ctx, ` + INSERT INTO users(username, encrypted_password, api_token, created_at, updated_at) + VALUES(?, ?, ?, ?, ?) + `, username, string(hash), token, now, now) + if err != nil { + return 0, fmt.Errorf("insert user: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("last id: %w", err) + } // Backfill: assign any orphan bookmarks (pre-user era) to the first created user if _, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET user_id = ? WHERE user_id IS NULL`, id); err != nil { return 0, fmt.Errorf("backfill bookmarks: %w", err) diff --git a/internal/web/app.js b/internal/web/app.js @@ -48,7 +48,9 @@ createApp({ // Settings page if (first === 'settings') { if (!this.me || !this.me.username) { location.replace('/'); return; } + // Ensure the API token value is present to render immediately await this.fetchApiPresence(); + await this.revealApiKey(); this.updateTitle(); return; } diff --git a/internal/web/settings.html b/internal/web/settings.html @@ -13,29 +13,13 @@ <main class="container"> <section> <h2>API Token</h2> - <div class="api-key"> - <template v-if="api.present"> - <template v-if="!api.show"> - Token present. <a href="#" @click.prevent="revealApiKey">reveal</a> - <span class="sep">|</span> - <a href="#" @click.prevent="regenerateApiKey">regenerate</a> - </template> - <template v-else> - <div> - <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> - </div> - </template> - </template> - <template v-else> - No token yet. - <span class="sep">|</span> - <a href="#" @click.prevent="generateApiKey">generate</a> - </template> + <p>Use this token with the API. See the <a href="https://git.sr.ht/~dce/marky#api" target="_blank" rel="noopener">API docs</a> for details.</p> + <p> + <code class="token-display">{{ api.value }}</code> + </p> + <div style="display:flex; gap:8px;"> + <button class="btn" @click.prevent="copyApiKey">{{ api.copied ? 'Copied!' : 'Copy' }}</button> + <button class="btn" @click.prevent="regenerateApiKey" :disabled="api.busy">{{ api.busy ? 'Working…' : 'Regenerate' }}</button> </div> </section> @@ -52,4 +36,3 @@ <script src="/app.js"></script> </body> </html> - diff --git a/internal/web/styles.css b/internal/web/styles.css @@ -21,12 +21,22 @@ h1 { margin: 0 0 4px 0; font-size: 24px; } @media (max-width: 800px) { .grid { grid-template-columns: 1fr; } } /* Span full width across both columns when needed (e.g., public view) */ .span2 { grid-column: 1 / -1; } +/* Vertical spacing between stacked sections on non-grid pages (e.g., settings) */ +.container:not(.grid) > section + section { margin-top: 48px; } form { display: grid; gap: 10px; } label { display: grid; gap: 6px; font-size: 13px; } input, textarea { padding: 8px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; background: transparent; color: inherit; } label.inline { display: inline-flex; align-items: center; gap: 6px; } button { padding: 8px 12px; font-size: 14px; border-radius: 6px; border: 1px solid #888; background: #eee; color: #111; cursor: pointer; } @media (prefers-color-scheme: dark) { button { background: #333; color: #eee; border-color: #777; } } +/* Reusable button style for prominent actions */ +.btn { display: inline-block; padding: 8px 12px; font-size: 14px; border-radius: 6px; background: #111; color: #fff !important; text-decoration: none; border: none; cursor: pointer; } +.btn:hover { filter: brightness(1.05); } +@media (prefers-color-scheme: dark) { .btn { background: #000; color: #fff !important; } } +button.btn { border: none; } +/* Token display */ +.token-display { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; padding: 10px 12px; border: 1px solid #ddd3; border-radius: 6px; background: #f6f7f8; color: inherit; display: inline-block; } +@media (prefers-color-scheme: dark) { .token-display { background: #1b1d1f; border-color: #333; } } .error { color: #b00020; } .ok { color: #0a7f2e; } .results { display: grid; gap: 12px; margin-top: 12px; }