This commit is contained in:
2026-09-29 04:35:35 +02:00
parent 73c0659d4a
commit 6c813143eb
51 changed files with 3708 additions and 743 deletions
+10 -1
View File
@@ -118,7 +118,7 @@ export async function getOrCreateAnonUserByCredential(credentialId, req = null,
* @param {string} [hwFingerprint]
* @returns {Promise<{ session: string, csrf_token: string }>}
*/
export async function createAnonSession(userId, req, hwFingerprint = null) {
export async function createAnonSession(userId, req, hwFingerprint = null, credentialId = null) {
const auditIp = resolveAuditIP(req);
// Update anon_identities last_ip and hw_fingerprint
@@ -130,8 +130,15 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
WHERE user_id = ${userId}
`.catch(() => {});
// Remember which passkey this session runs on (settings: can't delete the one in use)
const markCredential = async (sessionHash) => {
if (!credentialId) return;
await db`UPDATE user_sessions SET passkey_credential_id = ${credentialId} WHERE session = ${sessionHash}`.catch(() => {});
};
// If req.session is already active for this exact userId, reuse it
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
await markCredential(lib.sha256(req.cookies.session));
await logAnonActivity(req, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: req.session.csrf_token };
}
@@ -146,6 +153,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
`;
if (existing.length > 0) {
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
await markCredential(existingHash);
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
}
@@ -173,6 +181,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
await db`
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
`;
await markCredential(sessionHash);
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
+1
View File
@@ -299,6 +299,7 @@
"focus_comment": "Kommentarfeld fokussieren",
"send_comment": "Kommentar senden",
"flash_yank": "Flash-Yank ein-/ausschalten",
"open_settings": "Einstellungen öffnen/schließen",
"tag_exclude": "Tag-Ausschluss öffnen",
"tag_input": "Tag-Eingabe öffnen",
"toggle_bg": "Hintergrund ein-/ausschalten",
+1
View File
@@ -299,6 +299,7 @@
"focus_comment": "focus comment input",
"send_comment": "send comment",
"flash_yank": "enable/disable flash yank",
"open_settings": "open/close settings",
"tag_exclude": "open tag exclude",
"tag_input": "open tag input",
"toggle_bg": "turns on/off the background",
+1
View File
@@ -297,6 +297,7 @@
"focus_comment": "focus op commentaarinvoer",
"send_comment": "opmerking verzenden",
"flash_yank": "flash yank in/uitschakelen",
"open_settings": "instellingen openen/sluiten",
"tag_exclude": "open tag uitsluiten",
"tag_input": "open tag invoer",
"toggle_bg": "turns on/off the background",
+1
View File
@@ -295,6 +295,7 @@
"focus_comment": "Kommentareingabe fokussieren",
"send_comment": "Kommentar senden",
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
"open_settings": "Einstellungen auf-/zuklappen",
"tag_exclude": "Etiketten-Ausschluss öffnen",
"tag_input": "Etiketten-Eingabe öffnen",
"toggle_bg": "Hintergrund ein-/ausschalten",
+174 -9
View File
@@ -168,7 +168,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
}
const user = (await db`select id, login from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
const user = (await db`select id, login, password from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
const targetIdentity = user ? user.login : email;
// 2. Identity-based check
@@ -182,7 +182,8 @@ export default (router, tpl) => {
// but the user wants "maximum tries per day is 1", so we record it regardless of email existence)
await security.recordAttempt(ip, targetIdentity, 'password_reset_request', true);
if (!user) {
// Passkey-only accounts (password '!') can't get a password by mail: same neutral answer, no mail
if (!user || user.password === '!') {
const msg = "If an account with that email exists, we have sent a reset link.";
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
return res.reply({ body: tpl.render("forgot-password", { success: msg }) });
@@ -259,7 +260,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
}
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() limit 1`)[0];
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() and password <> '!' limit 1`)[0];
if (!user) {
const msg = "Invalid or expired reset token.";
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
@@ -287,16 +288,18 @@ export default (router, tpl) => {
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
// Dashboard counters (cheap aggregate queries; failures just show 0)
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0 };
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0, invite_requests: 0 };
try {
const [[r], [u], [t]] = await Promise.all([
const [[r], [u], [t], [ir]] = await Promise.all([
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
FROM "user"`,
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`,
db`SELECT count(*)::int AS n FROM invite_requests WHERE status = 'pending'`
]);
dash.trash = t?.n || 0;
dash.invite_requests = ir?.n || 0;
dash.open_reports = r?.n || 0;
dash.users = u?.users || 0;
dash.anon_users = u?.anon || 0;
@@ -669,11 +672,154 @@ export default (router, tpl) => {
if (res.json) return res.json({ success: false });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
}
// Revoke any linked invite request and notify the user
const [linkedRequest] = await db`
SELECT ir.id, ir.user_id FROM invite_requests ir
WHERE ir.token_id = ${req.post.id} AND ir.status = 'approved'
`;
if (linkedRequest) {
await db`
UPDATE invite_requests
SET status = 'revoked', reviewed_at = NOW(), reviewed_by = ${req.session.id}
WHERE id = ${linkedRequest.id}
`;
if (linkedRequest.user_id) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${linkedRequest.user_id}, 'invite_denied', 0, ${db.json({ revoked: true })})
`;
}
}
await db`delete from invite_tokens where id = ${req.post.id}`;
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
});
// ═══════════════════ Invite Requests (admin) ═══════════════════
// List all invite requests
router.get(/^\/api\/v2\/admin\/invite-requests\/?$/, lib.auth, async (req, res) => {
try {
const requests = await db`
SELECT ir.*,
u_reviewer.user as reviewed_by_name,
u_requester.login as requester_login,
u_requester.user as requester_name
FROM invite_requests ir
LEFT JOIN "user" u_reviewer ON u_reviewer.id = ir.reviewed_by
LEFT JOIN "user" u_requester ON u_requester.id = ir.user_id
ORDER BY
CASE WHEN ir.status = 'pending' THEN 0 ELSE 1 END,
ir.created_at DESC
LIMIT 200
`;
if (res.json) return res.json({ success: true, requests });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, requests }));
} catch (err) {
const msg = lib.logError(err, 'Failed to fetch invite requests');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
// Approve an invite request — generates a token and notifies the anon user
router.post(/^\/api\/v2\/admin\/invite-requests\/approve\/?$/, lib.auth, async (req, res) => {
try {
const { id } = req.post;
if (!id) {
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
}
// Check request exists and is pending
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
if (!request) {
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
}
// Generate invite token
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
const [tokenRow] = await db`
INSERT INTO invite_tokens (token, created_at, created_by)
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
RETURNING id
`;
// Update request status
await db`
UPDATE invite_requests
SET status = 'approved', token_id = ${tokenRow.id}, reviewed_by = ${req.session.id}, reviewed_at = NOW()
WHERE id = ${+id}
`;
// Find the anon user's real user_id via their fingerprint, and send them a notification
const anonUser = await db`
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
`;
if (anonUser.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${anonUser[0].user_id}, 'invite_approved', 0, ${db.json({ token, request_id: +id })})
`;
}
console.log(`[INVITE-REQ] Approved request #${id} (fp: ${request.fingerprint.slice(0, 12)}…) → token ${token.slice(0, 8)}…`);
if (res.json) return res.json({ success: true, token });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
} catch (err) {
const msg = lib.logError(err, 'Failed to approve invite request');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
// Deny an invite request
router.post(/^\/api\/v2\/admin\/invite-requests\/deny\/?$/, lib.auth, async (req, res) => {
try {
const { id } = req.post;
if (!id) {
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
}
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
if (!request) {
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
}
await db`
UPDATE invite_requests
SET status = 'denied', reviewed_by = ${req.session.id}, reviewed_at = NOW()
WHERE id = ${+id}
`;
// Notify the anon user their request was denied
const anonUser = await db`
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
`;
if (anonUser.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${anonUser[0].user_id}, 'invite_denied', 0, ${db.json({ request_id: +id })})
`;
}
console.log(`[INVITE-REQ] Denied request #${id} (fp: ${request.fingerprint.slice(0, 12)}…)`);
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
} catch (err) {
const msg = lib.logError(err, 'Failed to deny invite request');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
router.post(/^\/api\/v2\/admin\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { user_id, reason, duration } = req.post;
@@ -1544,6 +1690,26 @@ export default (router, tpl) => {
}
});
// The system ghost that deleted users' content is reassigned to. It can never log in: password '!'
// matches no hash, it is not activated and it is banned (the unban route refuses to touch it).
const ensureGhostUser = async () => {
let ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (ghost.length) return ghost;
await db`
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
ON CONFLICT (login) DO NOTHING
`;
ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (!ghost.length) throw new Error('Could not create the "deleted_user" ghost account.');
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
VALUES (${ghost[0].id}, 0, 'amoled', 0, null, 'default.png', 'deleted user')
ON CONFLICT (user_id) DO NOTHING
`;
return ghost;
};
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
try {
const { user_id } = req.post;
@@ -1554,9 +1720,8 @@ export default (router, tpl) => {
if (!target.length) throw new Error('User not found');
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be deleted.');
// Get deleted_user info
const ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (!ghost.length) throw new Error('Ghost account "deleted_user" not found. Please run migration.');
// Get deleted_user info (created on first use; see migrations/add_deleted_user_ghost.sql)
const ghost = await ensureGhostUser();
const targetId = target[0].id;
const targetLogin = target[0].login;
+43 -5
View File
@@ -12,7 +12,7 @@ import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost
base64url, fromBase64url, getRpIdFromHost, aaguidProvider
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs';
@@ -247,7 +247,10 @@ export default router => {
);
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
const userRows = await db`SELECT banned, ban_reason, ban_expires, activated FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].activated === false) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
@@ -262,7 +265,7 @@ export default router => {
SET sign_count = ${regResult.signCount}, last_used = NOW()
`;
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null, credentialId);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
@@ -402,7 +405,10 @@ export default router => {
}
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
const userRows = await db`SELECT banned, ban_reason, ban_expires, activated FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].activated === false) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
@@ -424,7 +430,7 @@ export default router => {
WHERE user_id = ${userId} AND credential_id = ${credentialId}
`.catch(() => {});
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null, credentialId);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
@@ -567,6 +573,38 @@ export default router => {
// ─── Identity ─────────────────────────────────────────────────────────────
/**
* GET /api/v2/anon/passkeys
* The current anonymous identity's passkeys (settings page). `primary` marks the one the
* identity's fingerprint is derived from.
*/
group.get(/\/passkeys$/, async (req, res) => {
try {
const userId = await getAnonSessionUserId(req);
if (!userId) return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
const rows = await db`
SELECT pc.id, pc.credential_id, pc.name, pc.aaguid, pc.created_at, pc.last_used,
(ai.credential_id IS NOT NULL) AS primary
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = pc.credential_id
WHERE pc.user_id = ${userId}
ORDER BY pc.created_at ASC
`;
const [sess] = req.session.sess_id
? await db`SELECT passkey_credential_id FROM user_sessions WHERE id = ${+req.session.sess_id}`
: [];
const inUse = sess?.passkey_credential_id || null;
return res.json({
success: true,
max: MAX_ANON_PASSKEYS,
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
});
} catch (err) {
console.error('[ANON_PASSKEY] list error:', err);
return res.json({ success: false, msg: 'Failed to load passkeys' }, 500);
}
});
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
+167 -7
View File
@@ -10,9 +10,29 @@ import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, getRpIdFromHost
base64url, getRpIdFromHost, aaguidProvider
} from '../../webauthn.mjs';
// Passkey-only accounts store this instead of a hash; lib.verify never matches it
const PASSWORD_DISABLED = '!';
const LAST_PASSKEY_MSG = 'This is your last passkey and password login is disabled. Set a password first, or add another passkey.';
const IN_USE_PASSKEY_MSG = 'This passkey is the one you are signed in with right now and cannot be removed from this session.';
// The passkey the current session was opened with (null: password login or an older session)
const sessionPasskey = async (req) => {
if (!req.session?.sess_id) return null;
const [row] = await db`select passkey_credential_id from user_sessions where id = ${+req.session.sess_id}`;
return row?.passkey_credential_id || null;
};
// True when removing a passkey would leave a passkey-only account with no way to log in
const isLastPasskeyOfPasskeyOnly = async (userId) => {
const [row] = await db`
select (u.password = ${PASSWORD_DISABLED}) as disabled,
(select count(*)::int from passkey_credentials pc where pc.user_id = u.id) as n
from "user" u where u.id = ${+userId}
`;
return !!row && row.disabled && row.n <= 1;
};
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -442,6 +462,34 @@ export default router => {
}
});
/**
* POST /api/v2/settings/password/disable
* Passkey-only account: switch off password login. Needs the current password (a hijacked session
* alone can't lock the owner out) and at least one passkey. Setting a new password re-enables it.
*/
group.post(/\/password\/disable$/, lib.registeredUser, async (req, res) => {
try {
const { current_password } = req.post || {};
const user = (await db`select password from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
if (user.password === PASSWORD_DISABLED) return res.json({ success: false, msg: 'Password login is already disabled' }, 400);
if (!current_password || !(await lib.verify(current_password, user.password))) {
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
}
const [{ n }] = await db`select count(*)::int as n from passkey_credentials where user_id = ${+req.session.id}`;
if (n < 1) return res.json({ success: false, msg: 'Add a passkey first, otherwise you could not log in anymore' }, 400);
await db`update "user" set password = ${PASSWORD_DISABLED}, force_password_change = false where id = ${+req.session.id}`;
// Sessions elsewhere may have been opened with the password: end them, keep this one
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
await db`delete from login_attempts where username = ${req.session.login}`.catch(() => {});
return res.json({ success: true, msg: 'Password login disabled. Use your passkey to sign in.' });
} catch (err) {
console.error('[SETTINGS] Disable password error:', err);
return res.json({ success: false, msg: 'Failed to disable password' }, 500);
}
});
// Update password
group.put(/\/password/, lib.registeredUser, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post;
@@ -453,7 +501,9 @@ export default router => {
const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
if (!user.force_password_change) {
// Passkey-only accounts have no current password: setting one turns password login back on
const passwordDisabled = user.password === PASSWORD_DISABLED;
if (!user.force_password_change && !passwordDisabled) {
if (!current_password) {
return res.json({ success: false, msg: 'Current password is required' }, 400);
}
@@ -576,7 +626,7 @@ export default router => {
});
// Update Font preference
group.put(/\/font/, lib.registeredUser, async (req, res) => {
group.put(/\/font/, lib.loggedin, async (req, res) => {
const { font } = req.post;
// F-023 Security: Validate font against actual files on disk
@@ -1216,7 +1266,11 @@ export default router => {
WHERE user_id = ${req.session.id}
ORDER BY created_at DESC
`;
return res.json({ success: true, passkeys: rows });
const inUse = await sessionPasskey(req);
return res.json({
success: true,
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
});
} catch (err) {
console.error('[PASSKEYS] List error:', err);
return res.json({ success: false, msg: err.message }, 500);
@@ -1328,6 +1382,8 @@ export default router => {
const body = req.post || req.body || {};
const credentialId = body.credential_id;
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
@@ -1348,6 +1404,8 @@ export default router => {
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
try {
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
@@ -1403,7 +1461,7 @@ export default router => {
// Look up credential — must belong to a registered (non-anon) user
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
u.login, u.user, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
u.login, u.user, u.activated, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
FROM passkey_credentials pc
JOIN "user" u ON u.id = pc.user_id
WHERE pc.credential_id = ${credentialId}
@@ -1421,6 +1479,10 @@ export default router => {
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
}
if (!row.activated) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
// Verify the assertion
try {
await verifyAuthentication({
@@ -1457,9 +1519,10 @@ export default router => {
last_used: stamp,
last_action: '/passkey-login',
kmsi: 1,
ip
ip,
passkey_credential_id: credentialId
};
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`;
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip', 'passkey_credential_id')}`;
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
@@ -1472,5 +1535,102 @@ export default router => {
return group;
});
// ═══════════════════ Invite Request (anon user) ═══════════════════
// Submit an invite request (requires a session — anon or registered, but mainly for anon)
router.post(/^\/api\/v2\/invite-request\/?$/, lib.loggedin, async (req, res) => {
try {
if (!req.session.is_anon) {
return res.json({ success: false, msg: 'You already have a registered account' }, 400);
}
const fingerprint = req.session.fingerprint;
if (!fingerprint) {
return res.json({ success: false, msg: 'No passkey identity found' }, 400);
}
// Check if there's already a pending request for this fingerprint
const [existing] = await db`
SELECT id, status FROM invite_requests
WHERE fingerprint = ${fingerprint} AND status = 'pending'
LIMIT 1
`;
if (existing) {
return res.json({ success: false, msg: 'You already have a pending invite request' }, 409);
}
const reason = (req.post.reason || '').trim().slice(0, 500);
const ip = security.getRealIP(req);
const ipHash = ip ? crypto.createHash('sha256').update(ip).digest('hex').slice(0, 16) : null;
await db`
INSERT INTO invite_requests (user_id, fingerprint, ip_hash, reason)
VALUES (${req.session.id}, ${fingerprint}, ${ipHash}, ${reason})
`;
// Notify all admin users
const anonLogin = req.session.login || req.session.user || 'anonymous';
const admins = await db`SELECT id FROM "user" WHERE admin = true`;
if (admins.length > 0) {
const notifications = admins.map(a => ({
user_id: a.id,
type: 'invite_request',
reference_id: 0,
data: db.json({ username: anonLogin, reason: reason.slice(0, 100) })
}));
await db`INSERT INTO notifications ${db(notifications)}`;
}
console.log(`[INVITE-REQ] New request from fp: ${fingerprint.slice(0, 12)}…`);
return res.json({ success: true, msg: 'Invite request submitted! An admin will review it shortly.' });
} catch (err) {
console.error('[INVITE-REQ] Submit error:', err);
return res.json({ success: false, msg: 'Failed to submit request' }, 500);
}
});
// Check status of current invite request
router.get(/^\/api\/v2\/invite-request\/status\/?$/, lib.loggedin, async (req, res) => {
try {
const fingerprint = req.session.fingerprint;
if (!fingerprint) {
return res.json({ success: true, status: null });
}
const [request] = await db`
SELECT ir.id, ir.status, ir.created_at, ir.reviewed_at,
it.token, it.is_used
FROM invite_requests ir
LEFT JOIN invite_tokens it ON it.id = ir.token_id
WHERE ir.fingerprint = ${fingerprint}
ORDER BY ir.created_at DESC
LIMIT 1
`;
if (!request) {
return res.json({ success: true, status: null });
}
// Auto-revoke if token was deleted or already used but status still says approved
let status = request.status;
if (status === 'approved' && (!request.token || request.is_used)) {
await db`UPDATE invite_requests SET status = 'revoked' WHERE id = ${request.id}`;
status = 'revoked';
}
return res.json({
success: true,
status,
created_at: request.created_at,
reviewed_at: request.reviewed_at,
token: status === 'approved' ? request.token : undefined
});
} catch (err) {
console.error('[INVITE-REQ] Status check error:', err);
return res.json({ success: false, msg: 'Failed to check status' }, 500);
}
});
return router;
};
+8
View File
@@ -9,6 +9,7 @@ import queue from '../../queue.mjs';
import path from "path";
import f0cklib from "../../routeinc/f0cklib.mjs";
import { addPrivateItem } from "../../private_items.mjs";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../../transcode.mjs";
// ──────────────────────────────────────────────────────────────────────
// In-memory job progress map (keyed by jobId string)
@@ -734,6 +735,13 @@ export default router => {
source = source.replace(/\.mkv$/, '.mp4');
mime = 'video/mp4';
}
if (TRANSCODE_TO_MP4.has(mime)) { // .mpg etc.: browsers can't play it, re-encode
const converted = source.replace(/\.[^./]+$/, '') + '.conv.mp4';
await transcodeToMp4(queue, source, converted);
await fs.unlink(source).catch(() => {});
source = converted;
mime = 'video/mp4';
}
if (source.match(/\.opus$/)) {
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]);
await fs.unlink(source).catch(() => {});
+3 -2
View File
@@ -654,8 +654,9 @@ export default (router, tpl) => {
});
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute);
// This avoids ambiguity with the profile route. The other /user/:user/<page> routes (comments, tags, halls)
// are excluded too, or an item lookup for "comments" etc. would answer "post not visible" before they run.
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!(?:f0cks|uploads|favs|comments|tags|halls)$)[a-zA-Z0-9_-]+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids)
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
+14 -7
View File
@@ -492,13 +492,14 @@ export default (router, tpl) => {
db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id IN (SELECT id FROM items WHERE active = true OR is_deleted = true) AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup admin_pending notifications:', err));
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning', 'invite_approved', 'invite_denied'];
const ADMIN_TYPES = ['invite_request'];
const nsflTagId = cfg.nsfl_tag_id || 3;
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
const offset = (page - 1) * limit;
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
const notifications = typeFilter
? await db`
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
@@ -596,7 +597,7 @@ export default (router, tpl) => {
LEFT JOIN items i ON n.item_id = i.id
LEFT JOIN reports r ON n.type = 'report' AND n.reference_id = r.id
WHERE n.user_id = ${req.session.id} AND n.is_read = false
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning')
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning', 'invite_request', 'invite_approved', 'invite_denied')
OR (
${req.session.do_not_disturb !== true} AND (
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
@@ -604,7 +605,7 @@ export default (router, tpl) => {
)
)
)
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning'))
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning', 'invite_request', 'invite_approved', 'invite_denied'))
AND (n.type != 'report' OR r.status = 'pending')
AND (n.type != 'admin_pending' OR (i.active = false AND i.is_deleted = false))
ORDER BY n.created_at DESC
@@ -630,15 +631,21 @@ export default (router, tpl) => {
}
});
// Mark all as read
// Mark all as read (optionally filtered by tab)
router.post('/api/notifications/read', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
const tab = req.url.qs?.tab || null;
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
if (typeFilter) {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id} AND type = ANY(${typeFilter})`;
} else {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
}
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
body: JSON.stringify({ success: true, tab })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
+62 -18
View File
@@ -80,6 +80,11 @@ export default (router, tpl) => {
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
}
// anon_* logins are reserved for anonymous shadow users (treated as anon throughout the app)
if (/^anon_/i.test(username) || username.toLowerCase() === 'anonymous') {
return renderError("Username taken");
}
if (!password || password.length < 20) {
return renderError("Password must be at least 20 characters long.");
}
@@ -90,7 +95,7 @@ export default (router, tpl) => {
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.site_key && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try {
@@ -154,23 +159,55 @@ export default (router, tpl) => {
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
// Anonymous passkey identity registering: upgrade the existing shadow user in place
// instead of creating a new one. Favs, comments, uploads and passkeys stay attached;
// dropping the anon_identities row is what turns the account into a regular one.
const upgradeAnonId = req.session?.is_anon ? req.session.id : null;
let userId;
try {
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
returning id
`;
userId = newUser[0].id;
if (upgradeAnonId) {
await db.begin(async sql => {
// Uploads reference their owner by name (items.username = the anon shadow login),
// so they move to the new username, the name regular uploads are stored under
const [old] = await sql`select login from "user" where id = ${upgradeAnonId} for update`;
if (old?.login) {
await sql`update items set username = ${username} where lower(username) = lower(${old.login})`;
}
await sql`
update "user"
set "login" = ${username.toLowerCase()}, "password" = ${hash}, "user" = ${username},
"email" = ${email || null}, "activated" = ${activated}, "activation_token" = ${activationToken}
where id = ${upgradeAnonId}
`;
await sql`
update user_options
set display_name = null, avatar_file = coalesce(avatar_file, 'default.png')
where user_id = ${upgradeAnonId}
`;
await sql`delete from anon_identities where user_id = ${upgradeAnonId}`;
});
userId = upgradeAnonId;
if (global._invalidateSessionCache && req.cookies?.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
} else {
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
returning id
`;
userId = newUser[0].id;
// Assign default avatar file
const avatarId = null;
const avatarFile = 'default.png';
// Assign default avatar file
const avatarId = null;
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
}
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);
if (err.code === '23505') { // Unique constraint violation
@@ -200,6 +237,11 @@ export default (router, tpl) => {
// In production they should see an error, but let's keep it simple for now.
}
if (upgradeAnonId) {
// Unactivated accounts may not stay logged in — end the anon sessions until the email link is used
await db`delete from user_sessions where user_id = ${upgradeAnonId}`;
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
}
await renderSuccess("Registration successful! Please check your email to activate your account.");
return;
}
@@ -217,13 +259,15 @@ export default (router, tpl) => {
await security.recordAttempt(ip, username, 'register', true);
const successMsg = "Registration successful! You can now login.";
const successMsg = upgradeAnonId
? "Username claimed, you can still use your passkey."
: "Registration successful! You can now login.";
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg }));
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg, upgraded: !!upgradeAnonId }));
}
// Redirect to home with login success message
return res.writeHead(302, { "Location": "/?login=success" }).end();
// Upgraded accounts keep their current session; otherwise redirect home with login success message
return res.writeHead(302, { "Location": upgradeAnonId ? "/settings" : "/?login=success" }).end();
});
return router;
+3 -1
View File
@@ -37,7 +37,7 @@ export default (router, tpl) => {
// Get full user info
const user = (await db`
select email, created_at from "user" where id = ${+req.session.id}
select email, created_at, (password = '!') as password_disabled from "user" where id = ${+req.session.id}
`)[0];
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -58,6 +58,8 @@ export default (router, tpl) => {
banner_size: userOptions?.banner_size || 'cover',
email: user?.email || '',
joined: user?.created_at || null,
// Passkey-only account: password login switched off (password = '!')
password_disabled: !!user?.password_disabled,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
enable_swf: cfg.enable_swf,
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
+2 -3
View File
@@ -126,11 +126,10 @@ export const isAnonSession = (session) => {
};
// Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value).
// config `onara: false` disables it for everyone; `onara: true` is only the default for users
// without a stored preference. `forceOn` covers explicit requests like ?onara=1.
// config `onara` is only the default for users without a stored preference (true = on, false = off);
// users can always turn it on or off themselves. `forceOn` covers explicit requests like ?onara=1.
export const isOnaraEnabledFor = (req, forceOn = false) => {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
if (c === false) return false;
if (forceOn) return true;
const ck = req?.cookies || {};
const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara;
+26
View File
@@ -0,0 +1,26 @@
// Re-encode video that browsers can't play (MPEG-1/2 program streams, .mpg/.mpeg) to web MP4.
// A plain remux (-codec copy) is not enough for these: MPEG-1/2 video has to become H.264.
// file --mime-type results that get converted
export const TRANSCODE_TO_MP4 = new Set(['video/mpeg']);
/**
* Re-encode src to H.264/AAC MP4 at dest.
* - yadif only touches frames flagged as interlaced (DVD MPEG-2), progressive video is left alone
* - even dimensions + yuv420p for broad decoder support, faststart so playback starts before the full download
* @param {{ spawn: Function }} queue
* @param {string} src
* @param {string} dest
*/
export async function transcodeToMp4(queue, src, dest) {
await queue.spawn('ffmpeg', [
'-y', '-hide_banner', '-loglevel', 'error',
'-i', src,
'-map', '0:v:0', '-map', '0:a?',
'-vf', 'yadif=deint=interlaced,scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
dest
]);
}
+8
View File
@@ -8,6 +8,7 @@ import autotagger from "../autotagger.mjs";
import fetch from "flumm-fetch";
import fs from "fs";
import path from "path";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../transcode.mjs";
const regex = {
@@ -648,6 +649,13 @@ export default async bot => {
source = source.replace(/\.mkv$/, '.mp4');
mime = 'video/mp4';
}
if (TRANSCODE_TO_MP4.has(mime)) { // .mpg etc.: browsers can't play it, re-encode
const converted = path.join(cfg.paths.tmp, `${uuid}.conv.mp4`);
await transcodeToMp4(queue, source, converted);
await fs.promises.unlink(source).catch(_ => { });
source = converted;
mime = 'video/mp4';
}
if (source.match(/\.opus$/)) { // opus failsafe
await queue.spawn('ffmpeg', ['-i', path.join(cfg.paths.tmp, `${uuid}.opus`), '-codec', 'copy', path.join(cfg.paths.tmp, `${uuid}.ogg`)]);
await fs.promises.unlink(source);
+41
View File
@@ -472,3 +472,44 @@ export function buildAuthenticationOptions({ challenge, allowCredentials = [], r
timeout: 60000
};
}
// ─── Authenticator names ─────────────────────────────────────────────────────
// AAGUIDs (stored as 32 hex chars) of common passkey providers, from the community list at
// github.com/passkeydeveloper/passkey-authenticator-aaguids. All zeros = the browser/authenticator
// did not disclose it (common with Firefox and "none" attestation).
const AAGUID_PROVIDERS = {
'd548826e79b4db40a3d811116f7e8349': 'Bitwarden',
'fbfc3007154e4ecc8c0b6e020557d7bd': 'iCloud Keychain',
'dd4ec289e01d41c9bb8970fa845d4bf2': 'iCloud Keychain (Managed)',
'ea9b8d664d011d213ce4b6b48cb575d4': 'Google Password Manager',
'adce000235bcc60a648b0b25f1f05503': 'Chrome on Mac',
'08987058cadc4b81b6e130de50dcbe96': 'Windows Hello',
'9ddd1817af5a4672a2b93e3dd95000a9': 'Windows Hello',
'6028b017b1d44c02b4b3afcdafc96bb2': 'Windows Hello',
'bada5566a7aa401fbd9645619a55120d': '1Password',
'531126d6e717415c93203d9aa6981239': 'Dashlane',
'fdb141b25d84443e8a354698c205a502': 'KeePassXC',
'50726f746f6e5061737350726f746f6e': 'Proton Pass',
'53414d53554e47000000000000000000': 'Samsung Pass',
'cb69481e8ff7403993ec0a2729a154a8': 'YubiKey',
'ee882879721c491397753dfcce97072a': 'YubiKey',
'fa2b99dc9e3942578f924a30d23c4118': 'YubiKey',
'2fc0579f811347eab116bb5a8db9202a': 'YubiKey',
'c5ef55ffad9a4b9fb580adebafe026d0': 'YubiKey',
'73bb0cd4e50249b89c6fb59445bf720b': 'YubiKey',
'a4e9fc6d4cbe4758b8ba37598bb5bbaa': 'Yubico Security Key',
'b92c3f9ac0144056887f140a2501163b': 'Yubico Security Key',
'0bb43545fd2c418587ddfeb0b2916ace': 'Yubico Security Key'
};
/**
* Human name of the authenticator that holds a passkey, from its stored AAGUID.
* @param {string|null} aaguidHex
* @returns {string|null} provider name, 'Undisclosed' for the all-zero AAGUID, or null if unknown
*/
export function aaguidProvider(aaguidHex) {
if (!aaguidHex) return null;
const hex = String(aaguidHex).toLowerCase().replace(/[^0-9a-f]/g, '');
if (/^0+$/.test(hex)) return 'Undisclosed';
return AAGUID_PROVIDERS[hex] || null;
}