import path from 'node:path'; import { loadEnv, sendJson, readJsonBody, clientIp, matchRoute, serveStatic, listen, createRateLimiter, dirFromMeta, } from '../lib/node-server.js'; const __dirname = dirFromMeta(import.meta.url); loadEnv(path.join(__dirname, '.env')); const PORT = Number(process.env.REGISTER_UI_PORT || 3001); const HOST = process.env.REGISTER_UI_HOST || '0.0.0.0'; const USE_HTTP2 = process.env.HTTP2 === 'true'; const NEXTCLOUD_URL = (process.env.NEXTCLOUD_URL || 'http://127.0.0.1:8080').replace(/\/$/, ''); const ADMIN_USER = process.env.REGISTRATION_ADMIN_USER || ''; const ADMIN_PASS = process.env.REGISTRATION_ADMIN_PASSWORD || ''; const DEFAULT_QUOTA = process.env.DEFAULT_QUOTA || '5GB'; const DEFAULT_GROUP = process.env.DEFAULT_GROUP || 'users'; const ENABLED = process.env.REGISTRATION_ENABLED !== 'false'; const PUBLIC_DIR = path.join(__dirname, 'public'); const ALLOWED_DOMAINS = (process.env.ALLOWED_EMAIL_DOMAINS || '') .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); const TLS = process.env.SSL_KEY && process.env.SSL_CERT ? { key: process.env.SSL_KEY, cert: process.env.SSL_CERT } : null; const rateLimit = createRateLimiter({ windowMs: 3600_000, max: Number(process.env.RATE_LIMIT_PER_HOUR || 10), }); function authHeader() { return 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASS}`).toString('base64'); } async function ocs(method, ocsPath, body) { const headers = { 'OCS-APIRequest': 'true', Accept: 'application/json', Authorization: authHeader(), }; const init = { method, headers }; if (body) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; init.body = new URLSearchParams(body).toString(); } const res = await fetch(`${NEXTCLOUD_URL}${ocsPath}`, init); const data = await res.json().catch(() => ({})); const meta = data?.ocs?.meta; if (!meta || meta.status !== 'ok') throw new Error(meta?.message || 'Ошибка Nextcloud'); return data.ocs.data; } function validateUsername(id) { return /^[a-zA-Z0-9._@-]{3,64}$/.test(id); } function validatePassword(p) { return typeof p === 'string' && p.length >= 8; } function validateEmail(email) { if (!email) return true; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false; if (ALLOWED_DOMAINS.length) { return ALLOWED_DOMAINS.includes(email.split('@')[1]?.toLowerCase()); } return true; } const routes = [ { method: 'GET', path: '/api/config', handler: async (_req, res) => { sendJson(res, 200, { enabled: ENABLED && Boolean(ADMIN_USER && ADMIN_PASS), nextcloudUrl: NEXTCLOUD_URL, defaultQuota: DEFAULT_QUOTA, allowedEmailDomains: ALLOWED_DOMAINS, }); }, }, { method: 'POST', path: '/api/register', handler: async (req, res) => { if (!rateLimit(clientIp(req))) { return sendJson(res, 429, { error: 'Слишком много попыток. Попробуйте позже.' }); } if (!ENABLED) return sendJson(res, 403, { error: 'Регистрация временно отключена' }); if (!ADMIN_USER || !ADMIN_PASS) { return sendJson(res, 503, { error: 'Сервер не настроен (REGISTRATION_ADMIN_* в .env)' }); } const { username, password, email, displayName } = await readJsonBody(req); if (!validateUsername(username)) { return sendJson(res, 400, { error: 'Логин: 3–64 символа, латиница, цифры, . _ @ -' }); } if (!validatePassword(password)) { return sendJson(res, 400, { error: 'Пароль минимум 8 символов' }); } if (!validateEmail(email || '')) { return sendJson(res, 400, { error: ALLOWED_DOMAINS.length ? `Разрешены только email: ${ALLOWED_DOMAINS.join(', ')}` : 'Некорректный email', }); } try { await ocs('POST', '/ocs/v1.php/cloud/users', { userid: username, password }); if (DEFAULT_QUOTA) { await ocs('PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}`, { key: 'quota', value: DEFAULT_QUOTA, }); } if (email) { await ocs('PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}`, { key: 'email', value: email, }); } if (displayName) { await ocs('PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}`, { key: 'displayname', value: displayName, }); } if (DEFAULT_GROUP) { await ocs('POST', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}/groups`, { groupid: DEFAULT_GROUP, }); } sendJson(res, 200, { ok: true, username, loginUrl: NEXTCLOUD_URL }); } catch (e) { const msg = e.message || 'Ошибка регистрации'; if (/already exists|102/i.test(msg)) { return sendJson(res, 409, { error: 'Пользователь с таким логином уже существует' }); } sendJson(res, 400, { error: msg }); } }, }, ]; async function handleRequest(req, res) { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const matched = matchRoute(req.method, url.pathname, routes); if (matched) { return matched.handler(req, res, matched.params); } const file = await serveStatic(PUBLIC_DIR, url.pathname); if (file) { res.writeHead(200, { 'Content-Type': file.type, 'Content-Length': file.body.length }); res.end(file.body); return; } sendJson(res, 404, { error: 'Not found' }); } listen(handleRequest, { host: HOST, port: PORT, http2: USE_HTTP2, tls: TLS, }); console.log(`Nextcloud → ${NEXTCLOUD_URL}`); console.log(`Registration → ${ENABLED ? 'enabled' : 'disabled'}`);