194 lines
8.9 KiB
JavaScript
194 lines
8.9 KiB
JavaScript
import db from "../sql.mjs";
|
|
import lib from "../lib.mjs";
|
|
import security from "../security.mjs";
|
|
import { getRegistrationOpen, getDefaultLayout } from "../settings.mjs";
|
|
import { sendMail } from "../../lib/smtp.mjs";
|
|
import cfg from "../config.mjs";
|
|
import crypto from "crypto";
|
|
|
|
export default (router, tpl) => {
|
|
router.get(/^\/register(\/)?$/, async (req, res) => {
|
|
if (req.session) {
|
|
return res.writeHead(302, { "Location": "/?already_logged_in=1" }).end();
|
|
}
|
|
let url = "/#register";
|
|
if (req.url.qs?.token) url += `:${req.url.qs.token}`;
|
|
return res.writeHead(302, { "Location": url }).end();
|
|
});
|
|
|
|
router.get(/^\/activate\/(?<token>.+)$/, async (req, res) => {
|
|
const token = req.params.token;
|
|
const user = await db`SELECT id FROM "user" WHERE activation_token = ${token}`;
|
|
|
|
if (user.length === 0) {
|
|
const errorMsg = encodeURIComponent("Invalid or expired activation token.");
|
|
return res.writeHead(302, { "Location": `/#register:error:${errorMsg}` }).end();
|
|
}
|
|
|
|
await db`UPDATE "user" SET activated = TRUE, activation_token = NULL WHERE id = ${user[0].id}`;
|
|
|
|
const successMsg = encodeURIComponent("Account activated! You can now login.");
|
|
return res.writeHead(302, { "Location": `/#register:success:${successMsg}` }).end();
|
|
});
|
|
|
|
router.post(/^\/register(\/)?$/, async (req, res) => {
|
|
let { username, email, password, password_confirm, token, email_confirm_field } = req.post;
|
|
if (username) username = username.trim();
|
|
const ip = security.getRealIP(req);
|
|
|
|
// Honeypot check
|
|
if (email_confirm_field) {
|
|
console.log(`[SPAM] Honeypot triggered by IP: ${ip}, User: ${username}`);
|
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Spam detected." }));
|
|
}
|
|
|
|
if (await security.isRateLimited(ip, null, 'register')) {
|
|
const errorMsg = "Too many attempts.";
|
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
|
|
}
|
|
return res.reply({
|
|
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
|
|
});
|
|
}
|
|
|
|
const renderError = async (msg) => {
|
|
await security.recordAttempt(ip, username, 'register', false);
|
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
|
}
|
|
return res.reply({
|
|
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
|
|
});
|
|
};
|
|
|
|
const renderSuccess = async (msg) => {
|
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
|
}
|
|
return res.reply({
|
|
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
|
|
});
|
|
};
|
|
|
|
// Input Validation
|
|
if (!username || username.trim().length === 0) {
|
|
return renderError("Username is required.");
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
|
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
|
|
}
|
|
|
|
if (!password || password.length < 20) {
|
|
return renderError("Password must be at least 20 characters long.");
|
|
}
|
|
|
|
if (password !== password_confirm) {
|
|
return renderError("Passwords do not match.");
|
|
}
|
|
|
|
// Registration Logic
|
|
let activated = true;
|
|
let activationToken = null;
|
|
|
|
if (!token && !getRegistrationOpen()) {
|
|
return renderError("Invite token is required for registration.");
|
|
}
|
|
|
|
if (token) {
|
|
const tokenRow = await db`
|
|
select * from invite_tokens where token = ${token} and is_used = false
|
|
`;
|
|
if (tokenRow.length === 0) return renderError("Invalid or used invite token");
|
|
// Token used, so it will be activated by default
|
|
} else {
|
|
// No token, Open Registration
|
|
if (!email || !email.includes('@')) return renderError("A valid email is required for no-token registration.");
|
|
activated = false;
|
|
activationToken = crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
// Check user existence
|
|
const existing = await db`select id from "user" where "login" = ${username.toLowerCase()} or "user" = ${username}`;
|
|
if (existing.length > 0) return renderError("Username taken");
|
|
|
|
// Create User
|
|
const hash = await lib.hash(password);
|
|
const ts = ~~(Date.now() / 1e3);
|
|
|
|
let userId;
|
|
try {
|
|
const newUser = await db`
|
|
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
|
|
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
|
|
returning id
|
|
`;
|
|
userId = newUser[0].id;
|
|
|
|
// Assign default avatar file
|
|
const avatarId = null;
|
|
const avatarFile = 'default.png';
|
|
|
|
await db`
|
|
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
|
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
|
`;
|
|
} catch (err) {
|
|
console.error(`[REGISTER] DB Error during user creation:`, err);
|
|
if (err.code === '23505') { // Unique constraint violation
|
|
return renderError("Username taken");
|
|
}
|
|
return renderError("An unexpected error occurred. Please try again later.");
|
|
}
|
|
|
|
// If not activated, send email and return success message
|
|
if (!activated) {
|
|
const activationLink = `${cfg.main.url.full}/activate/${activationToken}`;
|
|
const mailBody = `Hello ${username},\n\nThank you for registering. Please activate your account by clicking the link below:\n\n${activationLink}\n\nIf you did not request this, please ignore this email.`;
|
|
|
|
try {
|
|
if (cfg.smtp && cfg.smtp.host) {
|
|
await sendMail(cfg.smtp, {
|
|
to: email,
|
|
subject: "Activate your account",
|
|
body: mailBody
|
|
});
|
|
} else {
|
|
console.log(`[SMTP] No configuration found. Activation link: ${activationLink}`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[SMTP] Send failed:`, e.message);
|
|
// We'll still proceed since the user is created, but they'll be stuck.
|
|
// In production they should see an error, but let's keep it simple for now.
|
|
}
|
|
|
|
await renderSuccess("Registration successful! Please check your email to activate your account.");
|
|
return;
|
|
}
|
|
|
|
if (token) {
|
|
const tokenRow = await db`select id from invite_tokens where token = ${token} and is_used = false`;
|
|
if (tokenRow.length > 0) {
|
|
await db`
|
|
update invite_tokens
|
|
set is_used = true, used_by = ${userId}
|
|
where id = ${tokenRow[0].id}
|
|
`;
|
|
}
|
|
}
|
|
|
|
await security.recordAttempt(ip, username, 'register', true);
|
|
|
|
const successMsg = "Registration successful! You can now login.";
|
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg }));
|
|
}
|
|
|
|
// Redirect to home with login success message
|
|
return res.writeHead(302, { "Location": "/?login=success" }).end();
|
|
});
|
|
|
|
return router;
|
|
};
|