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
+
+ 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. +
+ +