Stiglo je novo izdanje. Osvježi stranu.
Pravo na uvid u kod. Otvoren protokol omogućava nezavisne operatere i interoperabilnost. Manifest
Izvor
4d7883a894b3
- server.js
- lib/auth.js
- lib/build.js
- lib/cards.js
- lib/db.js
- lib/gradovi.js
- lib/icons.js
- lib/imena.js
- lib/jezik.js
- lib/kartapodaci.js
- lib/lekcije.js
- lib/md.js
- lib/mehanika.js
- lib/mreza.js
- lib/pages.js
- lib/registar.js
- lib/routes.js
- lib/schema2.js
- lib/views.js
- seed/content.mjs
- seed/seed.mjs
- public/app.js
- public/style.css
1 // YU Network Forum: zero-dependency Node 24 server (node:http + node:sqlite + crypto.argon2).
2 import http from 'node:http';
3 import fs from 'node:fs';
4 import path from 'node:path';
5 import crypto from 'node:crypto';
6 import { fileURLToPath } from 'node:url';
7 import { openDb, backup } from './lib/db.js';
8 import { loadSession, anonCsrf, safeEqual, token, Limiter, SESSION_COOKIE, ANON_COOKIE } from './lib/auth.js';
9 import { buildId } from './lib/build.js';
10 import { errorPage } from './lib/views.js';
11 import { routes } from './lib/routes.js';
12 import { seed } from './seed/seed.mjs';
13 import {migrate} from './lib/schema2.js';
14 import {registry} from './lib/registar.js';
15 import {mechanics,level} from './lib/mehanika.js';
16 import {jezik,t} from './lib/jezik.js';
17
18 const ROOT = path.dirname(fileURLToPath(import.meta.url));
19 const PUBLIC = path.join(ROOT, 'public');
20 const TYPES = { '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png',
21 '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.jpg': 'image/jpeg',
22 '.webp': 'image/webp', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
23 const ROOT_FILES = new Set(['favicon.ico', 'favicon.svg', 'apple-touch-icon.png', 'icon-192.png', 'icon-512.png', 'og.gif', 'og.png', 'twitter.png', 'site.webmanifest']);
24 const CSP = "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'self'; base-uri 'none'; object-src 'none'";
25
26 export function parseCookies(h) {
27 const out = {};
28 for (const part of String(h || '').split(';')) {
29 const i = part.indexOf('=');
30 if (i > 0) { const k = part.slice(0, i).trim(); if (!(k in out)) { try { out[k] = decodeURIComponent(part.slice(i + 1).trim()); } catch { /* ignore */ } } }
31 }
32 return out;
33 }
34
35 export function createApp({ dataDir, secure = true, trustProxy = true, origin = 'https://www.yugoslavia.forum', adminHash = process.env.ADMIN_PASSWORD_HASH || (process.env.ADMIN_PASSWORD_HASH_B64 ? Buffer.from(process.env.ADMIN_PASSWORD_HASH_B64, 'base64url').toString() : undefined), now } = {}) {
36 const db = openDb(dataDir);
37 const secretFile = path.join(dataDir, 'secret');
38 if (!fs.existsSync(secretFile)) fs.writeFileSync(secretFile, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
39 const secret = fs.readFileSync(secretFile, 'utf8').trim();
40 fs.mkdirSync(path.join(dataDir, 'avatars'), { recursive: true });
41 const reg=registry(db), mech=mechanics(db,reg);
42 migrate(db,()=>seed(db,{reg,mechanics:mech,adminHash,now:now??Date.now()}));
43 if(adminHash)db.run("UPDATE users SET pass_hash=? WHERE handle='signal' AND role='admin'",adminHash);
44 const build = buildId(ROOT);
45 const app = { db, reg, mechanics:mech, dataDir, secret, build, limiter: new Limiter(), secure, origin };
46 const table = routes(app);
47
48 const cookie = (name, value, { maxAge, httpOnly = true } = {}) =>
49 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax${httpOnly ? '; HttpOnly' : ''}${secure ? '; Secure' : ''}${maxAge !== undefined ? '; Max-Age=' + maxAge : ''}`;
50
51 function send(res, status, body, headers = {}) {
52 const h = { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin',
53 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()', 'X-Frame-Options': 'SAMEORIGIN',
54 'Cross-Origin-Opener-Policy': 'same-origin', ...headers };
55 if (secure) h['Strict-Transport-Security'] = 'max-age=31536000';
56 if (Array.isArray(h['Set-Cookie']) && !h['Set-Cookie'].length) delete h['Set-Cookie'];
57 if (!h['Content-Type']) h['Content-Type'] = 'text/html; charset=utf-8';
58 if (!h['Cache-Control']) h['Cache-Control'] = 'no-cache';
59 res.writeHead(status, h);
60 res.end(res.req.method === 'HEAD' ? undefined : body);
61 }
62
63 function serveFile(req, res, file, cache) {
64 let st;
65 try { st = fs.statSync(file); } catch { return false; }
66 if (!st.isFile()) return false;
67 const etag = `"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`;
68 if (req.headers['if-none-match'] === etag) { send(res, 304, undefined, { ETag: etag, 'Cache-Control': cache }); return true; }
69 send(res, 200, fs.readFileSync(file), { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': cache, ETag: etag });
70 return true;
71 }
72
73 function readBody(req, limit) {
74 return new Promise((resolve, reject) => {
75 const chunks = []; let n = 0;
76 req.on('data', (c) => { n += c.length; if (n > limit) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else chunks.push(c); });
77 req.on('end', () => resolve(Buffer.concat(chunks)));
78 req.on('error', reject);
79 });
80 }
81
82 async function handle(req, res) {
83 const url = new URL(req.url, 'http://local');
84 const p = url.pathname;
85 const method = req.method === 'HEAD' ? 'GET' : req.method;
86 // Static assets first: no session work.
87 if (method === 'GET') {
88 if (p.startsWith('/static/') && !p.includes('..')) {
89 const rel = decodeURIComponent(p.slice(8));
90 if (/^[\w./-]+$/.test(rel) && serveFile(req, res, path.join(PUBLIC, rel), url.search || rel.startsWith('fonts/') ? 'public, max-age=31536000, immutable' : 'public, max-age=3600')) return;
91 }
92 const root = p.slice(1);
93 if (ROOT_FILES.has(root) && serveFile(req, res, path.join(PUBLIC, root), 'public, max-age=86400')) return;
94 const av = /^\/avatars\/(\d+-[\w-]{8}\.(?:png|jpg|webp))$/.exec(p);
95 if (av && serveFile(req, res, path.join(dataDir, 'avatars', av[1]), 'public, max-age=604800, immutable')) return;
96 }
97
98 const cookies = parseCookies(req.headers.cookie);
99 const setCookies = [];
100 const ip = (trustProxy && (req.headers['x-real-ip'] || String(req.headers['x-forwarded-for'] || '').split(',').pop().trim())) || req.socket.remoteAddress || '?';
101 let anon = cookies[ANON_COOKIE];
102 if (!anon || !/^[\w-]{16,64}$/.test(anon)) { anon = token(18); setCookies.push(cookie(ANON_COOKIE, anon, { maxAge: 86400 * 365 })); }
103 const sess = loadSession(db, cookies[SESSION_COOKIE]);
104 const user = sess ? { ...sess } : null;
105 if (user) {
106 const t = Date.now();
107 if (t - user.last_visit_at > 30 * 60000) {
108 db.run('UPDATE users SET prev_visit_at = last_visit_at, last_visit_at = ? WHERE id = ?', t, user.id);
109 user.prev_visit_at = user.last_visit_at;
110 } else if (t - user.last_visit_at > 60000) db.run('UPDATE users SET last_visit_at = ? WHERE id = ?', t, user.id);
111 }
112 let flash = null;
113 if (cookies.yu_flash) {
114 try { flash = JSON.parse(Buffer.from(cookies.yu_flash, 'base64url').toString()); } catch { /* ignore */ }
115 setCookies.push(cookie('yu_flash', '', { maxAge: 0 }));
116 }
117 const ctx = {
118 req, res, url, cookies, pismo: cookies.pismo==='cir'?'cir':cookies.pismo==='lat'?'lat':user?.pismo||'lat', path: p, query: url.searchParams, ip, user, flash, build, q: '',
119 csrf: user ? user.csrf : anonCsrf(secret, anon), anon,
120 notif: user ? db.get('SELECT COUNT(*) n FROM notifications WHERE user_id = ? AND read = 0', user.id).n : 0,
121 cookie, setCookies, form: null, body: null,
122 flashNext(kind, msg) { setCookies.push(cookie('yu_flash', Buffer.from(JSON.stringify([kind, msg])).toString('base64url'), { maxAge: 60 })); },
123 html(status, body, headers = {}) { send(res, status, body, { 'Set-Cookie': setCookies, ...headers }); },
124 json(status, obj, headers = {}) { send(res, status, JSON.stringify(obj), { 'Content-Type': 'application/json', 'Set-Cookie': setCookies, ...headers }); },
125 text(status, body, type = 'text/plain; charset=utf-8', headers = {}) { send(res, status, body, { 'Content-Type': type, 'Set-Cookie': setCookies, ...headers }); },
126 redirect(to, status = 303) { send(res, status, '', { Location: to, 'Set-Cookie': setCookies }); },
127 error(status, msg) { send(res, status, errorPage(ctx, status, msg), { 'Set-Cookie': setCookies }); },
128 };
129
130 for (const r of table) {
131 if (r.method !== method) continue;
132 const m = r.re.exec(p);
133 if (!m) continue;
134 if (method === 'POST') {
135 const o = req.headers.origin;
136 if (o && o !== origin && (secure || o !== `http://${req.headers.host}`)) return ctx.error(403, t('Zahtjev sa druge stranice nije dozvoljen.'));
137 if (r.raw) {
138 ctx.body = await readBody(req, r.raw);
139 if (!safeEqual(req.headers['x-csrf-token'], ctx.csrf)) return ctx.json(403, { error: 'csrf' });
140 } else {
141 const buf = await readBody(req, 128 * 1024);
142 ctx.form = new URLSearchParams(buf.toString('utf8'));
143 if (!safeEqual(ctx.form.get('csrf'), ctx.csrf)) return ctx.error(403, t('Obrazac je istekao. Osvježite stranu i pokušajte ponovo.'));
144 }
145 }
146 if (r.auth && !user) return method === 'GET' ? ctx.redirect('/prijava?next=' + encodeURIComponent(p + url.search)) : ctx.error(403, t('Prvo se prijavite.'));
147 if (r.admin && user?.role !== 'admin') return ctx.error(403, t('Samo operater.'));
148 return jezik.run(ctx.pismo,()=>r.fn(ctx, ...m.slice(1).map((s) => (s === undefined ? s : decodeURIComponent(s)))));
149 }
150 if (table.some((r) => r.re.test(p))) return ctx.error(405, t('Metoda nije dozvoljena.'));
151 return ctx.error(404, t('Strana nije pronađena.'));
152 }
153
154 const server = http.createServer((req, res) => {
155 const t0 = Date.now();
156 res.on('finish', () => { if (!req.url.startsWith('/static/')) console.log(`${req.method} ${req.url.split('?')[0].slice(0, 80)} ${res.statusCode} ${Date.now() - t0}ms`); });
157 handle(req, res).catch((e) => {
158 if (e.status === 413) { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'text/plain' }); res.end(t('Datoteka je prevelika.')); } return; }
159 console.error(e);
160 if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(t('Greška pri obradi.')); }
161 });
162 });
163 server.headersTimeout = 20000;
164 server.requestTimeout = 30000;
165 server.keepAliveTimeout = 65000;
166 app.server = server;
167 return app;
168 }
169
170 if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
171 const dataDir = process.env.DATA_DIR || process.env.DATA || path.resolve('.local/data');
172 const app = createApp({ dataDir, secure: process.env.INSECURE_COOKIES !== '1' });
173 const port = +process.env.PORT || 3000;
174 app.server.listen(port, () => console.log(`yugoslavia.forum build ${app.build} listening on ${port}`));
175 const daily = () => { try { const b = backup(app.db, dataDir); if (b) console.log('backup', b); } catch (e) { console.error('backup failed', e.message); } };
176 setTimeout(daily, 60000);
177 setInterval(daily, 3600000).unref();
178 const stop = () => { app.server.close(); app.db.close(); process.exit(0); };
179 process.on('SIGTERM', stop);
180 process.on('SIGINT', stop);
181 }
182