add api key for uploading via 3rd party tools

This commit is contained in:
2026-05-22 20:42:48 +02:00
parent d44fb1ac05
commit 71f292f243
6 changed files with 382 additions and 7 deletions

View File

@@ -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
--

View File

@@ -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 =
`<span>Active key: <code style="font-size:0.9em;">${escHTML(preview)}</code></span>` +
`<span style="color:var(--text-muted); margin-left:12px; font-size:0.85em;">Created: ${escHTML(date)}</span>`;
if (btnRevokeApiKey) btnRevokeApiKey.style.display = '';
} else {
apiKeyStatusBox.innerHTML = '<span class="text-muted">No API key generated yet.</span>';
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 = '<span class="text-muted">Could not load key info.</span>';
}
} catch (e) {
apiKeyStatusBox.innerHTML = '<span class="text-muted">Could not load key info.</span>';
}
})();
}
// 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';
}
});
}
})();

View File

@@ -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";

View File

@@ -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;
});

View File

@@ -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) {

View File

@@ -407,6 +407,38 @@
</div>
@endif
<h2>Upload API Key</h2>
<div id="api-key-section" class="account-settings-wrapper"
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 15px;">
Use this key to upload files via external tools or scripts without a browser session.
The key grants <strong>upload access only</strong> to your account — it cannot be used for comments or any other action.
</p>
<div id="api-key-status-box" style="margin-bottom: 15px;">
<span class="text-muted" id="api-key-loading-text">Loading&hellip;</span>
</div>
<!-- Shown only after regenerate — full key revealed once for copying -->
<div id="api-key-reveal" style="display:none; margin-bottom: 15px;">
<div style="background: rgba(0,180,100,0.12); border: 1px solid rgba(0,180,100,0.4); border-radius: 4px; padding: 14px;">
<strong style="display:block; margin-bottom: 6px;">&#9888; Copy your key now &mdash; it will not be shown again in full:</strong>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<code id="api-key-full-display"
style="flex: 1; word-break: break-all; font-size: 0.85em; background: rgba(0,0,0,0.25); padding: 8px 10px; border-radius: 4px; user-select: all;"></code>
<button type="button" id="btn-copy-api-key" class="button"
style="white-space: nowrap; padding: 6px 14px;">Copy</button>
</div>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<button type="button" id="btn-regen-api-key" class="button button-primary">Generate / Regenerate Key</button>
<button type="button" id="btn-revoke-api-key" class="button button-danger" style="display:none;">Revoke Key</button>
</div>
<div id="api-key-action-status" class="avatar-status" style="margin-top: 10px;"></div>
</div>
<style>
@keyframes exportDotBounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }