1477 lines
58 KiB
JavaScript
1477 lines
58 KiB
JavaScript
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, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
||
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;
|
||
};
|