commit 934946a516bc71b0b18e4297f234ae41bceec1e9
parent b082d76c4a8bb402166408e264a17bf575ab22d9
Author: David Eisinger <[email protected]>
Date: Thu, 9 Oct 2025 14:06:14 -0400
API Token / settings tweaks
Diffstat:
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; }