From 71f292f2436d75c639e1138f7c22be502814f0a7 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 22 May 2026 20:42:48 +0200 Subject: [PATCH] add api key for uploading via 3rd party tools --- migrations/f0ckm_schema.sql | 37 ++++++++ public/s/js/settings.js | 141 ++++++++++++++++++++++++++++++ src/inc/lib.mjs | 61 +++++++++++++ src/inc/routes/apiv2/settings.mjs | 76 ++++++++++++++++ src/upload_handler.mjs | 42 +++++++-- views/settings.html | 32 +++++++ 6 files changed, 382 insertions(+), 7 deletions(-) diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index b4f2894..84cffab 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -20,6 +20,9 @@ SET client_min_messages = warning; SET row_security = off; DROP PUBLICATION IF EXISTS alltables; +ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_user_id_fkey; +ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_api_key_key; +ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_pkey; ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey; ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey; ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey; @@ -91,6 +94,7 @@ DROP INDEX IF EXISTS public.idx_user_last_seen; DROP INDEX IF EXISTS public.idx_user_halls_user_id; DROP INDEX IF EXISTS public.idx_user_halls_assign_item; DROP INDEX IF EXISTS public.idx_user_halls_assign_hall; +DROP INDEX IF EXISTS public.idx_user_api_keys_api_key; DROP INDEX IF EXISTS public.idx_user_alias_userid; DROP INDEX IF EXISTS public.idx_user_alias_type; DROP INDEX IF EXISTS public.idx_user_alias_alias; @@ -191,6 +195,7 @@ DROP TABLE IF EXISTS public.user_halls_assign; DROP TABLE IF EXISTS public.user_halls; DROP TABLE IF EXISTS public.user_dm_keyvault; DROP TABLE IF EXISTS public.user_conversation_states; +DROP TABLE IF EXISTS public.user_api_keys; DROP TABLE IF EXISTS public.user_alias; DROP TABLE IF EXISTS public."user"; DROP SEQUENCE IF EXISTS public.user_id_seq; @@ -1326,6 +1331,23 @@ CREATE TABLE public."user" ( ALTER TABLE public."user" OWNER TO f0ckm; +-- +-- Name: user_api_keys; Type: TABLE; Schema: public; Owner: f0ckm +-- + +CREATE TABLE public.user_api_keys ( + user_id integer NOT NULL, + api_key text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_api_keys_pkey PRIMARY KEY (user_id), + CONSTRAINT user_api_keys_user_id_fkey + FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE, + CONSTRAINT user_api_keys_api_key_key UNIQUE (api_key) +); + +ALTER TABLE public.user_api_keys OWNER TO f0ckm; + + -- -- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm -- @@ -2241,6 +2263,13 @@ CREATE INDEX idx_user_alias_type ON public.user_alias USING btree (type); CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid); +-- +-- Name: idx_user_api_keys_api_key; Type: INDEX; Schema: public; Owner: f0ckm +-- + +CREATE INDEX idx_user_api_keys_api_key ON public.user_api_keys USING btree (api_key); + + -- -- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm -- @@ -2721,6 +2750,14 @@ ALTER TABLE ONLY public.user_warnings ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE; +-- +-- Name: user_api_keys user_api_keys_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm +-- + +ALTER TABLE ONLY public.user_api_keys + ADD CONSTRAINT user_api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE; + + -- -- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm -- diff --git a/public/s/js/settings.js b/public/s/js/settings.js index 507e327..46194f8 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -1780,5 +1780,146 @@ }); } + // ============================================================ + // Upload API Key Management + // ============================================================ + const apiKeyStatusBox = document.getElementById('api-key-status-box'); + const apiKeyRevealBox = document.getElementById('api-key-reveal'); + const apiKeyFullDisplay = document.getElementById('api-key-full-display'); + const btnCopyApiKey = document.getElementById('btn-copy-api-key'); + const btnRegenApiKey = document.getElementById('btn-regen-api-key'); + const btnRevokeApiKey = document.getElementById('btn-revoke-api-key'); + const apiKeyActionStatus = document.getElementById('api-key-action-status'); + + const showApiKeyStatus = (msg, type) => { + if (!apiKeyActionStatus) return; + apiKeyActionStatus.textContent = msg; + apiKeyActionStatus.className = 'avatar-status ' + (type || ''); + }; + + const renderApiKeyState = (hasKey, preview, createdAt) => { + if (!apiKeyStatusBox) return; + if (hasKey) { + const date = createdAt ? new Date(createdAt).toLocaleString() : 'unknown'; + apiKeyStatusBox.innerHTML = + `Active key: ${escHTML(preview)}` + + `Created: ${escHTML(date)}`; + if (btnRevokeApiKey) btnRevokeApiKey.style.display = ''; + } else { + apiKeyStatusBox.innerHTML = 'No API key generated yet.'; + if (btnRevokeApiKey) btnRevokeApiKey.style.display = 'none'; + } + }; + + // Load current state + if (apiKeyStatusBox) { + (async () => { + try { + const res = await fetch('/api/v2/settings/api-key'); + const data = await res.json(); + if (data.success) { + renderApiKeyState(data.has_key, data.preview, data.created_at); + } else { + apiKeyStatusBox.innerHTML = 'Could not load key info.'; + } + } catch (e) { + apiKeyStatusBox.innerHTML = 'Could not load key info.'; + } + })(); + } + + // Generate / Regenerate + if (btnRegenApiKey) { + btnRegenApiKey.addEventListener('click', async () => { + if (btnRevokeApiKey && btnRevokeApiKey.style.display !== 'none') { + // Key already exists — warn the user + if (!confirm('Regenerating will immediately invalidate your current API key. Continue?')) return; + } + + btnRegenApiKey.disabled = true; + btnRegenApiKey.textContent = 'Generating…'; + showApiKeyStatus('', ''); + + try { + const res = await fetch('/api/v2/settings/api-key/regenerate', { + method: 'POST', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token } + }); + const data = await res.json(); + + if (data.success) { + // Show one-time reveal box + if (apiKeyFullDisplay) apiKeyFullDisplay.textContent = data.api_key; + if (apiKeyRevealBox) apiKeyRevealBox.style.display = ''; + + // Update status row to masked preview + const preview = '****' + data.api_key.slice(-8); + renderApiKeyState(true, preview, new Date().toISOString()); + showApiKeyStatus('Key generated. Copy it now — it will not be shown in full again.', 'success'); + } else { + showApiKeyStatus(data.msg || 'Failed to generate key.', 'error'); + } + } catch (e) { + showApiKeyStatus('Request failed.', 'error'); + } finally { + btnRegenApiKey.disabled = false; + btnRegenApiKey.textContent = 'Generate / Regenerate Key'; + } + }); + } + + // Copy key to clipboard + if (btnCopyApiKey) { + btnCopyApiKey.addEventListener('click', async () => { + const key = apiKeyFullDisplay?.textContent?.trim(); + if (!key) return; + try { + await navigator.clipboard.writeText(key); + const orig = btnCopyApiKey.textContent; + btnCopyApiKey.textContent = 'Copied!'; + setTimeout(() => { btnCopyApiKey.textContent = orig; }, 2000); + } catch (e) { + // Fallback: select the text + const range = document.createRange(); + range.selectNodeContents(apiKeyFullDisplay); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + }); + } + + // Revoke key + if (btnRevokeApiKey) { + btnRevokeApiKey.addEventListener('click', async () => { + if (!confirm('Revoke your API key? This cannot be undone — you will need to generate a new one.')) return; + + btnRevokeApiKey.disabled = true; + btnRevokeApiKey.textContent = 'Revoking…'; + + try { + const res = await fetch('/api/v2/settings/api-key', { + method: 'DELETE', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token } + }); + const data = await res.json(); + + if (data.success) { + renderApiKeyState(false, null, null); + if (apiKeyRevealBox) apiKeyRevealBox.style.display = 'none'; + if (apiKeyFullDisplay) apiKeyFullDisplay.textContent = ''; + showApiKeyStatus('API key revoked.', 'success'); + } else { + showApiKeyStatus(data.msg || 'Failed to revoke key.', 'error'); + btnRevokeApiKey.disabled = false; + btnRevokeApiKey.textContent = 'Revoke Key'; + } + } catch (e) { + showApiKeyStatus('Request failed.', 'error'); + btnRevokeApiKey.disabled = false; + btnRevokeApiKey.textContent = 'Revoke Key'; + } + }); + } })(); diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index eb5fb53..1e88765 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -334,6 +334,67 @@ export default new class { return next(); }; + // Middleware: authenticate via X-Api-Key header (upload-only) + async apiKeyAuth(req, res, next) { + const key = req.headers['x-api-key']; + if (!key) { + return res.reply({ + code: 401, + body: JSON.stringify({ success: false, msg: 'API key required' }), + type: 'application/json' + }); + } + + let row; + try { + const rows = await db` + SELECT + u.id, u.user, u.login, u.admin, u.is_moderator, u.banned, + uo.display_name, uo.mode, uo.theme, uo.avatar, uo.avatar_file, + uo.username_color, uo.show_motd, uo.disable_autoplay, + uo.disable_swiping, uo.use_new_layout, uo.excluded_tags, + uo.ruffle_background, uo.ruffle_volume, uo.quote_emojis, + uo.embed_youtube_in_comments, uo.hide_koepfe, + uo.use_alternative_infobox, uo.language, uo.comment_display_mode, + uo.force_comment_display_mode, uo.min_xd_score, uo.show_background, + uo.font, uo.receive_system_notifications, uo.receive_user_notifications, + uo.do_not_disturb, uo.description + FROM user_api_keys k + JOIN "user" u ON u.id = k.user_id + LEFT JOIN user_options uo ON uo.user_id = u.id + WHERE k.api_key = ${key} + LIMIT 1 + `; + row = rows[0]; + } catch (err) { + console.error('[API KEY AUTH] DB error:', err); + return res.reply({ + code: 500, + body: JSON.stringify({ success: false, msg: 'Internal server error' }), + type: 'application/json' + }); + } + + if (!row) { + return res.reply({ + code: 401, + body: JSON.stringify({ success: false, msg: 'Invalid API key' }), + type: 'application/json' + }); + } + + if (row.banned) { + return res.reply({ + code: 403, + body: JSON.stringify({ success: false, msg: 'Account banned' }), + type: 'application/json' + }); + } + + req.session = { ...row, api_key_auth: true }; + return next(); + }; + getCookieOptions(expires = null, httpOnly = true) { const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https'); let options = "Path=/; SameSite=Lax"; diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index 947814a..aef053a 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -3,6 +3,7 @@ import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; import fs from 'fs/promises'; import path from 'path'; +import crypto from 'crypto'; // Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs // These routes remain for other settings API endpoints @@ -726,6 +727,81 @@ export default router => { } }); + // --- Upload API Key Management --- + + // GET /api/v2/settings/api-key + // Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview). + group.get(/\/api-key$/, lib.loggedin, async (req, res) => { + try { + const row = (await db` + SELECT api_key, created_at + FROM user_api_keys + WHERE user_id = ${+req.session.id} + LIMIT 1 + `)[0]; + + if (!row) { + return res.json({ success: true, has_key: false }, 200); + } + + return res.json({ + success: true, + has_key: true, + preview: `****${row.api_key.slice(-8)}`, + created_at: row.created_at + }, 200); + } catch (e) { + console.error('[API KEY] GET error:', e); + return res.json({ success: false, msg: 'Error fetching API key' }, 500); + } + }); + + // POST /api/v2/settings/api-key/regenerate + // Generates a new key (or replaces an existing one). Returns the full key — only shown once. + group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => { + try { + const newKey = crypto.randomBytes(32).toString('hex'); + + await db` + INSERT INTO user_api_keys (user_id, api_key, created_at) + VALUES (${+req.session.id}, ${newKey}, now()) + ON CONFLICT (user_id) DO UPDATE + SET api_key = EXCLUDED.api_key, + created_at = now() + `; + + return res.json({ + success: true, + api_key: newKey, + msg: 'API key generated. Copy it now — it will not be shown again in full.' + }, 200); + } catch (e) { + console.error('[API KEY] Regenerate error:', e); + return res.json({ success: false, msg: 'Error generating API key' }, 500); + } + }); + + // DELETE /api/v2/settings/api-key + // Revokes (deletes) the user's API key. + group.delete(/\/api-key$/, lib.loggedin, async (req, res) => { + try { + const result = await db` + DELETE FROM user_api_keys + WHERE user_id = ${+req.session.id} + RETURNING user_id + `; + + if (result.length === 0) { + return res.json({ success: false, msg: 'No API key to revoke' }, 404); + } + + return res.json({ success: true, msg: 'API key revoked' }, 200); + } catch (e) { + console.error('[API KEY] Delete error:', e); + return res.json({ success: false, msg: 'Error revoking API key' }, 500); + } + }); + return group; }); diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 86c128d..3513c88 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -37,14 +37,40 @@ export const handleUpload = async (req, res, self) => { } } + // Fallback: authenticate via X-Api-Key header (upload-only; no CSRF required) + if (!req.session && req.headers['x-api-key']) { + const key = req.headers['x-api-key']; + try { + const rows = await db` + SELECT u.id, u.user, u.login, u.admin, u.is_moderator, u.banned, + uo.* + FROM user_api_keys k + JOIN "user" u ON u.id = k.user_id + LEFT JOIN user_options uo ON uo.user_id = u.id + WHERE k.api_key = ${key} + LIMIT 1 + `; + if (rows.length > 0) { + if (rows[0].banned) { + return sendJson(res, { success: false, msg: 'Account banned' }, 403); + } + req.session = { ...rows[0], api_key_auth: true }; + } + } catch (err) { + console.error('[UPLOAD] API key lookup error:', err); + } + } + if (!req.session) { return sendJson(res, { success: false, msg: 'Unauthorized' }, 401); } - // CSRF validation — must happen after session lookup since flummpress middlewares run in parallel. - const csrfToken = req.headers['x-csrf-token']; - if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) { - return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403); + // CSRF validation — required for browser sessions, skipped for API key auth. + if (!req.session.api_key_auth) { + const csrfToken = req.headers['x-csrf-token']; + if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) { + return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403); + } } try { @@ -157,10 +183,11 @@ export const handleUpload = async (req, res, self) => { AND stamp > ${twelveHoursAgo} AND is_deleted = false `; - if (parseInt(uploadCount[0].count) >= 69) { + const uploadLimit = cfg.main.upload_limit ?? 69; + if (parseInt(uploadCount[0].count) >= uploadLimit) { return sendJson(res, { success: false, - msg: 'Rate limit exceeded. You can only upload 69 files every 12 hours.' + msg: `Rate limit exceeded. You can only upload ${uploadLimit} files every 12 hours.` }, 429); } } @@ -634,7 +661,8 @@ export const handleUpload = async (req, res, self) => { msg: successMsg, itemid: itemid, manual_approval: manualApproval, - redirect: !manualApproval ? `/${itemid}` : null + redirect: !manualApproval ? `/${itemid}` : null, + url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : null }); } catch (err) { diff --git a/views/settings.html b/views/settings.html index 5ea325b..9a817da 100644 --- a/views/settings.html +++ b/views/settings.html @@ -407,6 +407,38 @@ @endif +

Upload API Key

+
+

+ Use this key to upload files via external tools or scripts without a browser session. + The key grants upload access only to your account — it cannot be used for comments or any other action. +

+ +
+ Loading… +
+ + + + +
+ + +
+
+
+