gfds
This commit is contained in:
+10
-1
@@ -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 });
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user