add api key for uploading via 3rd party tools
This commit is contained in:
@@ -20,6 +20,9 @@ SET client_min_messages = warning;
|
|||||||
SET row_security = off;
|
SET row_security = off;
|
||||||
|
|
||||||
DROP PUBLICATION IF EXISTS alltables;
|
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_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_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;
|
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_user_id;
|
||||||
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
|
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_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_userid;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_type;
|
DROP INDEX IF EXISTS public.idx_user_alias_type;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_alias;
|
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_halls;
|
||||||
DROP TABLE IF EXISTS public.user_dm_keyvault;
|
DROP TABLE IF EXISTS public.user_dm_keyvault;
|
||||||
DROP TABLE IF EXISTS public.user_conversation_states;
|
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_alias;
|
||||||
DROP TABLE IF EXISTS public."user";
|
DROP TABLE IF EXISTS public."user";
|
||||||
DROP SEQUENCE IF EXISTS public.user_id_seq;
|
DROP SEQUENCE IF EXISTS public.user_id_seq;
|
||||||
@@ -1326,6 +1331,23 @@ CREATE TABLE public."user" (
|
|||||||
|
|
||||||
ALTER TABLE public."user" OWNER TO f0ckm;
|
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
|
-- 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);
|
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
|
-- 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;
|
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
|
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -334,6 +334,67 @@ export default new class {
|
|||||||
return next();
|
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) {
|
getCookieOptions(expires = null, httpOnly = true) {
|
||||||
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
||||||
let options = "Path=/; SameSite=Lax";
|
let options = "Path=/; SameSite=Lax";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import lib from '../../lib.mjs';
|
|||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||||
// These routes remain for other settings API endpoints
|
// 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;
|
return group;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if (!req.session) {
|
||||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel.
|
// CSRF validation — required for browser sessions, skipped for API key auth.
|
||||||
const csrfToken = req.headers['x-csrf-token'];
|
if (!req.session.api_key_auth) {
|
||||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -157,10 +183,11 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
AND stamp > ${twelveHoursAgo}
|
AND stamp > ${twelveHoursAgo}
|
||||||
AND is_deleted = false
|
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, {
|
return sendJson(res, {
|
||||||
success: false,
|
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);
|
}, 429);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,7 +661,8 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
msg: successMsg,
|
msg: successMsg,
|
||||||
itemid: itemid,
|
itemid: itemid,
|
||||||
manual_approval: manualApproval,
|
manual_approval: manualApproval,
|
||||||
redirect: !manualApproval ? `/${itemid}` : null
|
redirect: !manualApproval ? `/${itemid}` : null,
|
||||||
|
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : null
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -407,6 +407,38 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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…</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;">⚠ Copy your key now — 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>
|
<style>
|
||||||
@keyframes exportDotBounce {
|
@keyframes exportDotBounce {
|
||||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }
|
||||||
|
|||||||
Reference in New Issue
Block a user