Files
f0ckm/src/inc/routes/apiv2/settings.mjs
T
2026-09-28 22:12:38 +02:00

1477 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { canAnonDo, isAnonSession } from '../../settings.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, getRpIdFromHost
} from '../../webauthn.mjs';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
export default router => {
router.group(/^\/api\/v2\/settings/, group => {
group.put(/\/setAvatar/, lib.registeredUser, async (req, res) => {
if (!req.post.avatar) {
return res.json({
msg: 'no avatar provided',
debug: req.post
}, 400); // bad request
}
const avatar = +req.post.avatar;
const itemid = (await db`
select id
from "items"
where id = ${+avatar} and active = true
`)?.[0]?.id;
if (!itemid) {
return res.json({
msg: 'itemid not found'
}, 404); // not found
}
const q = await db`
update "user_options" set ${db({
avatar
}, 'avatar')
}
where user_id = ${+req.session.id}
`;
return res.json({
msg: q
}, 200);
});
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
group.put(/\/useCustomAvatar/, lib.registeredUser, async (req, res) => {
// Check if user has a custom avatar file
const userOpts = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
`)[0];
if (!userOpts?.avatar_file) {
return res.json({
success: false,
msg: 'No custom avatar uploaded'
}, 400);
}
// Set avatar to 0 so avatar_file takes priority
await db`
update user_options
set avatar = 0
where user_id = ${+req.session.id}
`;
return res.json({
success: true,
avatar_file: userOpts.avatar_file,
msg: 'Switched to custom avatar'
}, 200);
});
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
});
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagname = req.post?.tagname || req.body?.tagname;
const tagId = req.post?.tag_id || req.body?.tag_id;
if (!tagname && !tagId) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tag = tagId
? (await db`select id, tag, normalized from tags where id = ${+tagId}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagname}) or tag = ${tagname}`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
await db`
update user_options
set excluded_tags = array_append(coalesce(excluded_tags, '{}'), ${tag.id})
where user_id = ${+req.session.id} and not (${tag.id} = any(coalesce(excluded_tags, '{}')))
`;
if (req.session) {
if (!req.session.excluded_tags) req.session.excluded_tags = [];
if (!req.session.excluded_tags.includes(tag.id)) {
req.session.excluded_tags.push(tag.id);
}
}
// Return updated list
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags, tag }, 200);
});
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagParam = decodeURIComponent(req.params.tag);
const isNum = /^\d+$/.test(tagParam);
const tag = isNum
? (await db`select id, tag, normalized from tags where id = ${+tagParam}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagParam}) or tag = ${tagParam}`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
await db`
update user_options
set excluded_tags = array_remove(coalesce(excluded_tags, '{}'), ${tag.id})
where user_id = ${+req.session.id}
`;
if (req.session && req.session.excluded_tags) {
req.session.excluded_tags = req.session.excluded_tags.filter(id => id !== tag.id);
}
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags, tag }, 200);
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
group.post(/\/link\/token/, lib.registeredUser, async (req, res) => {
// 6-char alphanumeric code
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
try {
await db`
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
`;
return res.json({ success: true, token, type }, 200);
} catch (e) {
console.error('Token gen error:', e);
// Fallback for schema if link_token doesn't have type yet (optional, but good for safety)
// If migration failed or not applied to link_token... wait, check schema for link_token first.
// Schema for link_token: user_id, token, created_at. NO TYPE.
// Ah, I need to add 'type' to link_token too OR just rely on the bot to know which type it is verifying?
// Actually, the bot trigger knows its type. When !link <token> is sent to Discord bot, it checks token.
// If I use same table for both, a token generated for Matrix could be used on Discord if not careful.
// It's safer to add type to link_token OR just rely on who claims it.
// If I don't add type to link_token, then a token is just "allow linking".
// If I send !link TOKEN to Matrix bot, it links Matrix account.
// If I send !link TOKEN to Discord bot, it links Discord account.
// This seems fine without adding type to link_token, because the USER triggers the action on the specific platform.
// So I will stick to the existing schema for link_token for now to avoid another migration if possible.
// BUT, I should check if I really need date restriction or type.
// Let's keep it simple: Token is just a key. Authenticated user generated it.
// Whoever consumes it (Discord bot or Matrix bot) links THEIR account to that user_id.
// So NO CHANGE needed for link_token table schema.
// Reverting to original simple insert (ignoring type in DB, just returning it for frontend convenience if needed)
await db`
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
`;
return res.json({ success: true, token, type }, 200);
}
});
// Get linked accounts (Discord & Matrix)
group.get(/\/link\/accounts/, lib.registeredUser, async (req, res) => {
try {
const aliases = await db`
SELECT alias, type FROM user_alias
WHERE userid = ${req.session.id}
ORDER BY type DESC, alias ASC
`;
// Sanitize aliases
const sanitized = aliases.map(a => ({
alias: a.alias.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
type: a.type || 'discord' // Default to discord if null (though migration sets default)
}));
return res.json({ success: true, aliases: sanitized }, 200);
} catch (e) {
console.error('Get linked error:', e);
return res.json({ success: false, msg: 'Error fetching linked accounts' }, 500);
}
});
// Unlink account
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
try {
const alias = decodeURIComponent(req.params.alias);
const type = req.params.type;
const result = await db`
DELETE FROM user_alias
WHERE lower(alias) = lower(${alias})
AND userid = ${req.session.id}
AND type = ${type}
RETURNING alias
`;
if (result.length > 0) {
return res.json({ success: true, msg: 'Account unlinked' }, 200);
} else {
return res.json({ success: false, msg: 'Account not found' }, 404);
}
} catch (e) {
console.error('Unlink error:', e);
return res.json({ success: false, msg: 'Error unlinking account' }, 500);
}
});
// Backward compatibility routes for Discord (Deprecated)
// Discord Token Generation (Redirect to generic)
group.post(/\/discord\/token/, lib.registeredUser, async (req, res) => {
// Just call the logic inline
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
try {
await db`
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
`;
return res.json({ success: true, token }, 200);
} catch (e) { return res.json({ success: false }, 500); }
});
// Get linked Discord accounts (Legacy)
group.get(/\/discord\/linked/, lib.registeredUser, async (req, res) => {
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
});
// Unlink Discord account (Legacy)
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
const alias = decodeURIComponent(req.params.alias);
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, msg: 'Account unlinked' }, 200);
});
// Update MOTD visibility preference
group.put(/\/motd/, lib.loggedin, async (req, res) => {
const show = req.post.show === true || req.post.show === 'true';
try {
await db`
update user_options
set show_motd = ${show}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.show_motd = show;
return res.json({ success: true, show }, 200);
} catch (e) {
console.error('Update MOTD pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Autoplay (on load) preference
group.put(/\/autoplay/, lib.loggedin, async (req, res) => {
const disable_autoplay = req.post.disable_autoplay === true || req.post.disable_autoplay === 'true';
try {
await db`
update user_options
set disable_autoplay = ${disable_autoplay}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.disable_autoplay = disable_autoplay;
return res.json({ success: true, disable_autoplay }, 200);
} catch (e) {
console.error('Update Autoplay pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Swiping preference
group.put(/\/swiping/, lib.loggedin, async (req, res) => {
const disable_swiping = req.post.disable_swiping === true || req.post.disable_swiping === 'true';
try {
await db`
update user_options
set disable_swiping = ${disable_swiping}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.disable_swiping = disable_swiping;
return res.json({ success: true, disable_swiping }, 200);
} catch (e) {
console.error('Update Swiping pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update New Layout visibility preference
group.put(/\/layout/, lib.loggedin, async (req, res) => {
const use_new_layout = req.post.use_new_layout === true || req.post.use_new_layout === 'true';
try {
await db`
update user_options
set use_new_layout = ${use_new_layout}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.use_new_layout = use_new_layout;
return res.json({ success: true, use_new_layout }, 200);
} catch (e) {
console.error('Update Layout pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Favorites Privacy preference
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
try {
await db`
update user_options
set favorites_private = ${favorites_private}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.favorites_private = favorites_private;
return res.json({ success: true, favorites_private }, 200);
} catch (e) {
console.error('Update Favorites Privacy pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Hide Fav Badge preference
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
try {
await db`
update user_options
set hide_fav_badge = ${hide_fav_badge}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
return res.json({ success: true, hide_fav_badge }, 200);
} catch (e) {
console.error('Update Hide Fav Badge pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.registeredUser, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
const vis = parseInt(req.post.default_upload_visibility, 10);
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
}
try {
await db`
update user_options
set default_upload_visibility = ${vis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.default_upload_visibility = vis;
return res.json({ success: true, default_upload_visibility: vis }, 200);
} catch (e) {
console.error('Update Default Upload Visibility pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference
group.put(/\/username_color/, lib.registeredUser, async (req, res) => {
const { color } = req.post;
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
return res.json({ success: false, msg: 'Invalid color format' }, 400);
}
try {
await db`
update user_options
set username_color = ${color}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.username_color = color;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
username_color: color
});
await db`select pg_notify('profile_update', ${payloadStr})`;
return res.json({ success: true, color }, 200);
} catch (e) {
console.error('Update Username Color error:', e);
return res.json({ success: false, msg: 'Error updating color' }, 500);
}
});
// Update password
group.put(/\/password/, lib.registeredUser, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post;
if (!new_password || !new_password_confirm) {
return res.json({ success: false, msg: 'New password and confirmation are required' }, 400);
}
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) {
if (!current_password) {
return res.json({ success: false, msg: 'Current password is required' }, 400);
}
const valid = await lib.verify(current_password, user.password);
if (!valid) {
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
}
}
if (new_password !== new_password_confirm) {
return res.json({ success: false, msg: 'New passwords do not match' }, 400);
}
if (new_password.length < 20) {
return res.json({ success: false, msg: 'New password must be at least 20 characters long' }, 400);
}
const hash = await lib.hash(new_password);
await db`update "user" set password = ${hash}, force_password_change = false where id = ${+req.session.id}`;
// Clear flag in session too
if (req.session) req.session.force_password_change = false;
// Invalidate all other sessions (Issue 21 fix)
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
return res.json({ success: true, msg: 'Password updated successfully' }, 200);
});
// Update email
group.put(/\/email/, lib.registeredUser, async (req, res) => {
const { email } = req.post;
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
const cleanEmail = email.trim();
if (!cleanEmail.includes('@')) return res.json({ success: false, msg: 'Invalid email address' }, 400);
// Check if email is already taken by another user
const existing = await db`
select id from "user"
where lower(email) = lower(${cleanEmail})
and id != ${+req.session.id}
limit 1
`;
if (existing.length > 0) {
return res.json({ success: false, msg: 'Email already in use' }, 400);
}
await db`update "user" set email = ${cleanEmail} where id = ${+req.session.id}`;
return res.json({ success: true, msg: 'Email updated successfully' }, 200);
});
// Update Display Name
group.put(/\/display_name/, lib.registeredUser, async (req, res) => {
const { display_name } = req.post;
if (display_name !== undefined && typeof display_name !== 'string') {
return res.json({ success: false, msg: 'Invalid display name format' }, 400);
}
const cleanDisplayName = display_name ? display_name.trim().substring(0, 32) : null;
try {
await db`
update user_options
set display_name = ${cleanDisplayName}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.display_name = cleanDisplayName;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
display_name: cleanDisplayName
});
await db`select pg_notify('profile_update', ${payloadStr})`;
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
} catch (e) {
console.error('Update Display Name error:', e);
return res.json({ success: false, msg: 'Error updating display name' }, 500);
}
});
// Update Description
group.put(/\/description/, lib.registeredUser, async (req, res) => {
if (!cfg.websrv.enable_profile_description) {
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
}
const { description } = req.post;
if (description !== undefined && typeof description !== 'string') {
return res.json({ success: false, msg: 'Invalid description format' }, 400);
}
const cleanDescription = description ? description.trim().substring(0, 2000) : null;
try {
await db`
update user_options
set description = ${cleanDescription}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.description = cleanDescription;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
description: cleanDescription
});
await db`select pg_notify('profile_update', ${payloadStr})`;
return res.json({ success: true, description: cleanDescription }, 200);
} catch (e) {
console.error('Update Description error:', e);
return res.json({ success: false, msg: 'Error updating description' }, 500);
}
});
// Update Font preference
group.put(/\/font/, lib.registeredUser, async (req, res) => {
const { font } = req.post;
// F-023 Security: Validate font against actual files on disk
// The font value is rendered into CSS url() in header.html, so it must be a real filename
if (font) {
const fontsDir = cfg.paths.fonts;
try {
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
if (!available.includes(font)) {
return res.json({ success: false, msg: 'Invalid font selection' }, 400);
}
} catch {
return res.json({ success: false, msg: 'Font directory unavailable' }, 500);
}
}
try {
await db`
update user_options
set font = ${font || null}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.font = font || null;
return res.json({ success: true, font: font || null }, 200);
} catch (e) {
console.error('Update Font error:', e);
return res.json({ success: false, msg: 'Error updating font' }, 500);
}
});
// Lightweight "who am I right now" endpoint — reads directly from DB (not session cache)
// Used by the frontend to sync display_name after it may have been changed by an admin
group.get(/\/me$/, lib.loggedin, async (req, res) => {
const row = (await db`
SELECT u.login, u.user, uo.display_name
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE u.id = ${+req.session.id}
LIMIT 1
`)[0];
if (!row) return res.json({ success: false }, 404);
return res.json({
success: true,
login: row.login,
user: row.user,
display_name: row.display_name || null
}, 200);
});
// Update min xD score filter preference
group.put(/\/min_xd_score/, lib.loggedin, async (req, res) => {
const raw = req.post.min_xd_score;
const min_xd_score = parseInt(raw, 10);
if (isNaN(min_xd_score) || min_xd_score < 0 || min_xd_score > 999) {
return res.json({ success: false, msg: 'Invalid value: must be 0–999' }, 400);
}
try {
await db`
update user_options
set min_xd_score = ${min_xd_score}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.min_xd_score = min_xd_score;
return res.json({ success: true, min_xd_score }, 200);
} catch (e) {
console.error('Update min_xd_score error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update background blur preference
group.put(/\/background/, lib.loggedin, async (req, res) => {
const show_background = req.post.show_background === 'true' || req.post.show_background === true;
try {
await db`
update user_options
set show_background = ${show_background}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.show_background = show_background;
return res.json({ success: true, show_background }, 200);
} catch (e) {
console.error('Update background error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Ruffle (Flash) preferences
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
const ruffle_volume = req.post.ruffle_volume !== undefined ? parseFloat(req.post.ruffle_volume) : undefined;
if (ruffle_volume !== undefined && (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1)) {
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
}
try {
const updateData = { ruffle_background };
if (ruffle_volume !== undefined) updateData.ruffle_volume = ruffle_volume;
await db`
update user_options
set ${db(updateData)}
where user_id = ${+req.session.id}
`;
if (req.session) {
req.session.ruffle_background = ruffle_background;
if (ruffle_volume !== undefined) req.session.ruffle_volume = ruffle_volume;
}
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
} catch (e) {
console.error('Update Ruffle pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update quote_emojis preference (render :emoji: inside quote replies)
group.put(/\/quote_emojis/, lib.loggedin, async (req, res) => {
const quote_emojis = req.post.quote_emojis === true || req.post.quote_emojis === 'true';
try {
await db`
update user_options
set quote_emojis = ${quote_emojis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.quote_emojis = quote_emojis;
return res.json({ success: true, quote_emojis }, 200);
} catch (e) {
console.error('Update quote_emojis error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update embed_youtube_in_comments preference
group.put(/\/embed_youtube_in_comments/, lib.loggedin, async (req, res) => {
const embed_youtube_in_comments = req.post.embed_youtube_in_comments === true || req.post.embed_youtube_in_comments === 'true';
try {
await db`
update user_options
set embed_youtube_in_comments = ${embed_youtube_in_comments}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.embed_youtube_in_comments = embed_youtube_in_comments;
return res.json({ success: true, embed_youtube_in_comments }, 200);
} catch (e) {
console.error('Update embed_youtube_in_comments error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update hide_koepfe preference (hide the Köpfe background images if enabled in config)
group.put(/\/hide_koepfe/, lib.loggedin, async (req, res) => {
const hide_koepfe = req.post.hide_koepfe === true || req.post.hide_koepfe === 'true';
try {
await db`
update user_options
set hide_koepfe = ${hide_koepfe}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.hide_koepfe = hide_koepfe;
return res.json({ success: true, hide_koepfe }, 200);
} catch (e) {
console.error('Update hide_koepfe error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update alternative infobox preference (per-user toggle for the rich author block)
group.put(/\/alternative_infobox/, lib.loggedin, async (req, res) => {
const use_alternative_infobox = req.post.use_alternative_infobox === true || req.post.use_alternative_infobox === 'true';
try {
const mode = req.session.mode || 0;
const theme = req.session.theme || cfg.websrv.theme || 'amoled';
await db`
insert into user_options (user_id, use_alternative_infobox, mode, theme)
values (${+req.session.id}, ${use_alternative_infobox}, ${mode}, ${theme})
on conflict (user_id) do update set
use_alternative_infobox = excluded.use_alternative_infobox
`;
if (req.session) req.session.use_alternative_infobox = use_alternative_infobox;
return res.json({ success: true, use_alternative_infobox }, 200);
} catch (e) {
console.error('Update alternative_infobox error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update alternative steuerung preference (per-user toggle for icon-only nav)
group.put(/\/alternative_steuerung/, lib.loggedin, async (req, res) => {
const use_alternative_steuerung = req.post.use_alternative_steuerung === true || req.post.use_alternative_steuerung === 'true';
try {
await db`
update user_options
set use_alternative_steuerung = ${use_alternative_steuerung}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.use_alternative_steuerung = use_alternative_steuerung;
return res.json({ success: true, use_alternative_steuerung }, 200);
} catch (e) {
console.error('Update alternative_steuerung error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update per-user language preference
group.put(/\/language/, lib.loggedin, async (req, res) => {
if (cfg.websrv.allow_language_change === false) {
return res.json({ success: false, msg: 'Language change is disabled by the site administrator' }, 403);
}
const { language } = req.post;
// NULL means "use site default"; only allow known locale codes
const ALLOWED = ['en', 'de', 'nl', 'zange', null, ''];
const lang = (language === '' || language === null || language === undefined) ? null : language;
if (!ALLOWED.includes(lang)) {
return res.json({ success: false, msg: 'Unsupported language' }, 400);
}
try {
await db`
update user_options
set language = ${lang}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.language = lang;
return res.json({ success: true, language: lang }, 200);
} catch (e) {
console.error('Update language error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update comment display mode preference
group.put(/\/comment_display_mode/, lib.loggedin, async (req, res) => {
const mode = parseInt(req.post.mode, 10);
if (isNaN(mode) || (mode !== 0 && mode !== 1)) {
return res.json({ success: false, msg: 'Invalid mode' }, 400);
}
// Check if mode is forced
const forced = (await db`select force_comment_display_mode from user_options where user_id = ${+req.session.id}`)[0]?.force_comment_display_mode;
if (forced) {
return res.json({ success: false, msg: 'Comment layout is locked for your account.' }, 403);
}
try {
await db`
update user_options
set comment_display_mode = ${mode}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.comment_display_mode = mode;
return res.json({ success: true, mode }, 200);
} catch (e) {
console.error('Update comment_display_mode error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update notification preferences (Consolidated Endpoint)
group.post('/notifications', lib.loggedin, async (req, res) => {
const { key, value } = req.post;
const allowedKeys = ['receive_system_notifications', 'receive_user_notifications', 'do_not_disturb'];
if (!allowedKeys.includes(key)) {
return res.json({ success: false, msg: 'Invalid preference key' }, 400);
}
const boolValue = value === true || value === 'true';
try {
await db`
update user_options
set ${db({ [key]: boolValue }, key)}
where user_id = ${+req.session.id}
`;
if (req.session) req.session[key] = boolValue;
await db`SELECT pg_notify('profile_update', ${JSON.stringify({ user_id: req.session.id, [key]: boolValue })})`;
return res.json({ success: true, [key]: boolValue }, 200);
} catch (e) {
console.error(`Update notification preference (${key}) error:`, e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// --- Upload API Key Management ---
// GET /api/v2/settings/api-key
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
group.get(/\/api-key$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
try {
const row = (await db`
SELECT api_key, created_at
FROM user_api_keys
WHERE user_id = ${+req.session.id}
LIMIT 1
`)[0];
if (!row) {
return res.json({ success: true, has_key: false }, 200);
}
return res.json({
success: true,
has_key: true,
preview: `****${row.api_key.slice(-8)}`,
created_at: row.created_at
}, 200);
} catch (e) {
console.error('[API KEY] GET error:', e);
return res.json({ success: false, msg: 'Error fetching API key' }, 500);
}
});
// POST /api/v2/settings/api-key/regenerate
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
group.post(/\/api-key\/regenerate$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
try {
const newKey = crypto.randomBytes(32).toString('hex');
await db`
INSERT INTO user_api_keys (user_id, api_key, created_at)
VALUES (${+req.session.id}, ${newKey}, now())
ON CONFLICT (user_id) DO UPDATE
SET api_key = EXCLUDED.api_key,
created_at = now()
`;
return res.json({
success: true,
api_key: newKey,
msg: 'API key generated. Copy it now — it will not be shown again in full.'
}, 200);
} catch (e) {
console.error('[API KEY] Regenerate error:', e);
return res.json({ success: false, msg: 'Error generating API key' }, 500);
}
});
// DELETE /api/v2/settings/api-key
// Revokes (deletes) the user's API key.
group.delete(/\/api-key$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
try {
const result = await db`
DELETE FROM user_api_keys
WHERE user_id = ${+req.session.id}
RETURNING user_id
`;
if (result.length === 0) {
return res.json({ success: false, msg: 'No API key to revoke' }, 404);
}
return res.json({ success: true, msg: 'API key revoked' }, 200);
} catch (e) {
console.error('[API KEY] Delete error:', e);
return res.json({ success: false, msg: 'Error revoking API key' }, 500);
}
});
// GET /api/v2/settings/api-key/sharex-config
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
group.get(/\/api-key\/sharex-config$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.status(403).reply({ body: 'API keys are disabled' });
}
try {
const row = (await db`
SELECT api_key FROM user_api_keys
WHERE user_id = ${+req.session.id}
LIMIT 1
`)[0];
if (!row) {
return res.status(404).reply({ body: 'No API key — generate one first in Settings.' });
}
// Determine a safe default rating for the ShareX config.
// In shitpost mode the server accepts uploads without a rating, but
// we still send 'sfw' so the item gets a rating tag by default.
const defaultRating = 'sfw';
const sxcu = {
Version: '15.0.0',
Name: cfg.main.url.domain,
DestinationType: 'ImageUploader, FileUploader',
RequestMethod: 'POST',
RequestURL: `${cfg.main.url.full}/api/v2/upload`,
Headers: {
'X-Api-Key': row.api_key
},
Body: 'MultipartFormData',
FileFormName: 'file',
// The server requires a rating field. Without it every upload is rejected.
// Users can change this value in ShareX's custom uploader settings.
Parameters: {
rating: defaultRating
},
// {json:file_url} maps to the direct file URL in the success response JSON
URL: '{json:file_url}',
ThumbnailURL: '{json:file_url}',
ErrorMessage: '{response}'
};
const filename = `${cfg.main.url.domain}.sxcu`;
const body = JSON.stringify(sxcu, null, 2);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': Buffer.byteLength(body)
}).end(body);
} catch (e) {
console.error('[API KEY] ShareX config error:', e);
return res.status(500).reply({ body: 'Error generating config' });
}
});
// --- User Invite System ---
// Eligibility: ≥150 uploads, ≥30 days old, ≥66 comments, ≥200 tags
// Slots: configurable (default 2), refresh 30 days after a token is used.
const getInviteCriteria = () => {
const ic = cfg.websrv.invite_criteria || {};
return {
uploads: Number.isFinite(+ic.uploads) ? +ic.uploads : 150,
age_days: Number.isFinite(+ic.age_days) ? +ic.age_days : 30,
comments: Number.isFinite(+ic.comments) ? +ic.comments : 66,
tags: Number.isFinite(+ic.tags) ? +ic.tags : 200,
};
};
const getInviteSlots = () => {
const n = parseInt(cfg.websrv.user_invite_slots);
return Number.isFinite(n) && n > 0 ? n : 2;
};
// GET /api/v2/settings/invites
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
group.get(/\/invites$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const userId = +req.session.id;
const username = req.session.user;
const totalSlots = getInviteSlots();
const refreshDays = 30;
const isAdmin = !!req.session.admin;
// Always fetch this user's token history
const tokens = await db`
SELECT
it.id,
it.token,
it.is_used,
it.used_at,
it.created_at,
u.user AS used_by_name
FROM invite_tokens it
LEFT JOIN "user" u ON u.id = it.used_by
WHERE it.created_by = ${userId}
ORDER BY it.created_at DESC
`;
let eligible, criteria, slotsConsumed, slotsAvailable;
if (isAdmin) {
// Admins bypass all criteria and slot limits
eligible = true;
criteria = null;
slotsConsumed = 0;
slotsAvailable = Infinity;
} else {
// Gather eligibility stats in one query
const [stats] = await db`
SELECT
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
FROM "user" u
WHERE u.id = ${userId}
`;
const INVITE_CRITERIA = getInviteCriteria();
criteria = {
uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads },
age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days },
comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments },
tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags },
};
eligible = Object.values(criteria).every(c => c.met);
// Slots consumed = tokens used within the last 30 days
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
}
return res.json({
success: true,
is_admin: isAdmin,
eligible,
criteria,
tokens,
slots_total: isAdmin ? null : totalSlots,
slots_consumed: isAdmin ? null : slotsConsumed,
slots_available: isAdmin ? null : slotsAvailable,
refresh_days: refreshDays,
}, 200);
} catch (e) {
console.error('[INVITES] GET error:', e);
return res.json({ success: false, msg: 'Error fetching invite data' }, 500);
}
});
// POST /api/v2/settings/invites/create
// Generates a new invite token if eligible and slots remain.
group.post(/\/invites\/create$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const userId = +req.session.id;
const username = req.session.user;
const totalSlots = getInviteSlots();
const refreshDays = 30;
const isAdmin = !!req.session.admin;
if (!isAdmin) {
// Eligibility check
const [stats] = await db`
SELECT
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
FROM "user" u WHERE u.id = ${userId}
`;
const INVITE_CRITERIA = getInviteCriteria();
const eligible =
stats.upload_count >= INVITE_CRITERIA.uploads &&
stats.age_days >= INVITE_CRITERIA.age_days &&
stats.comment_count >= INVITE_CRITERIA.comments &&
stats.tag_count >= INVITE_CRITERIA.tags;
if (!eligible) {
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403);
}
// Check available slots (used within last 30 days)
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
const [{ slots_consumed }] = await db`
SELECT COUNT(*)::int AS slots_consumed
FROM invite_tokens
WHERE created_by = ${userId}
AND is_used = true
AND used_at > ${cutoff}
`;
if (slots_consumed >= totalSlots) {
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
}
}
// Generate token
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
await db`
INSERT INTO invite_tokens (token, created_at, created_by)
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${userId})
`;
console.log(`[INVITES] User ${username} (${userId}) generated invite token ${token}`);
return res.json({ success: true, token }, 200);
} catch (e) {
console.error('[INVITES] Create error:', e);
return res.json({ success: false, msg: 'Error creating invite token' }, 500);
}
});
// POST /api/v2/settings/invites/delete
// Deletes an unused invite token owned by the calling user.
group.post(/\/invites\/delete$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const { id } = req.post;
if (!id) return res.json({ success: false, msg: 'Missing token ID' }, 400);
const result = await db`
DELETE FROM invite_tokens
WHERE id = ${+id}
AND created_by = ${+req.session.id}
AND is_used = false
RETURNING id
`;
if (result.length === 0) {
return res.json({ success: false, msg: 'Token not found or already used' }, 404);
}
return res.json({ success: true }, 200);
} catch (e) {
console.error('[INVITES] Delete error:', e);
return res.json({ success: false, msg: 'Error deleting invite token' }, 500);
}
});
// ─── Passkey Management (registered users) ──────────────────────────────
/**
* GET /api/v2/settings/passkeys
* List the current user's registered passkeys.
*/
group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => {
try {
const rows = await db`
SELECT id, credential_id, name, aaguid, created_at, last_used
FROM passkey_credentials
WHERE user_id = ${req.session.id}
ORDER BY created_at DESC
`;
return res.json({ success: true, passkeys: rows });
} catch (err) {
console.error('[PASSKEYS] List error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/begin
* Generate WebAuthn registration options for a logged-in user.
*/
group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => {
try {
// Get existing credentials to exclude (prevent re-registering same authenticator)
const existing = await db`
SELECT credential_id FROM passkey_credentials
WHERE user_id = ${req.session.id}
`;
const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' }));
const challenge = generateChallenge({ type: 'user-register', userId: req.session.id });
// User handle: SHA256 of user_id encoded as base64url (stable, opaque)
const userHandle = base64url(
crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16)
);
const body = req.post || req.body || {};
const passkeyName = (body.name || '').trim().slice(0, 64) || null;
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: req.session.login || req.session.user,
displayName: req.session.display_name || req.session.user,
excludeCredentials,
rpId: getRpIdFromHost(req.headers.host)
});
// Stash the intended passkey name in challenge metadata
if (passkeyName) {
// Re-consume and re-store with name (challenges are stored by their value)
// Simpler: store name in a temporary Map keyed by challenge
options._passkeyName = passkeyName;
}
return res.json({ success: true, options, passkey_name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/finish
* Verify attestation and store new passkey for the logged-in user.
*/
group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, name } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-register' || challengeMeta.userId !== req.session.id) {
return res.json({ success: false, msg: 'Challenge mismatch' }, 400);
}
// Verify attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[PASSKEYS] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey';
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName}
`;
return res.json({ success: true, credential_id: credentialId, name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/delete
* Remove a passkey credential owned by the current user.
* Uses POST + JSON body to avoid URL-encoding issues with credential IDs.
*/
group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const credentialId = body.credential_id;
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* DELETE /api/v2/settings/passkeys/:id
* Legacy path — kept for compatibility.
*/
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
try {
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/begin
* Start a passkey login challenge for a registered user (no session required).
*/
group.post(/\/passkeys\/login\/begin$/, async (req, res) => {
try {
const challenge = generateChallenge({ type: 'user-login' });
const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) });
return res.json({ success: true, options });
} catch (err) {
console.error('[PASSKEYS] login/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/finish
* Verify assertion and create a full registered-user session.
*/
group.post(/\/passkeys\/login\/finish$/, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-login') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// 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
FROM passkey_credentials pc
JOIN "user" u ON u.id = pc.user_id
WHERE pc.credential_id = ${credentialId}
AND u.login IS NOT NULL
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401);
}
const row = credRows[0];
if (row.banned) {
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
}
// Verify the assertion
try {
await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki: row.public_key_spki,
storedSignCount: row.sign_count,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[PASSKEYS] login/finish verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Update sign count
await db`
UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Create a full user session (same as normal login)
const stamp = Math.floor(Date.now() / 1000);
const ip = security.storableIP(security.getRealIP(req));
const sessionToken = crypto.randomBytes(32).toString('hex');
const csrfToken = crypto.randomBytes(32).toString('hex');
const sessRecord = {
user_id: row.user_id,
session: lib.sha256(sessionToken),
csrf_token: csrfToken,
browser: req.headers['user-agent'] || '',
created_at: stamp,
last_used: stamp,
last_action: '/passkey-login',
kmsi: 1,
ip
};
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`;
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 });
} catch (err) {
console.error('[PASSKEYS] login/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
return group;
});
return router;
};