srijeda, 11. novembar 2026. · Godina I · Broj 15 · Cijena: 0 dinara
JUGOSLOVENSKI FORUM

Glasilo YU mreže · NEMA GRANICA ZA ZNANJE

Prijava Upis
Pravo na uvid u kod. Otvoren protokol omogućava nezavisne operatere i interoperabilnost. Manifest

Izvor

4d7883a894b3

   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