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 import fs from 'node:fs';
2 import path from 'node:path';
3 import {esc,render} from './md.js';
4 import {t,datum,tekstHTML} from './jezik.js';
5 import {layout,csrfField,userLink,time,SITE} from './views.js';
6 import {hashPassword,verifyPassword,createSession,destroySession,token,sha,HANDLE_RE,RESERVED,SESSION_COOKIE} from './auth.js';
7 import {dinar,indexPost} from './db.js';
8 import {EDITIONS,EDITION,collectible,memberCard,avatar,memberNo} from './cards.js';
9 import {GRADOVI,GRAD,JEDINICE} from './gradovi.js';
10 import {normalName,validOrigin,SISTEMSKA,ZASTICENA} from './imena.js';
11 import {verify,record,canonical,verifyChain} from './registar.js';
12 import {level,DAY} from './mehanika.js';
13 import {network,mapSvg,teleMap,metrics} from './mreza.js';
14 import {LESSONS} from './lekcije.js';
15 import {manifest,planTables,economyTable} from './pages.js';
16 export const PUBLIC_ROUTES=['/','/prijava','/upis','/dobrodosli','/pretraga','/clanovi','/novo','/manifest','/pravila','/privatnost','/dinar','/slicice','/album','/karta','/registar','/connect','/stranice','/imenik','/pijaca','/uprava','/izbori','/zalbe','/dnevnik','/blagajna','/operater','/sigurnost','/protokol','/izvor','/plan','/mjerila','/akademija','/akcije','/teletekst','/en','/nivoi','/povelja','/federacija','/povratak','/provjera','/registar/pravila','/registar/prednost','/registar/osnivac','/registar/kljucevi','/registar/dnevnik','/uprava/odobrenja','/uprava/prijedlozi','/operater/postupci','/slicice/zamjena'];
17 export const OLD_ROUTES={login:'prijava',register:'upis',logout:'odjava',welcome:'dobrodosli',settings:'podesavanja',notifications:'obavjestenja',search:'pretraga',members:'clanovi',new:'novo',manifesto:'manifest',rules:'pravila',privacy:'privatnost',cards:'slicice','sličice':'slicice'};
18 const clean=(s,n=10000)=>String(s||'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g,'').trim().slice(0,n);
19 const safeNext=s=>typeof s==='string'&&/^\/(?!\/)[\w/?.=&%#-]*$/.test(s)?s:'/';
20 const link=(h,l)=>`<a href="${esc(h)}">${esc(t(l))}</a>`;
21 const inp=(name,label,type='text',value='',extra='')=>`<label>${t(label)}<input name="${name}" type="${type}" value="${esc(value)}" ${extra}></label>`;
22 const area=(name,label,value='')=>`<label>${t(label)}<textarea name="${name}" maxlength="20000">${esc(value)}</textarea></label>`;
23 const select=(name,label,options,value='')=>`<label>${t(label)}<select name="${name}">${options.map(([v,l])=>`<option value="${esc(v)}"${v===value?' selected':''}>${esc(t(l))}</option>`).join('')}</select></label>`;
24 const citySelect=value=>select('city_id','Grad',[['','Ne želim navesti'],...GRADOVI.map(g=>[g.id,g.ime])],value);
25 const form=(c,url,fields,button='Sačuvaj')=>`<form method="post" action="${url}">${csrfField(c)}${fields}<button>${t(button)}</button></form>`;
26 const quote=(key,sim=false)=>`<blockquote class="quote">${t('quote_'+key)} ${link('/manifest#'+key,'Manifest')}${sim?' <span class="tag" translate="no">SIMULACIJA</span>':''}</blockquote>`;
27 const table=(heads,rows,cls='')=>`<table class="${cls}"><thead><tr>${heads.map(h=>`<th scope="col">${t(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row=>`<tr>${row.map(v=>`<td>${v??''}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
28 export function routes(app){
29 const {db,reg,mechanics:m,limiter}=app,R=[];
30 const get=(re,fn,o={})=>R.push({method:'GET',re,fn,...o});
31 const post=(re,fn,o={})=>R.push({method:'POST',re,fn,auth:true,...o});
32 const page=(c,title,body,q='prava',sim=false)=>{c.ticker=db.all('SELECT id,title FROM threads WHERE deleted=0 ORDER BY last_post_at DESC LIMIT 5').map(r=>`<a href="/t/${r.id}">${esc(r.title)}</a>`).join(' · ');c.html(200,layout(c,{title:t(title),body:quote(q,sim)+`<h1>${esc(t(title))}</h1>`+body,spy:['/manifest','/pravila','/dinar','/protokol','/plan'].includes(c.path)}));};
33 const action=(c,fn,redirect)=>{try{const result=fn();if(redirect)c.redirect(typeof redirect==='function'?redirect(result):redirect);return result;}catch(e){c.error(422,t(e.code?.startsWith('ERR_SQLITE')?'Stanje je promijenjeno. Osvježite stranu i pokušajte ponovo.':e.message));return undefined;}};
34 const member=id=>{const u=db.get('SELECT * FROM users WHERE id=? AND deleted=0',id);if(u)u.nivo=level(db,u);return u;};
35 const notify=(uid,kind,actor,thread=null,postid=null)=>{if(uid&&uid!==actor)db.run('INSERT INTO notifications(user_id,kind,actor_id,thread_id,post_id,created_at) VALUES(?,?,?,?,?,?)',uid,kind,actor,thread,postid,Date.now());};
36 const earn=(uid,kind,ref)=>{const got=db.get("SELECT COALESCE(SUM(delta),0) n FROM ledger WHERE user_id=? AND reason IN('tema','odgovor') AND created_at>=?",uid,Math.floor(Date.now()/DAY)*DAY).n;const amount=Math.min(kind==='tema'?15:5,150-got);if(amount>0)dinar(db,uid,amount,kind,String(ref));};
37 const discussionList=rows=>table(['Tema','Poruka','Posljednja poruka'],rows.map(r=>[`<a href="/t/${r.id}">${esc(r.title)}</a>${r.pinned?' · '+t('zakačeno'):''}`,r.reply_count,time(r.last_post_at)]));
38 let metricsCache=null,metricsAt=0;const stats=()=>{if(!metricsCache||Date.now()-metricsAt>30000){metricsCache=metrics(db);metricsAt=Date.now();}return metricsCache;};
39 let networkCache=null,networkAt=0;const net=()=>{if(!networkCache||Date.now()-networkAt>30000){networkCache=network(db);networkAt=Date.now();}return networkCache;};
40 const steps=c=>`<div class="steps" data-onboarding>${[['/dobrodosli','Otkrij'],['/protokol#provjera','Provjeri'],['/connect','Instaliraj'],['/connect?ime=hello.yu','Pogledaj'],[c.user?'/dobrodosli':'/upis','Pristupi'],['/registar','Stvori']].map(([h,l],i)=>`<a href="${h}" data-step="${i}">${i+1}. ${t(l)}${i===2?' <small class="tag" translate="no">SIMULACIJA</small>':''}</a>`).join('')}</div><p data-countdown></p>`;
41 for(const [old,next]of Object.entries(OLD_ROUTES))get(new RegExp('^/'+encodeURI(old)+'$'),c=>c.redirect('/'+next+c.url.search,301));
42 get(/^\/pismo\/(lat|cir)$/, (c,pismo)=>{c.setCookies.push(c.cookie('pismo',pismo,{maxAge:31536000,httpOnly:false}));if(c.user)db.run('UPDATE users SET pismo=? WHERE id=?',pismo,c.user.id);let to='/';try{const u=new URL(c.req.headers.referer);if(u.origin===app.origin||u.origin===`${app.secure?'https':'http'}://${c.req.headers.host}`)to=u.pathname+u.search;}catch{}c.redirect(to);});
43 get(/^\/$/,c=>{const s=stats(),cats=db.all('SELECT * FROM categories ORDER BY position');const rows=cats.map(cat=>{const n=db.get('SELECT COUNT(*) n FROM threads WHERE category_id=? AND deleted=0',cat.id).n,p=db.get('SELECT COUNT(*) n FROM posts p JOIN threads t ON t.id=p.thread_id WHERE t.category_id=? AND t.deleted=0 AND p.deleted=0',cat.id).n,last=db.get('SELECT id,title FROM threads WHERE category_id=? AND deleted=0 ORDER BY last_post_at DESC LIMIT 1',cat.id);return [`<a href="/c/${cat.slug}"><svg width="16" height="16" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect x="1" y="4" width="14" height="11" fill="#003893"/><rect x="1" y="2" width="6" height="3" fill="#003893"/><rect x="11" y="1" width="4" height="4" fill="#f5c400"/></svg> ${t(cat.name)}</a>`,n,p,last?`<a href="/t/${last.id}">${esc(last.title)}</a>`:''];});page(c,'Naslovna',`${!c.user?steps(c):''}<div class="lead"><p>${t('opis')}</p><p>${t('quote_prava')} ${link('/manifest','Pročitaj manifest')}</p></div><div class="doors">${[['/stranice','Objavi',s.stranice],['/pijaca','Prodaj',s.oglasi],['/protokol','Gradi',s.imena],['/karta','Organizuj',s.cvorovi],['/upis','Pripadaj',s.clanovi]].map(([h,l,n])=>`<a href="${h}">${t(l)} · ${n}</a>`).join('')}</div><div class="cols"><section id="rubrike"><h2>${t('Rubrike')}</h2>${table(['Rubrika','Tema','Poruka','Posljednja poruka'],rows,'forum')}</section><aside><section class="panel"><h2>${t('Ko je na mreži')}</h2>${db.all('SELECT * FROM users WHERE deleted=0 AND last_visit_at>? LIMIT 12',Date.now()-900000).map(userLink).join(' · ')||t('Mirno je.')}</section><section class="panel"><h2>${t('Statistika')}</h2>${s.clanovi} ${t('članova')} · ${s.teme} ${t('tema')} · ${s.poruke} ${t('poruka')}</section><section class="panel"><h2>${t('Čvorovi')}</h2><a href="/karta">${mapSvg(net(),true)}</a></section><section class="panel"><h2>${t('Stepenice: mreža, pijaca, novac')}</h2>${s.imena} ${t('imena')} · ${s.narudzbe} ${t('narudžbi')}<p>${t('no_money')}</p>${link('/dinar','DINAR')}</section><section class="panel"><h2>${t('Javne rasprave u toku')}</h2>${link('/uprava/prijedlozi','Osam otvorenih pitanja')}</section><section class="panel"><h2>${t('Riječ sedmice')}</h2><p>${esc(db.get("SELECT p.body FROM posts p JOIN threads t ON t.id=p.thread_id JOIN categories cat ON cat.id=t.category_id WHERE cat.slug='kultura' AND t.pinned=1 AND p.deleted=0 AND t.deleted=0 ORDER BY t.last_post_at DESC,p.created_at LIMIT 1")?.body.slice(0,240)||t('Fjaka, milina, lušten, убаво.'))}</p>${link('/c/kultura','Kultura')}</section></aside></div>`,'mreza');});
44 get(/^\/(prijava|upis)$/, (c,which)=>page(c,which==='upis'?'Upis':'Prijava',form(c,'/'+which,inp('handle','Korisničko ime','text','','required autocomplete="username" minlength="3" maxlength="20"')+(which==='upis'?inp('display','Ime za prikaz','text','','required maxlength="60"'):'')+inp('password','Lozinka','password','','required minlength="12" maxlength="200" autocomplete="'+(which==='upis'?'new-password':'current-password')+'"')+(which==='upis'?inp('password2','Ponovi lozinku','password','','required')+`<div class="honey" aria-hidden="true">${inp('website','Ostavite prazno','text','','tabindex="-1" autocomplete="off"')}</div>`+`<label><input type="checkbox" name="rules" value="1" required> ${t('Prihvatam Kućni red')}</label><input type="hidden" name="invite" value="${esc(c.query.get('pozivnica')||'')}">`:`<input type="hidden" name="next" value="${esc(safeNext(c.query.get('next')))}">`),which==='upis'?'Upis':'Prijava')));
45 post(/^\/upis$/,c=>{m.expireReturns();const f=c.form,h=clean(f.get('handle'),20).toLowerCase(),pw=f.get('password')||'';if(f.get('website'))return c.error(400,t('Obrazac nije prihvaćen.'));if(limiter.hit('upis:'+c.ip,10,3600000))return c.error(429,t('Previše pokušaja. Pokušajte kasnije.'));if(!HANDLE_RE.test(h)||RESERVED.has(h)||['blagajna','pijaca','registar','operater'].includes(h)||db.get('SELECT 1 FROM users WHERE handle=?',h)||pw.length<12||pw.length>200||pw!==f.get('password2')||f.get('rules')!=='1')return c.error(422,t('Provjerite korisničko ime, lozinku i Kućni red.'));action(c,()=>{const id=db.tx(()=>{const num=db.get('SELECT COALESCE(MAX(member_no),0)+1 n FROM users').n;const id=Number(db.run('INSERT INTO users(handle,display,pass_hash,member_no,created_at) VALUES(?,?,?,?,?)',h,clean(f.get('display'),60)||h,hashPassword(pw),num,Date.now()).lastInsertRowid);dinar(db,id,250,'upis');reg.create(id,h,{trust:'provjereno'});db.run('INSERT INTO invites VALUES(?,?,?,0)',token(9),id,Date.now());if(num<=100)m.mint(id,'osnivacka','nagrada');const invite=db.get('SELECT * FROM invites WHERE code=?',f.get('invite')||'');if(invite){db.run('UPDATE users SET invited_by=? WHERE id=?',invite.user_id,id);if(invite.uses<5)m.reward(invite.user_id,20,'pozivnica',id);db.run('UPDATE invites SET uses=uses+1 WHERE code=?',invite.code);}return id;});const session=createSession(db,id);c.setCookies.push(c.cookie(SESSION_COOKIE,session.raw,{maxAge:session.maxAge}));return id;},'/dobrodosli');},{auth:false});
46 post(/^\/prijava$/,async c=>{const h=clean(c.form.get('handle'),20).toLowerCase();if(limiter.hit('login:'+h,6,900000)||limiter.hit('loginip:'+c.ip,40,900000))return c.error(429,t('Previše pokušaja. Pokušajte kasnije.'));const u=db.get('SELECT * FROM users WHERE handle=? AND deleted=0 AND banned=0',h);if(!await verifyPassword(c.form.get('password')||'',u&&!u.is_seed?u.pass_hash:'!'))return c.error(422,t('Korisničko ime ili lozinka nisu ispravni.'));const s=createSession(db,u.id);c.setCookies.push(c.cookie(SESSION_COOKIE,s.raw,{maxAge:s.maxAge}));c.redirect(safeNext(c.form.get('next')));},{auth:false});
47 get(/^\/odjava$/,c=>page(c,'Odjava',form(c,'/odjava','','Odjava')),{auth:true});post(/^\/odjava$/,c=>{destroySession(db,c.cookies[SESSION_COOKIE]);c.setCookies.push(c.cookie(SESSION_COOKIE,'',{maxAge:0}));c.redirect('/');});
48 get(/^\/dobrodosli$/,c=>page(c,'Dobro došli',`<p>${t('welcomeProza')}</p>${steps(c)}${c.user?`<p>${c.user.dinar} ${t('DINAR · probni bodovi')}</p><div class="vitrina">${memberCard({...c.user,nivo:level(db,c.user)},{build:c.build})}</div>${form(c,'/podesavanja/grad',citySelect(c.user.city_id),'Sačuvaj')}`:''}`));
49 post(/^\/dobrodosli\/vrijeme$/,c=>{const seconds=Number(c.form.get('seconds'));if(!Number.isInteger(seconds)||seconds<0||seconds>3600)return c.error(422,t('Neispravno vrijeme.'));if(!db.get("SELECT 1 FROM activities WHERE user_id=? AND kind='uvod'",c.user.id)){m.count('uvod_sekunde',seconds);m.count('uvod_zavrseno');m.activity(c.user.id,'uvod',seconds);}c.json(200,{ok:true});});
50 get(/^\/c\/([\w-]+)$/, (c,slug)=>{const cat=db.get('SELECT * FROM categories WHERE slug=?',slug);if(!cat)return c.error(404);page(c,cat.name,link('/c/'+slug+'/novo','Nova tema')+discussionList(db.all('SELECT * FROM threads WHERE category_id=? AND deleted=0 ORDER BY pinned DESC,last_post_at DESC',cat.id).filter(row=>!c.query.get('grad')||db.get('SELECT city_id FROM users WHERE id=?',row.user_id)?.city_id===c.query.get('grad'))),'mreza');});
51 get(/^\/c\/([\w-]+)\/novo$/, (c,slug)=>{const cat=db.get('SELECT * FROM categories WHERE slug=?',slug);if(!cat)return c.error(404);page(c,'Nova tema',form(c,c.path,inp('title','Naslov','text','','required maxlength="180"')+select('lang','Jezik',[['sh','srpskohrvatski / hrvatskosrpski'],['sl','slovenski'],['mk','makedonski'],['sq','shqip'],['hu','magyar']])+area('body','Poruka'),'Objavi'));},{auth:true});
52 post(/^\/c\/([\w-]+)\/novo$/, (c,slug)=>{const cat=db.get('SELECT * FROM categories WHERE slug=?',slug),title=clean(c.form.get('title'),180),body=clean(c.form.get('body'),20000);if(!cat||cat.admin_only&&c.user.role!=='admin')return c.error(403,t('Samo operater.'));if(title.length<5||body.length<5)return c.error(422,t('Naslov i poruka moraju imati najmanje pet znakova.'));action(c,()=>db.tx(()=>{const lang=['sh','sl','mk','sq','hu'].includes(c.form.get('lang'))?c.form.get('lang'):'sh',now=Date.now(),tid=Number(db.run('INSERT INTO threads(category_id,user_id,title,created_at,last_post_at,last_post_user,lang) VALUES(?,?,?,?,?,?,?)',cat.id,c.user.id,title,now,now,c.user.id,lang).lastInsertRowid),pid=Number(db.run('INSERT INTO posts(thread_id,user_id,body,created_at,lang) VALUES(?,?,?,?,?)',tid,c.user.id,body,now,lang).lastInsertRowid);indexPost(db,pid,tid,title,body);earn(c.user.id,'tema',tid);m.activity(c.user.id,'tema',tid);return tid;}),id=>'/t/'+id);});
53 get(/^\/t\/(\d+)(?:-[^/]+)?$/, (c,id)=>{const thread=db.get('SELECT * FROM threads WHERE id=? AND deleted=0',id);if(!thread)return c.error(404);db.run('UPDATE threads SET views=views+1 WHERE id=?',id);const posts=db.all('SELECT p.*,u.handle,u.display,u.member_no,u.city_id,u.created_at joined,u.role,u.abuse_contact,u.avatar FROM posts p JOIN users u ON u.id=p.user_id WHERE thread_id=? AND p.deleted=0 ORDER BY p.created_at,p.id',id);page(c,thread.title,posts.map((p,i)=>{const u=member(p.user_id)||{...p,id:p.user_id,nivo:2},n=db.get('SELECT COUNT(*) n FROM posts WHERE user_id=? AND deleted=0',p.user_id).n,thanks=db.get('SELECT COUNT(*) n FROM reactions WHERE post_id=?',p.id).n;return `<table class="post" id="p${p.id}"><thead><tr><th scope="col">${t('Član')}</th><th scope="col">${time(p.created_at)} · #${i+1} · <span class="lat">${p.lang}</span></th></tr></thead><tbody><tr><td class="autor">${avatar(u)}${userLink(u)}<span>${t(['posjetilac','istraživač','član','graditelj','ustanova'][u.nivo])}</span><span>${t(GRAD[p.city_id]?.ime||'negdje na mreži')}</span><span class="lat">${memberNo(p.member_no)}</span><span>${n} ${t('poruka')}</span>${time(p.joined)}${link('/u/'+p.handle,'Članska karta')}</td><td><div lang="${p.lang}">${render(p.body)}</div><hr><div class="actions">[${link('#odgovor','Odgovori')}] [<a href="#odgovor" data-quote="${esc(p.body)}">${t('Citiraj')}</a>] ${c.user?form(c,`/p/${p.id}/hvala`,'',t('Hvala')+' '+thanks)+form(c,`/p/${p.id}/prijava`,inp('reason','Razlog','text','','required maxlength="500"'),'Prijavi poruku'):link('/prijava','Prijava')}${c.user?.id===p.user_id?link('/p/'+p.id+'/izmijeni','Izmijeni'):''}</div></td></tr></tbody></table>`;}).join('')+(c.user&&!thread.locked?`<div id="odgovor">${form(c,`/t/${id}/odgovor`,area('body','Odgovor'),'Odgovori')}</div>`:link('/prijava','Prijava')),'mreza');});
54 post(/^\/t\/(\d+)\/odgovor$/, (c,id)=>{const thread=db.get('SELECT * FROM threads WHERE id=? AND deleted=0',id),body=clean(c.form.get('body'),20000);if(!thread||thread.locked)return c.error(403,t('Tema je zaključana.'));if(body.length<2)return c.error(422,t('Napišite poruku.'));action(c,()=>db.tx(()=>{const now=Date.now(),pid=Number(db.run('INSERT INTO posts(thread_id,user_id,body,created_at,lang) VALUES(?,?,?,?,?)',id,c.user.id,body,now,thread.lang).lastInsertRowid);indexPost(db,pid,+id,thread.title,body);db.run('UPDATE threads SET reply_count=reply_count+1,last_post_at=?,last_post_user=? WHERE id=?',now,c.user.id,id);earn(c.user.id,'odgovor',pid);notify(thread.user_id,'odgovor',c.user.id,+id,pid);m.activity(c.user.id,'odgovor',pid);networkCache=null;return pid;}),pid=>'/t/'+id+'#p'+pid);});
55 get(/^\/p\/(\d+)$/, (c,id)=>{const p=db.get('SELECT thread_id FROM posts WHERE id=? AND deleted=0',id);p?c.redirect('/t/'+p.thread_id+'#p'+id):c.error(404);});
56 get(/^\/p\/(\d+)\/izmijeni$/, (c,id)=>{const p=db.get('SELECT * FROM posts WHERE id=? AND deleted=0',id);if(!p||p.user_id!==c.user.id)return c.error(403);page(c,'Izmijeni',form(c,c.path,area('body','Poruka',p.body)));},{auth:true});
57 post(/^\/p\/(\d+)\/izmijeni$/, (c,id)=>{const p=db.get('SELECT * FROM posts WHERE id=? AND deleted=0',id),body=clean(c.form.get('body'),20000);if(!p||p.user_id!==c.user.id)return c.error(403);if(body.length<2)return c.error(422);db.tx(()=>{db.run('UPDATE posts SET body=?,edited_at=? WHERE id=?',body,Date.now(),id);db.run('UPDATE search SET body=? WHERE post_id=?',body,id);});c.redirect('/t/'+p.thread_id+'#p'+id);});
58 post(/^\/p\/(\d+)\/hvala$/, (c,id)=>{const p=db.get('SELECT * FROM posts WHERE id=? AND deleted=0',id);if(!p||p.user_id===c.user.id)return c.error(422,t('Zahvalite drugom članu.'));let on;db.tx(()=>{const old=db.get('SELECT 1 FROM reactions WHERE post_id=? AND user_id=?',id,c.user.id);on=!old;if(old)db.run('DELETE FROM reactions WHERE post_id=? AND user_id=?',id,c.user.id);else{db.run("INSERT INTO reactions VALUES(?,?,'hvala',?)",id,c.user.id,Date.now());if(!db.get("SELECT 1 FROM ledger WHERE reason='hvala' AND ref=?",id+':'+c.user.id)){dinar(db,p.user_id,2,'hvala',id+':'+c.user.id);notify(p.user_id,'hvala',c.user.id,p.thread_id,p.id);}}});if(c.req.headers.accept==='application/json')c.json(200,{on});else c.redirect('/t/'+p.thread_id+'#p'+id);});
59 post(/^\/p\/(\d+)\/prijava$/, (c,id)=>{const p=db.get('SELECT * FROM posts WHERE id=? AND deleted=0',id);if(!p)return c.error(404);db.run('INSERT INTO reports(post_id,user_id,reason,created_at) VALUES(?,?,?,?)',id,c.user.id,clean(c.form.get('reason'),500),Date.now());c.redirect('/t/'+p.thread_id+'#p'+id);});
60 get(/^\/(novo|pretraga)$/, (c,kind)=>{const q=clean(c.query.get('q'),100);let rows;if(kind==='novo'||!q)rows=db.all('SELECT * FROM threads WHERE deleted=0 ORDER BY last_post_at DESC LIMIT 50');else rows=db.all('SELECT DISTINCT t.* FROM threads t JOIN search s ON s.thread_id=t.id WHERE search MATCH ? AND t.deleted=0 LIMIT 50','"'+q.replaceAll('"','""')+'"');page(c,kind==='novo'?'Najnovije':'Pretraga',`<form action="/pretraga">${inp('q','Pretraga','search',q)}<button>${t('Traži')}</button></form>${discussionList(rows)}`,'mreza');});
61 get(/^\/clanovi$/,c=>page(c,'Članovi',table(['Član','Grad','Članski broj'],db.all("SELECT * FROM users WHERE deleted=0 AND role!='system' ORDER BY member_no").map(u=>[userLink(u),t(GRAD[u.city_id]?.ime||'negdje na mreži'),memberNo(u.member_no)])),'mreza'));
62 get(/^\/u\/([\w_]+)$/, (c,h)=>{const u=db.get('SELECT * FROM users WHERE handle=? AND deleted=0',h);if(!u||u.role==='system')return c.error(404);u.nivo=level(db,u);const ratings=db.get('SELECT COUNT(*) n,COALESCE(ROUND(AVG(score),1),0) avg FROM ratings WHERE to_id=?',u.id),inv=db.get('SELECT code FROM invites WHERE user_id=?',u.id);page(c,u.display,`<p class="lat">${h}.yu · <a href="${SITE}/u/${h}">${SITE}/u/${h}</a></p><div class="vitrina">${memberCard(u,{build:c.build,reputation:ratings.avg+' / '+ratings.n,invite:inv?.code,signature:db.get('SELECT sig FROM names WHERE name=?',h+'.yu')?.sig})}<button data-tilt hidden>${t('nagib')}</button><p data-tilt-status></p></div><p>${esc(u.bio)}</p><ol class="steps">${m.progress(u.id).map((done,i)=>`<li>${t(['INSTALIRAJ','GRADI','OBJAVI','POVEŽI'][i])} · ${t(done?'završeno':'čeka')}</li>`).join('')}</ol><p>${t('Ugled')}: ${ratings.avg} / ${ratings.n} ${t('ocjena')}</p><p>${link('/u/'+h+'/album','Album')} · ${link('/nivoi','Nivoi')}</p>${inv?link('/pozivnica/'+inv.code,'Pozivnica'):''}${c.user?form(c,'/pratim',`<input type="hidden" name="kind" value="clan"><input type="hidden" name="target" value="${u.id}">`,'Prati'):''}`);});
63 get(/^\/podesavanja$/,c=>page(c,'Podešavanja',form(c,'/podesavanja',inp('display','Ime za prikaz','text',c.user.display)+area('bio','O sebi',c.user.bio)+citySelect(c.user.city_id)+`<p>${t('city_privacy')}</p>`+area('abuse_contact','Javni kontakt za zloupotrebu',c.user.abuse_contact))+link('/podesavanja/karta','Biranje podataka')+' · '+link('/podesavanja/izvoz','Izvoz podataka')+form(c,'/podesavanja/brisanje',inp('password','Lozinka','password','','required')+`<label><input type="checkbox" name="posts" value="1"> ${t('Ukloni moje poruke')}</label>`,'Brisanje naloga')),{auth:true});
64 post(/^\/podesavanja(?:\/grad)?$/,c=>{const city=c.form.get('city_id')||null;if(city&&!GRAD[city])return c.error(422,t('Izaberite grad sa spiska.'));if(c.path.endsWith('/grad'))db.run('UPDATE users SET city_id=?,city=\'\' WHERE id=?',city,c.user.id);else db.run('UPDATE users SET display=?,bio=?,abuse_contact=?,city_id=?,city=\'\' WHERE id=?',clean(c.form.get('display'),60)||c.user.handle,clean(c.form.get('bio'),1000),clean(c.form.get('abuse_contact'),500),city,c.user.id);m.activity(c.user.id,'grad');networkCache=null;c.redirect('/u/'+c.user.handle);});
65 get(/^\/podesavanja\/izvoz$/,c=>c.json(200,m.exportUser(c.user.id),{'Content-Disposition':'attachment; filename="yu-izvoz.json"'}),{auth:true});
66 post(/^\/podesavanja\/brisanje$/,async c=>{if(!await verifyPassword(c.form.get('password')||'',c.user.pass_hash))return c.error(422,t('Lozinka nije ispravna.'));const code=m.remove(c.user.id,c.form.get('posts')==='1');c.setCookies.push(c.cookie(SESSION_COOKIE,'',{maxAge:0}));c.user=null;page(c,'Povratnica',`<p>${t('Sačuvajte ovaj kod. Prikazuje se samo jednom i važi 90 dana.')}</p><pre>${code}</pre>${link('/povratak','Povratak')}`);});
67 get(/^\/povratak$/,c=>page(c,'Povratak',form(c,'/povratak',inp('handle','Korisničko ime')+inp('code','Povratnica')+inp('password','Nova lozinka','password','','required minlength="12"'),'Povratak')));
68 post(/^\/povratak$/,c=>{if(limiter.hit('return:'+c.ip,10,900000))return c.error(429);if((c.form.get('password')||'').length<12)return c.error(422,t('Lozinka mora imati najmanje 12 znakova.'));action(c,()=>{const id=m.restore(clean(c.form.get('handle'),20),c.form.get('code')||'',hashPassword(c.form.get('password'))),s=createSession(db,id);c.setCookies.push(c.cookie(SESSION_COOKIE,s.raw,{maxAge:s.maxAge}));},'/dobrodosli');},{auth:false});
69 get(/^\/obavjestenja$/,c=>{const rows=db.all('SELECT * FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 100',c.user.id);page(c,'Obavještenja',rows.map(n=>`<p>${time(n.created_at)} ${t(n.kind)} ${n.thread_id?link('/t/'+n.thread_id,'Tema'):link('/album','Album')}</p>`).join('')+form(c,'/obavjestenja','','Označi pročitano'));},{auth:true});post(/^\/obavjestenja$/,c=>{db.run('UPDATE notifications SET read=1 WHERE user_id=?',c.user.id);c.redirect('/obavjestenja');});
70 get(/^\/slicice$/,c=>page(c,'Sličice',`<p>${t('Kesica: 60 DINAR za tri sličice. Najviše pet dnevno. Vjerovatnoće rijetkosti: obična 70, rijetka 22, folija 7, hologram 1; izbor se prilagođava preostaloj količini.')}</p>${c.user?form(c,'/slicice/kesica','','Otvori kesicu'):link('/prijava','Prijava')}<p>${link('/album','Album')} · ${link('/slicice/zamjena','Zamjena')}</p><div class="vitrina grid">${EDITIONS.map(e=>`<section>${collectible(e.slug)}${e.earned?`<p>${t('Nagrada za učešće')}</p>`:e.price&&c.user?form(c,`/slicice/${e.slug}/kupi`,'',t('Kupi sličicu')+' · '+e.price+' DINAR'):''}</section>`).join('')}</div>`,'kultura'));
71 post(/^\/slicice\/([\w-]+)\/kupi$/, (c,slug)=>action(c,()=>m.buy(c.user.id,slug),'/album'));
72 post(/^\/slicice\/kesica$/,c=>action(c,()=>m.pack(c.user.id),cards=>'/album?kesica='+cards.map(x=>x.id).join(',')));
73 const album=(c,u)=>{const cards=db.all('SELECT * FROM card_instances WHERE owner_id=? ORDER BY minted_at,id',u?.id||-1),owned=new Set(cards.map(x=>x.edition)),dupes=cards.filter((x,i)=>cards.findIndex(y=>y.edition===x.edition)!==i);page(c,'Album',`${c.query.has('kesica')?`<div class="pack-opening vitrina" data-pack>${cards.filter(x=>(c.query.get('kesica')||'').split(',').includes(String(x.id))).map(x=>collectible(x.edition,x).replace('class="karta ', 'data-flipped="true" class="karta ').replace('aria-pressed="false"','aria-pressed="true"')).join('')}</div>`:''}<p>${owned.size} / 24 · ${t('Prva serija')}</p>${owned.size===24?`<strong>${t('STRANA POPUNJENA')}</strong>`:''}<div class="album">${EDITIONS.map(e=>{const card=cards.find(x=>x.edition===e.slug);return card?collectible(e.slug,card,db.all('SELECT u.handle FROM card_transfers ct JOIN users u ON u.id=ct.from_id WHERE ct.card_id=? ORDER BY ct.id',card.id).map(r=>r.handle)):`<div class="empty">${e.no}. ${t(e.name)}</div>`;}).join('')}</div><h2>${t('Duple')}</h2>${dupes.map(x=>`<p>#${x.id} · ${t(EDITION[x.edition].name)}</p>`).join('')}${c.user?.id===u?.id?form(c,'/slicice/poklon',select('card','Sličica',cards.filter(x=>!EDITION[x.edition].earned).map(x=>[String(x.id),x.id+' · '+EDITION[x.edition].name]))+inp('to','Korisničko ime'),'Pokloni'):''}`,'kultura');};
74 get(/^\/album$/,c=>album(c,c.user));get(/^\/u\/([\w_]+)\/album$/, (c,h)=>{const u=db.get('SELECT * FROM users WHERE handle=? AND deleted=0',h);if(!u)return c.error(404);album(c,u);});
75 post(/^\/slicice\/poklon$/,c=>action(c,()=>{const to=db.get('SELECT id FROM users WHERE handle=? AND deleted=0',clean(c.form.get('to'),20));if(!to)throw Error('Primalac nije dostupan.');m.gift(c.user.id,to.id,Number(c.form.get('card')));},'/album'));
76 get(/^\/slicice\/zamjena$/,c=>page(c,'Zamjena',`<p>${t('Do tri sličice sa svake strane; dodatak od 0 do 100 DINAR. Ponuda važi sedam dana. Brojeve sličica pronađite u albumu.')}</p>${c.user?form(c,'/slicice/zamjena',inp('to','Korisničko ime')+inp('give','Nudim brojeve, razdvojene zarezom')+inp('want','Tražim brojeve, razdvojene zarezom')+inp('dinar','Dodatak DINAR','number','0','min="0" max="100"'),'Ponudi'):''}<h2>${t('Tražene sličice')}</h2>${table(['Član','Brojevi'],db.all("SELECT u.handle,GROUP_CONCAT(f.target_id,', ') wanted FROM follows f JOIN users u ON u.id=f.user_id WHERE f.kind='slicica' AND u.deleted=0 GROUP BY u.id").map(r=>[`<a class="lat" href="/u/${r.handle}/album">${r.handle}</a>`,esc(r.wanted)]))}${c.user?form(c,'/slicice/zelje',inp('numbers','Tražim brojeve izdanja, razdvojene zarezom'),'Sačuvaj'):''}${table(['Broj','Nudim','Tražim','Dodatak','Odluka'],db.all("SELECT * FROM trades WHERE status='otvoreno' AND created_at>?",Date.now()-7*DAY).map(x=>[x.id,esc(x.give_json),esc(x.want_json),x.dinar,c.user?.id===x.to_id?form(c,`/slicice/zamjena/${x.id}`,select('yes','Odluka',[['1','Prihvati'],['0','Odbij']])):'']))}`,'kultura'));
77 post(/^\/slicice\/zelje$/,c=>action(c,()=>db.tx(()=>{const numbers=[...new Set((c.form.get('numbers')||'').split(',').map(Number))];if(numbers.some(n=>!Number.isInteger(n)||n<1||n>24))throw Error('Izaberite brojeve od 1 do 24.');db.run("DELETE FROM follows WHERE user_id=? AND kind='slicica'",c.user.id);for(const n of numbers)db.run("INSERT INTO follows VALUES(?,'slicica',?,?)",c.user.id,String(n),Date.now());}),'/slicice/zamjena'));
78 post(/^\/slicice\/zamjena$/,c=>action(c,()=>{const to=db.get('SELECT id FROM users WHERE handle=? AND deleted=0',clean(c.form.get('to'),20));if(!to)throw Error('Primalac nije dostupan.');return m.offer(c.user.id,to.id,(c.form.get('give')||'').split(',').map(Number),(c.form.get('want')||'').split(',').map(Number),Number(c.form.get('dinar')));},'/slicice/zamjena'));
79 post(/^\/slicice\/zamjena\/(\d+)$/, (c,id)=>action(c,()=>m.accept(c.user.id,+id,c.form.get('yes')==='1'),'/album'));
80 get(/^\/slicice\/provjera\/(\d+)$/, (c,id)=>{const x=db.get('SELECT * FROM card_instances WHERE id=?',id);if(!x)return c.error(404);const key=db.get('SELECT public_key FROM reg_keys WHERE key_id=?',x.key_id);page(c,'Provjeri',`<p>${t(verify({card_id:x.id,edition:x.edition,serial:x.serial,minted_at:x.minted_at},x.sig,key.public_key)?'Potpis je ispravan.':'Potpis nije ispravan.')}</p>${collectible(x.edition,x)}`,'kultura');});
81 get(/^\/podesavanja\/karta$/,c=>page(c,'Biranje podataka',form(c,'/podesavanja/karta',['ime','grad','nivo','datum','ugled'].map(k=>`<label><input type="checkbox" name="fields" value="${k}" checked> ${t(k)}</label>`).join(''),'Objavi')+db.all('SELECT * FROM card_shares WHERE user_id=? AND revoked=0',c.user.id).map(x=>`<p>${link('/k/'+x.token,'Podijeljena karta')}</p>${form(c,'/podesavanja/karta/opoziv',`<input type="hidden" name="token" value="${x.token}">`,'Opozovi')}`).join('')),{auth:true});
82 post(/^\/podesavanja\/karta$/,c=>{const fields=c.form.getAll('fields').filter(x=>['ime','grad','nivo','datum','ugled'].includes(x)),code=token(18);db.run('INSERT INTO card_shares VALUES(?,?,?,?,0)',code,c.user.id,JSON.stringify(fields),Date.now());c.redirect('/k/'+code);});
83 post(/^\/podesavanja\/karta\/opoziv$/,c=>{db.run('UPDATE card_shares SET revoked=1 WHERE token=? AND user_id=?',c.form.get('token')||'',c.user.id);c.redirect('/podesavanja/karta');});
84 get(/^\/k\/([\w-]+)$/, (c,code)=>{const x=db.get('SELECT * FROM card_shares WHERE token=? AND revoked=0',code),u=x&&member(x.user_id);if(!u)return c.error(404);page(c,'Članska karta',`<div class="vitrina">${memberCard(u,{fields:JSON.parse(x.fields_json),build:c.build})}</div>`);});
85 get(/^\/pozivnica\/([\w-]+)$/, (c,code)=>{const inv=db.get('SELECT * FROM invites WHERE code=?',code),u=inv&&member(inv.user_id);if(!u)return c.error(404);page(c,'Pozivnica',`<div class="vitrina">${memberCard(u,{build:c.build})}</div><p>${t('Dobro došli za zajednički sto. Učite, objavite i upoznajte komšije. Učešće je dobrovoljno.')}</p>${link('/upis?pozivnica='+code,'Upis')}`);});
86 get(/^\/registar$/,c=>{const q=clean(c.query.get('q'),40);const rows=db.all('SELECT * FROM names WHERE name LIKE ? AND owner_id IN(SELECT id FROM users WHERE deleted=0) ORDER BY name','%'+q+'%');page(c,'Registar',`<p>${t('naming_text')}</p><nav>${[['/registar/pravila','Pravila imena'],['/registar/prednost','Period prednosti'],['/registar/osnivac','Popis osnivačevih imena'],['/registar/dnevnik','Dnevnik registra'],['/registar/kljucevi','Javni ključevi']].map(([h,l])=>link(h,l)).join(' · ')}</nav><form>${inp('q','Pretraga','search',q)}<button>${t('Traži')}</button></form>${c.user?form(c,'/registar/rezervisi',inp('name','Ime')+select('net','Mreža',[['glavna','Glavna mreža'],['proba','Probna mreža']]),'Rezerviši'):''}${table(['Ime','Stanje','Porijeklo'],rows.map(n=>[`<a class="lat" href="/registar/ime/${encodeURIComponent(n.name)}">${esc(n.name)}</a>`,t(n.status)+' · '+t(n.trust),`<span class="lat">${esc(n.origin)}</span>`]))}`,'registar');});
87 post(/^\/registar\/rezervisi$/,c=>action(c,()=>reg.reserve(c.user.id,c.form.get('name'),c.form.get('net')),'/registar'));
88 get(/^\/registar\/ime\/(.+)$/, (c,name)=>{const n=db.get('SELECT * FROM names WHERE name=?',name);if(!n)return c.error(404);page(c,'Zapis',`<h2 class="lat">${esc(n.name)}</h2><p>${t(n.status)} · ${t(n.trust)}</p><p class="lat">${esc(n.origin)}</p><p>${t('Potpis potvrđuje izjavu registra, a ne kvalitet ni zakonitost usluge.')}</p><pre>${esc(canonical({...record(n),signature:n.sig,key_id:n.key_id}))}</pre><button data-verify="${esc(n.name)}">${t('Provjeri potpis')}</button><p data-verification></p>${c.user?form(c,'/registar/provjera',`<input type="hidden" name="name" value="${esc(n.name)}">`,'Zabilježi provjeru'):''}${c.user&&n.status==='pregled'&&db.get('SELECT 1 FROM mandates WHERE user_id=? AND starts_at<=? AND ends_at>?',c.user.id,Date.now(),Date.now())?form(c,'/uprava/odobrenja',`<input type="hidden" name="subject" value="provjera"><input type="hidden" name="ref" value="${esc(n.name)}">`,'Odobri'):''}${c.user?.id===n.owner_id?form(c,'/registar/promjena',`<input type="hidden" name="name" value="${esc(n.name)}">`+select('action','Radnja',[['izdaj','Izdaj'],['obnovi','Obnovi']]))+form(c,'/registar/prenos',`<input type="hidden" name="name" value="${esc(n.name)}">`+inp('to','Primalac'),'Prenesi'):''}${c.user&&db.get('SELECT 1 FROM name_transfers WHERE name=? AND to_id=?',n.name,c.user.id)?form(c,'/registar/prihvati',`<input type="hidden" name="name" value="${esc(n.name)}">`,'Prihvati'):''}`,'registar');});
89 post(/^\/registar\/promjena$/,c=>action(c,()=>reg.change(c.user.id,c.form.get('name'),c.form.get('action')),'/registar'));
90 post(/^\/registar\/provjera$/,c=>action(c,()=>{const r=reg.resolve(c.form.get('name'));if(!r.ok)throw Error('Potpis nije ispravan.');db.tx(()=>{m.reward(c.user.id,10,'provjera','prva');m.activity(c.user.id,'provjera');});},'/registar'));
91 post(/^\/registar\/prenos$/,c=>action(c,()=>db.tx(()=>{const n=db.get("SELECT * FROM names WHERE name=? AND owner_id=? AND kind='licno' AND status='izdato'",c.form.get('name')||'',c.user.id),to=db.get('SELECT * FROM users WHERE handle=? AND deleted=0',c.form.get('to')||'');if(!n||!to||to.id===c.user.id)throw Error('Prenos nije dozvoljen.');db.run('INSERT OR REPLACE INTO name_transfers VALUES(?,?,?,?)',n.name,c.user.id,to.id,Date.now()+7*DAY);reg.log('ponuda-prenosa',n.name,c.user.id,{to:to.id});notify(to.id,'prenos',c.user.id);}),'/registar'));
92 post(/^\/registar\/prihvati$/,c=>action(c,()=>db.tx(()=>{const x=db.get('SELECT * FROM name_transfers WHERE name=? AND to_id=? AND expires_at>?',c.form.get('name')||'',c.user.id,Date.now());if(!x||db.get('SELECT owner_id FROM names WHERE name=?',x.name)?.owner_id!==x.from_id||db.get('SELECT COUNT(*) n FROM names WHERE owner_id=?',c.user.id).n>=6)throw Error('Prenos nije dozvoljen.');const seq=reg.log('prenos',x.name,c.user.id,{from:x.from_id});db.run('UPDATE names SET owner_id=?,sequence=? WHERE name=?',c.user.id,seq,x.name);db.run('UPDATE pages SET owner_id=? WHERE name=?',c.user.id,x.name);db.run('DELETE FROM name_transfers WHERE name=?',x.name);reg.seal(x.name);}),'/registar'));
93 get(/^\/registar\/snimak.json$/,c=>c.json(200,reg.snapshot()));get(/^\/registar\/kljucevi.json$/,c=>c.json(200,db.all('SELECT * FROM reg_keys ORDER BY created_at')));get(/^\/registar\/dnevnik.json$/,c=>c.json(200,db.all('SELECT * FROM reg_log ORDER BY seq')));
94 get(/^\/registar\/dnevnik$/,c=>page(c,'Dnevnik registra',`<button data-chain>${t('Provjeri lanac')}</button><p data-verification></p>${table(['Broj','Vrijeme','Radnja','Ime','Sažetak'],db.all('SELECT * FROM reg_log ORDER BY seq DESC LIMIT 100').map(r=>[r.seq,time(r.at),t(r.action),`<span class="lat">${esc(r.name)}</span>`,`<code>${r.hash.slice(0,16)}</code>`]))}`,'registar'));
95 get(/^\/registar\/kljucevi$/,c=>page(c,'Javni ključevi',table(['Otisak','Nastao','Povučen','Javni ključ'],db.all('SELECT * FROM reg_keys').map(k=>[k.key_id,time(k.created_at),k.retired_at?time(k.retired_at):t('važeći'),`<pre>${esc(k.public_key)}</pre>`]))+link('/uprava/odobrenja','Zamjena ključa'),'registar'));
96 get(/^\/registar\/pravila$/,c=>page(c,'Pravila imena',`<p>${t('naming_text')}</p><p class="protected">${t('ZAŠTIĆENO')}</p><p class="lat">${[...SISTEMSKA,...ZASTICENA].map(x=>x+'.yu').join(', ')}</p>`,'registar'));
97 get(/^\/registar\/prednost$/,c=>page(c,'Period prednosti',`<p>${t('Period prednosti je završen 10.11.2026. Udruženja i mjesne zajednice mogu tražiti nezavisan pregled.')}</p>${c.user?form(c,'/registar/prednost',inp('name','Ime'),'Tražim ime za udruženje'):link('/upis','Upis')}`,'registar'));
98 post(/^\/registar\/prednost$/,c=>action(c,()=>reg.reserve(c.user.id,c.form.get('name'),'glavna',true),'/registar'));
99 get(/^\/registar\/osnivac$/,c=>page(c,'Popis osnivačevih imena',table(['Ime','Svrha','Porijeklo'],db.all("SELECT n.* FROM names n JOIN users u ON u.id=n.owner_id WHERE u.handle='signal' OR n.kind='sistemsko'").map(n=>[`<span class="lat">${esc(n.name)}</span>`,t(n.kind),`<span class="lat">${esc(n.origin)}</span>`])),'registar'));
100 get(/^\/connect$/,c=>page(c,'YU Connect',`<p>${t('Simulator klijenta u pregledaču. Nema instalacije ni promjene DNS-a.')}</p><div class="monitor"><div class="screen"><p class="lat">YU CONNECT 0.1</p><p>${t('SPREMAN')}</p><form id="connect-form">${inp('ime','Ime','text',c.query.get('ime')||'hello.yu')}<label><input type="checkbox" name="proba"> ${t('Probna mreža')}</label><button>${t('Otvori')}</button></form><div id="connect-log" role="status"></div></div></div><div id="connect-result"></div><p>${['hello','isteklo','opozvano','sporno','proba-jedan'].map(n=>`<a class="lat" href="/connect?ime=${n}.yu">${n}.yu</a>`).join(' · ')}</p>`,'connect',true));
101 get(/^\/yu\/(.+)$/, (c,name)=>{const r=reg.resolve(name);if(!r.ok)return c.error(404,t(r.state));c.redirect('/connect?ime='+encodeURIComponent(r.record.name));});
102 const apiGuard=c=>{if(limiter.hit('api:'+c.ip,60,60000)){c.json(429,{error:'ogranicenje'});return false;}return true;};
103 get(/^\/api\/v1\/razrijesi$/,c=>{if(!apiGuard(c))return;const r=reg.resolve(c.query.get('ime')||'',c.query.get('mreza')||'glavna');m.count(r.ok?'razrijeseno':'odbijeno');if(r.ok){c.setCookies.push(c.cookie('istrazivac','1',{maxAge:31536000,httpOnly:false}));if(c.user)m.activity(c.user.id,'razrijeseno',r.record.name);}c.json(200,r);});
104 get(/^\/api\/v1\/provjera$/,c=>{if(!apiGuard(c))return;if(c.query.get('tip')==='dnevnik'){const rows=db.all('SELECT * FROM reg_log ORDER BY seq'),keys=Object.fromEntries(db.all('SELECT * FROM reg_keys').map(k=>[k.key_id,k.public_key]));return c.json(200,{ok:c.query.get('hash')===sha(canonical(rows))&&verifyChain(rows,keys)});}if(c.query.get('tip')==='snimak'){const {signature,...payload}=reg.snapshot();return c.json(200,{ok:c.query.get('hash')===sha(canonical(payload))&&verify(payload,signature,reg.key().public_key)});}const n=db.get('SELECT * FROM names WHERE name=?',c.query.get('ime')||'');c.json(200,{ok:!!n&&(!c.query.has('hash')||c.query.get('hash')===sha(canonical(record(n))))&&verify(record(n),n.sig,db.get('SELECT public_key FROM reg_keys WHERE key_id=?',n.key_id)?.public_key)});});
105 get(/^\/api\/v1\/mreza.json$/,c=>{if(apiGuard(c))c.json(200,net());});get(/^\/api\/v1\/mjerila.json$/,c=>{if(apiGuard(c))c.json(200,stats());});
106 get(/^\/stranice$/,c=>page(c,'Stranice',`<p>${t('Objavite stranicu pod svojim imenom. Tekst koristi siguran Markdown; HTML se prikazuje kao tekst.')}</p>${c.user?form(c,'/stranice',select('name','Ime',db.all("SELECT name FROM names WHERE owner_id=? AND kind='licno'",c.user.id).map(n=>[n.name,n.name]))+inp('title','Naslov')+area('body','Sadržaj')+inp('origin','Spoljno HTTPS porijeklo, ako ga imate','url'),'Objavi'):link('/upis','Upis')}${table(['Stranica','Ime'],db.all('SELECT * FROM pages WHERE published=1').map(p=>[`<a href="/s/${encodeURIComponent(p.name.slice(0,-3))}">${esc(p.title)}</a>`,`<span class="lat">${esc(p.name)}</span>`]))}`,'izvor'));
107 post(/^\/stranice$/,c=>action(c,()=>m.publish(c.user.id,c.form.get('name'),clean(c.form.get('title'),180),clean(c.form.get('body'),20000),clean(c.form.get('origin'),500)),'/stranice'));
108 get(/^\/s\/(.+)$/, (c,name)=>{const p=db.get('SELECT * FROM pages WHERE name=? AND published=1',name+'.yu');if(!p)return c.error(404);page(c,p.title,`<div class="origin lat">${esc(p.name)} · ${SITE}/s/${esc(name)}</div><article>${render(p.body)}</article>`,'izvor');});
109 get(/^\/imenik$/,c=>{const kind=c.query.get('kind')||'',jed=c.query.get('jed')||'',lang=c.query.get('lang')||'';let projects=db.all('SELECT * FROM projects');projects=projects.filter(p=>(!kind||p.kind===kind)&&(!jed||GRAD[p.city_id]?.jed===jed)&&(!lang||p.lang===lang));page(c,'Imenik',`<form>${select('kind','Vrsta',[['','Sve'],...['zanat','studio','zadruga','program','kultura','škola'].map(k=>[k,k])],kind)}${select('jed','Republika / pokrajina',[['','Sve'],...Object.entries(JEDINICE)],jed)}${select('lang','Jezik',[['','Svi'],['sh','srpskohrvatski / hrvatskosrpski'],['sl','slovenski'],['mk','makedonski'],['sq','shqip'],['hu','magyar']],lang)}<button>${t('Traži')}</button></form>${projects.map(p=>`<section class="listing"><h2>${esc(p.title)}</h2><p>${esc(p.summary)}</p><p>${t(p.kind)} · ${t(GRAD[p.city_id]?.ime||'')}</p><a class="lat" href="${esc(p.url)}" rel="noopener noreferrer nofollow ugc">${esc(p.name||p.url)}</a>${c.user?form(c,'/pratim',`<input type="hidden" name="kind" value="projekat"><input type="hidden" name="target" value="${p.id}">`,'Prati'):''}</section>`).join('')}${c.user?form(c,'/imenik',inp('title','Naslov')+select('kind','Vrsta',['zanat','studio','zadruga','program','kultura','škola'].map(k=>[k,k]))+area('summary','Opis')+inp('url','Javna HTTPS adresa','url')+inp('name','Ime .yu')+citySelect(c.user.city_id),'Objavi'):''}`,'mreza');});
110 post(/^\/imenik$/,c=>action(c,()=>{const url=c.form.get('url');if(!validOrigin(url)||!GRAD[c.form.get('city_id')])throw Error('Izaberite javnu HTTPS adresu i grad.');db.run('INSERT INTO projects(owner_id,title,kind,summary,city_id,name,url,lang,created_at) VALUES(?,?,?,?,?,?,?,?,?)',c.user.id,clean(c.form.get('title'),180),clean(c.form.get('kind'),20),clean(c.form.get('summary'),1000),c.form.get('city_id'),clean(c.form.get('name'),40),url,'sh',Date.now());},'/imenik'));
111 post(/^\/pratim$/,c=>{const kind=c.form.get('kind'),target=clean(c.form.get('target'),20);if(!['projekat','clan'].includes(kind))return c.error(422);db.run('INSERT OR IGNORE INTO follows VALUES(?,?,?,?)',c.user.id,kind,target,Date.now());c.redirect('/pratim');});
112 get(/^\/pratim$/,c=>page(c,'Pratim',table(['Vrsta','Sadržaj'],db.all('SELECT * FROM follows WHERE user_id=?',c.user.id).map(f=>{const p=f.kind==='projekat'?db.get('SELECT title FROM projects WHERE id=?',f.target_id):db.get('SELECT handle FROM users WHERE id=?',f.target_id);return [t(f.kind),p?f.kind==='projekat'?esc(p.title):`<a class="lat" href="/u/${p.handle}">${p.handle}</a>`:''];}))+discussionList(db.all("SELECT DISTINCT t.* FROM threads t JOIN follows f ON f.kind='clan' AND f.target_id=CAST(t.user_id AS TEXT) WHERE f.user_id=? AND t.deleted=0 ORDER BY t.last_post_at DESC LIMIT 30",c.user.id)),'mreza'),{auth:true});
113 get(/^\/karta$/,c=>page(c,'Karta mreže',mapSvg(net())+table(['Republika / pokrajina','Čvorovi','Članovi'],Object.entries(JEDINICE).map(([key,name])=>{const nodes=net().nodes.filter(n=>n.jed===key);return [link('/karta/jedinica/'+key,name),nodes.filter(n=>n.n).length,nodes.reduce((s,n)=>s+n.n,0)+` <svg class="unit-bars" viewBox="0 0 100 12" width="100" height="12" aria-hidden="true">${Array.from({length:Math.min(10,nodes.reduce((s,n)=>s+n.n,0))},(_,i)=>`<rect x="${i*10}" width="8" height="12" fill="#003893"/>`).join('')}</svg>`];}))+`<section data-network><h2>${t('Uživo')}</h2><p data-network-quiet>${t('Mirno je. Posljednji signal se prikazuje kada članovi nešto urade.')}</p><ul id="network-events" aria-live="polite">${net().events.map(e=>`<li>${time(e.at)} ${t(GRAD[e.a].ime)} · ${t(GRAD[e.b].ime)}: ${t(e.kind)}</li>`).join('')}</ul></section>`,'mreza'));
114 get(/^\/karta\/(grad|jedinica)\/([\w-]+)$/, (c,kind,id)=>{const cityIds=kind==='grad'?(GRAD[id]?[id]:[]):GRADOVI.filter(g=>g.jed===id).map(g=>g.id);if(!cityIds.length)return c.error(404);const users=db.all('SELECT * FROM users WHERE deleted=0').filter(u=>cityIds.includes(u.city_id)),ids=users.map(u=>u.id);page(c,kind==='grad'?GRAD[id].ime:JEDINICE[id],`<p>${link('/c/mjesne-zajednice'+(kind==='grad'?'?grad='+id:''),'Javi se komšijama')}</p><div class="grid">${users.map(u=>`<section>${userLink(u)}${memberCard({...u,nivo:level(db,u)},{build:c.build})}</section>`).join('')}</div><h2>${t('Projekti')}</h2>${db.all('SELECT * FROM projects').filter(p=>ids.includes(p.owner_id)).map(p=>`<p>${esc(p.title)}</p>`).join('')}<h2>${t('Oglasi')}</h2>${db.all('SELECT * FROM listings').filter(p=>ids.includes(p.seller_id)).map(p=>link('/pijaca/'+p.id,p.title)).join(' · ')}<h2>${t('Ogledala')}</h2>${db.all('SELECT * FROM mirrors').filter(p=>ids.includes(p.owner_id)).map(p=>`<p class="lat">${esc(p.url)}</p>`).join('')}<h2>${t('Najnovije')}</h2>${discussionList(db.all('SELECT * FROM threads WHERE deleted=0 ORDER BY last_post_at DESC').filter(p=>ids.includes(p.user_id)).slice(0,20))}`,'mreza');});
115 get(/^\/admin$/,c=>page(c,'Moderacija',table(['Broj','Prijava','Postupak'],db.all('SELECT * FROM reports WHERE resolved_at IS NULL ORDER BY created_at').map(r=>[r.id,esc(r.reason),form(c,'/admin/prijava/'+r.id,area('note','Obrazloženje')+select('action','Radnja',[['ostavi','Ostavi poruku'],['ukloni','Ukloni poruku']]),'Odluči')]))+form(c,'/admin/tema',inp('thread','Broj teme','number')+select('action','Radnja',[['zakljucaj','Zaključaj'],['otkljucaj','Otključaj'],['zakaci','Zakači']])+area('note','Obrazloženje'),'Sačuvaj')+link('/uprava/odobrenja','Suspenzije sa dva odobrenja'),'uprava'),{auth:true,admin:true});
116 post(/^\/admin\/prijava\/(\d+)$/, (c,id)=>action(c,()=>db.tx(()=>{const r=db.get('SELECT * FROM reports WHERE id=? AND resolved_at IS NULL',id);if(!r)throw Error('Prijava je već riješena.');const note=clean(c.form.get('note'),1000),act=c.form.get('action');if(!note||!['ostavi','ukloni'].includes(act))throw Error('Napišite obrazloženje.');if(act==='ukloni'){db.run('UPDATE posts SET deleted=1 WHERE id=?',r.post_id);db.run('DELETE FROM search WHERE post_id=?',r.post_id);}db.run('UPDATE reports SET resolved_by=?,resolved_at=?,outcome=? WHERE id=?',c.user.id,Date.now(),act,id);db.run('INSERT INTO mod_log(actor_id,action,target,note,created_at) VALUES(?,?,?,?,?)',c.user.id,act,'p:'+r.post_id,note,Date.now());notify(r.user_id,'odluka',c.user.id);}),'/admin'),{admin:true});
117 post(/^\/admin\/tema$/,c=>action(c,()=>db.tx(()=>{const id=Number(c.form.get('thread')),act=c.form.get('action'),note=clean(c.form.get('note'),1000);if(!db.get('SELECT 1 FROM threads WHERE id=?',id)||!note||!['zakljucaj','otkljucaj','zakaci'].includes(act))throw Error('Napišite obrazloženje.');if(act==='zakaci')db.run('UPDATE threads SET pinned=1 WHERE id=?',id);else db.run('UPDATE threads SET locked=? WHERE id=?',act==='zakljucaj'?1:0,id);db.run('INSERT INTO mod_log(actor_id,action,target,note,created_at) VALUES(?,?,?,?,?)',c.user.id,act,'t:'+id,note,Date.now());}),'/admin'),{admin:true});
118 get(/^\/pijaca$/,c=>{m.autoRelease();page(c,'Pijaca',`<p class="tag">${t('simulation_market')}</p>${db.all("SELECT l.*,u.handle FROM listings l JOIN users u ON u.id=l.seller_id WHERE l.status='otvoreno' AND u.deleted=0").map(l=>`<section class="listing"><h2><a href="/pijaca/${l.id}">${esc(l.title)}</a></h2><p>${esc(l.body)}</p><p>${esc(l.price_eur_text)} · ${t('obavještenje')} · ${l.price_dinar} ${t('DINAR · probni bodovi')}</p><span class="lat">${l.handle}</span>${level(db,member(l.seller_id))>=3&&db.get("SELECT COUNT(*) n FROM orders WHERE seller_id=? AND status='primljeno'",l.seller_id).n>=3&&!db.get("SELECT 1 FROM disputes WHERE respondent_id=? AND status='usvojena'",l.seller_id)?` <strong>${t('Provjerena tezga')}</strong>`:''}</section>`).join('')}${c.user?form(c,'/pijaca',inp('title','Naslov')+area('body','Opis')+inp('kind','Vrsta')+inp('price_eur_text','Informativna cijena EUR')+inp('price_dinar','Probna cijena DINAR','number','20','min="1" max="10000"')+inp('stock','Količina','number','1','min="1" max="1000"'),'Objavi'):''}`,'pijaca',true);});
119 post(/^\/pijaca$/,c=>action(c,()=>{const price=Number(c.form.get('price_dinar')),stock=Number(c.form.get('stock'));if(!Number.isInteger(price)||price<1||price>10000||!Number.isInteger(stock)||stock<1||stock>1000)throw Error('Neispravna količina ili probna cijena.');db.run("INSERT INTO listings(seller_id,title,body,kind,price_eur_text,price_dinar,stock,status,created_at) VALUES(?,?,?,?,?,?,?,'otvoreno',?)",c.user.id,clean(c.form.get('title'),180),clean(c.form.get('body'),5000),clean(c.form.get('kind'),30),clean(c.form.get('price_eur_text'),40),price,stock,Date.now());m.activity(c.user.id,'oglas');},'/pijaca'));
120 get(/^\/pijaca\/(\d+)$/, (c,id)=>{const l=db.get('SELECT * FROM listings WHERE id=?',id);if(!l)return c.error(404);const orders=c.user?db.all('SELECT * FROM orders WHERE listing_id=? AND (buyer_id=? OR seller_id=?) ORDER BY id DESC',id,c.user.id,c.user.id):[];page(c,l.title,`<p class="tag">${t('simulation_market')}</p>${render(l.body)}<p>${esc(l.price_eur_text)} · ${l.price_dinar} ${t('DINAR · probni bodovi')} · ${t('Količina')}: ${l.stock}</p>${c.user?form(c,'/pijaca/'+id+'/naruci','','Naruči'):link('/prijava','Prijava')}${orders.map(o=>`<section class="panel"><h2>${t('Narudžba')} #${o.id}</h2><p>${t(o.status)} · ${o.amount} DINAR</p>${form(c,'/pijaca/narudzba/'+o.id,select('action','Radnja',[['poslano','Poslano'],['primljeno','Primljeno'],['spor','Otvori spor']]))}${o.status==='primljeno'?form(c,'/pijaca/ocjena/'+o.id,inp('score','Ocjena','number','5','min="1" max="5"')+area('note','Napomena'),'Ocijeni'):''}</section>`).join('')}`,'pijaca',true);});
121 post(/^\/pijaca\/(\d+)\/naruci$/, (c,id)=>action(c,()=>m.order(c.user.id,+id),'/pijaca/'+id));
122 post(/^\/pijaca\/narudzba\/(\d+)$/, (c,id)=>action(c,()=>m.orderAction(c.user.id,+id,c.form.get('action')),'/pijaca'));
123 post(/^\/pijaca\/ocjena\/(\d+)$/, (c,id)=>action(c,()=>m.rate(c.user.id,+id,Number(c.form.get('score')),clean(c.form.get('note'),500)),'/pijaca'));
124 const proposals=(c,onlyElections=false)=>db.all("SELECT * FROM proposals WHERE (?=1 AND kind='izbori') OR (?=0 AND kind!='izbori') ORDER BY id",onlyElections?1:0,onlyElections?1:0).map(p=>`<section class="panel" id="prijedlog-${p.id}"><h2>${esc(p.title)}</h2><p>${esc(p.body)}</p><p>${t(p.kind)} · ${t(p.status)} · ${t('Kvorum')}: ${p.quorum} · ${time(p.closes_at)}</p>${p.thread_id?link('/t/'+p.thread_id,'Javna rasprava'):''}${table(['Izbor','Glasovi'],m.result(p.id).tally.map(r=>[t(r.choice),r.n]))}${c.user&&p.status==='otvoreno'?form(c,'/uprava/glas/'+p.id,select('choice','Izbor',JSON.parse(p.options).map(x=>[x,x])),'Glasaj'):''}${c.user&&p.status==='otvoreno'&&p.closes_at<=Date.now()?form(c,'/uprava/zakljuci/'+p.id,'','Zaključi'):''}</section>`).join('');
125 get(/^\/uprava$/,c=>page(c,'Uprava',`<nav>${[['/uprava/prijedlozi','Prijedlozi'],['/izbori','Izbori'],['/zalbe','Žalbe'],['/uprava/odobrenja','Dvojni potpis'],['/povelja','Povelja'],['/dnevnik','Javni dnevnik'],['/blagajna','Blagajna']].map(([h,l])=>link(h,l)).join(' · ')}</nav>${table(['Član','Mjesto','Mandat do'],db.all('SELECT m.*,u.handle FROM mandates m JOIN users u ON u.id=m.user_id WHERE m.ends_at>?',Date.now()).map(r=>[`<a class="lat" href="/u/${r.handle}">${r.handle}</a>`,t(r.seat),time(r.ends_at)]))}`,'uprava'));
126 get(/^\/uprava\/prijedlozi$/,c=>page(c,'Prijedlozi',proposals(c)+`<p>${t('Obavezujući glas traži nalog star najmanje 72 h. Prijedlog: većina i kvorum 10. Izmjena Povelje: dvije trećine i kvorum 20.')}</p>${c.user?form(c,'/uprava/prijedlozi',inp('title','Naslov')+area('body','Prijedlog')+select('kind','Vrsta',[['rasprava','Javna rasprava'],['prijedlog','Prijedlog'],['izmjena-povelje','Izmjena Povelje']])+inp('thread_id','Broj teme','number','','required'),'Predloži'):''}`,'uprava'));
127 post(/^\/uprava\/prijedlozi$/,c=>action(c,()=>{const kind=c.form.get('kind'),tid=Number(c.form.get('thread_id'));if(!['rasprava','prijedlog','izmjena-povelje'].includes(kind)||!db.get('SELECT 1 FROM threads WHERE id=? AND deleted=0',tid))throw Error('Izaberite vrstu i postojeću temu.');db.run("INSERT INTO proposals(kind,title,body,options,author_id,thread_id,opens_at,closes_at,quorum,threshold,status) VALUES(?,?,?,?,?,?,?,?,?,?,'otvoreno')",kind,clean(c.form.get('title'),180),clean(c.form.get('body'),20000),JSON.stringify(['za','protiv','uzdrzan']),c.user.id,tid,Date.now(),Date.now()+14*DAY,kind==='rasprava'?0:kind==='prijedlog'?10:20,kind==='izmjena-povelje'?2/3:.5);},'/uprava/prijedlozi'));
128 post(/^\/uprava\/glas\/(\d+)$/, (c,id)=>action(c,()=>m.vote(c.user.id,+id,c.form.get('choice')),'/uprava/prijedlozi'));
129 post(/^\/uprava\/zakljuci\/(\d+)$/, (c,id)=>action(c,()=>m.closeProposal(+id),'/uprava/prijedlozi'));
130 get(/^\/izbori$/,c=>page(c,'Izbori',`<p>${t('Sedam mjesta: graditelji, stvaraoci, trgovci, sigurnost i privatnost, jezičke zajednice i dva opšta. Mandat traje 90 dana. Pojedinačni glasovi nisu javni.')}</p>${db.all('SELECT ca.*,u.handle FROM candidates ca JOIN users u ON u.id=ca.user_id').map(x=>`<p><span class="lat">${x.handle}</span> · ${esc(x.statement)}</p>`).join('')}${proposals(c,true)}`,'uprava'));
131 get(/^\/uprava\/odobrenja$/,c=>page(c,'Dvojni potpis',`<p>${t('Svaka osjetljiva radnja traži dva različita povjerenika. Jedan potpis nije dovoljan.')}</p>${table(['Radnja','Predmet','Odobrenja'],db.all('SELECT subject,subject_id,COUNT(*) n FROM approvals GROUP BY subject,subject_id').map(x=>[t(x.subject),`<span class="lat">${esc(x.subject_id)}</span>`,x.n]))}${c.user&&level(db,c.user)===4?form(c,'/uprava/odobrenja',select('subject','Radnja',[['kljuc','Zamjena ključa'],['opoziv','Opoziv imena'],['provjera','Provjera porijekla'],['suspenzija','Suspenzija']])+inp('ref','Predmet','text',reg.key().key_id),'Odobri'):''}`,'uprava'));
132 post(/^\/uprava\/odobrenja$/,c=>action(c,()=>m.approval(c.user.id,c.form.get('subject'),clean(c.form.get('ref'),100)),'/uprava/odobrenja'));
133 get(/^\/zalbe$/,c=>page(c,'Žalbe i sporovi',`<p>${t('Izjava, 72 h za odgovor, nezavisan pregled i jedna žalba drugom povjereniku. Na zahtjev sakrivamo korisničko ime.')}</p>${db.all('SELECT * FROM disputes ORDER BY id DESC').map(d=>`<section class="panel"><h2>#${d.id} · ${t(d.kind)}</h2><p>${d.redact?t('Ime je sakriveno.'):userLink(member(d.opener_id)||{handle:'',display:t('Otišli član')})}</p><p>${esc(d.statement)}</p><p>${esc(d.response||'')}</p><p>${t(d.status)} · ${time(d.deadline_at)}</p>${c.user?.id===d.respondent_id&&d.status==='u toku'?form(c,'/zalbe/odgovor/'+d.id,area('response','Odgovor'),'Odgovori'):''}${c.user&&level(db,c.user)===4?form(c,'/zalbe/odluka/'+d.id,select('decision','Odluka',[['usvojena','Usvojena'],['odbijena','Odbijena']]),'Odluči'):''}${c.user?.id===d.opener_id&&!d.appeal_of&&d.status!=='u toku'?form(c,'/zalbe',`<input type="hidden" name="appeal" value="${d.id}"><input type="hidden" name="kind" value="${esc(d.kind)}"><input type="hidden" name="ref" value="${esc(d.ref)}"><input type="hidden" name="respondent" value="${d.respondent_id}">`+area('statement','Žalba'),'Podnesi žalbu'):''}</section>`).join('')}${c.user?form(c,'/zalbe',select('kind','Vrsta',[['ime','Ime'],['moderacija','Moderacija'],['narudzba','Narudžba']])+inp('ref','Predmet')+inp('respondent','Broj člana koji je postupao','number')+area('statement','Izjava')+`<label><input type="checkbox" name="redact" value="1"> ${t('Sakrij moje ime')}</label>`,'Podnesi'):link('/prijava','Prijava')}`,'uprava'));
134 post(/^\/zalbe$/,c=>action(c,()=>m.dispute(c.user.id,clean(c.form.get('kind'),30),clean(c.form.get('ref'),100),clean(c.form.get('statement'),3000),Number(c.form.get('respondent')),Number(c.form.get('appeal'))||null,c.form.get('redact')==='1'?1:0),'/zalbe'));
135 post(/^\/zalbe\/odgovor\/(\d+)$/, (c,id)=>{const d=db.get("SELECT * FROM disputes WHERE id=? AND respondent_id=? AND status='u toku'",id,c.user.id);if(!d)return c.error(403);db.run('UPDATE disputes SET response=? WHERE id=?',clean(c.form.get('response'),3000),id);c.redirect('/zalbe');});
136 post(/^\/zalbe\/odluka\/(\d+)$/, (c,id)=>action(c,()=>m.decide(c.user.id,+id,c.form.get('decision')),'/zalbe'));
137 get(/^\/dnevnik$/,c=>page(c,'Javni dnevnik',table(['Vrijeme','Radnja','Predmet','Obrazloženje'],db.all('SELECT * FROM mod_log ORDER BY id DESC LIMIT 100').map(r=>[time(r.created_at),t(r.action),esc(r.target),esc(r.note)])),'uprava'));
138 get(/^\/blagajna$/,c=>{const treasury=db.get("SELECT * FROM users WHERE handle='blagajna'"),circulation=db.get("SELECT SUM(dinar) n FROM users WHERE handle!='blagajna'").n,escrow=db.get("SELECT COALESCE(SUM(amount),0) n FROM orders WHERE status IN('polog','poslano','spor')").n;page(c,'Blagajna',`<p>${t('Izdato')}: ${treasury.dinar+circulation+escrow} · ${t('U opticaju')}: ${circulation} · ${t('Polog')}: ${escrow} · ${t('Blagajna')}: ${treasury.dinar} ${t('DINAR · probni bodovi')}</p>${table(['Vrijeme','Promjena','Razlog'],db.all('SELECT * FROM ledger WHERE user_id=? ORDER BY id DESC LIMIT 100',treasury.id).map(r=>[time(r.created_at),r.delta,t(r.reason)]))}`,'dinar',true);});
139 get(/^\/provjera$/,c=>page(c,'Provjera izvoza',`<p>${t('Zalijepite potpisani izvoz. Provjera se obavlja u pregledaču; podaci se ne šalju serveru.')}</p><form id="export-verify">${area('document','Potpisani JSON')}<button>${t('Provjeri')}</button></form><p data-verification></p>`,'izvor'));
140 get(/^\/federacija$/,c=>page(c,'Federacija',`<p>${t('federation_text')}</p>${table(['Ogledalo','Grad','Stanje'],db.all('SELECT * FROM mirrors').map(x=>[`<a class="lat" href="${esc(x.url)}" rel="noopener noreferrer nofollow ugc">${esc(x.url)}</a>`,t(GRAD[x.city_id]?.ime||''),x.simulated?'<span class="tag" translate="no">SIMULACIJA</span>':t('upisano')]))}${c.user?form(c,'/federacija',inp('url','Javna HTTPS adresa','url')+area('note','Napomena'),'Upiši ogledalo'):''}`,'izvor'));
141 post(/^\/federacija$/,c=>action(c,()=>{if(!validOrigin(c.form.get('url')))throw Error('Porijeklo mora biti javna HTTPS adresa.');db.run('INSERT INTO mirrors(owner_id,url,city_id,note,created_at,simulated) VALUES(?,?,?,?,?,0)',c.user.id,c.form.get('url'),c.user.city_id,clean(c.form.get('note'),1000),Date.now());},'/federacija'));
142 get(/^\/povelja$/,c=>page(c,'Povelja',db.all("SELECT * FROM docs WHERE slug LIKE 'povelja%' ORDER BY version DESC").map(d=>`<h2>${t('Izdanje')} ${d.version}</h2>${render(d.body)}`).join('')+`<p>${[['/podesavanja','Izvoz i brisanje'],['/zalbe','Nezavisna žalba'],['/izbori','Ograničen mandat'],['/protokol','Otvoren protokol'],['/operater','Javan operater']].map(([h,l])=>link(h,l)).join(' · ')}</p>`,'uprava'));
143 get(/^\/akademija$/,c=>page(c,'Akademija',Array.from({length:6},(_,i)=>`<section class="panel"><h2>${t('Lekcija')} ${i+1}</h2><p>${t('academy_'+(i+1))}</p>${['sl','mk'].map(lang=>{const translated=db.get('SELECT body FROM docs WHERE slug=?','lekcija-'+(i+1)+'-'+lang);return translated?`<p lang="${lang}">${esc(translated.body)}</p>`:'';}).join('')}${c.user?db.get('SELECT 1 FROM lesson_done WHERE user_id=? AND lesson=?',c.user.id,String(i+1))?`<p>${t('Položeno')}</p>`:form(c,'/akademija/'+(i+1),LESSONS[i].questions.map(([question,choices],j)=>select('q'+j,question,[['','Izaberi'],...choices.map((label,k)=>[String(k),label])])).join(''),'Provjeri znanje'):link('/upis','Upis')}</section>`).join(''),'akademija'));
144 post(/^\/akademija\/([1-6])$/, (c,n)=>action(c,()=>{if(!LESSONS[Number(n)-1].questions.every((question,i)=>c.form.get('q'+i)===String(question[2])))throw Error('Pročitajte lekciju i pokušajte ponovo.');db.tx(()=>{if(db.run('INSERT OR IGNORE INTO lesson_done VALUES(?,?,?)',c.user.id,n,Date.now()).changes){m.reward(c.user.id,15,'lekcija',n);if(db.get("SELECT COUNT(*) n FROM lesson_done WHERE user_id=? AND lesson IN('1','2','3','4','5','6')",c.user.id).n===6)m.mint(c.user.id,'akademac','nagrada');}});},'/akademija'));
145 const tasks=c=>db.all('SELECT t.*,u.handle FROM threads t JOIN users u ON u.id=t.user_id WHERE t.deleted=0 AND t.reply_count=0 AND t.created_at<? ORDER BY t.created_at LIMIT 30',Date.now()-DAY);
146 get(/^\/akcije$/,c=>page(c,'Radne akcije',`<p>${t('Odgovorite na temu bez odgovora stariju od 24 h. Zatim prijavite završetak. Nagrada je 10 DINAR, najviše tri dnevno; deset akcija donosi Udarnika mreže.')}</p>${tasks(c).map(x=>`<section>${link('/t/'+x.id,x.title)}${c.user?form(c,'/akcije',inp('thread','Broj teme','hidden',x.id),'Završeno'):''}</section>`).join('')||t('Nema neodgovorenih tema.')}<h2>${t('Imena čekaju pregled')}</h2>${db.all("SELECT name FROM names WHERE status='pregled'").map(x=>link('/registar/ime/'+x.name,x.name)).join(' · ')}<h2>${t('Pozdrav novim članovima')}</h2>${discussionList(db.all("SELECT t.* FROM threads t JOIN users u ON u.id=t.user_id WHERE u.created_at>? AND t.deleted=0 AND NOT EXISTS(SELECT 1 FROM posts p WHERE p.thread_id=t.id AND p.user_id!=t.user_id)",Date.now()-7*DAY))}${c.user?form(c,'/akcije',inp('thread','Broj teme','number')+'<input type="hidden" name="kind" value="pozdrav">','Prijavi pozdrav'):''}<h2>${t('Prevod lekcije')}</h2>${c.user?form(c,'/akcije/prevod',select('lesson','Lekcija',Array.from({length:6},(_,i)=>[String(i+1),t('Lekcija')+' '+(i+1)]))+select('lang','Jezik',[['sl','slovenski'],['mk','makedonski']])+area('body','Prevod'),'Pošalji na pregled'):''}${table(['Lekcija','Jezik','Stanje'],Array.from({length:6},(_,i)=>['sl','mk'].map(lang=>[i+1,lang,db.get('SELECT 1 FROM docs WHERE slug=?','lekcija-'+(i+1)+'-'+lang)?t('prevedeno'):t('traži prevod')])).flat())}${c.user&&level(db,c.user)===4?db.all('SELECT * FROM work_submissions WHERE completed_at IS NULL').map(w=>`<section><p lang="${w.lang}">${esc(w.body)}</p>${form(c,'/akcije/prevod/'+w.id,'','Odobri prevod')}</section>`).join(''):''}`,'akademija'));
147 post(/^\/akcije$/,c=>action(c,()=>db.tx(()=>{const id=Number(c.form.get('thread')),thread=db.get('SELECT * FROM threads WHERE id=? AND deleted=0',id),reply=db.get('SELECT * FROM posts WHERE thread_id=? AND user_id!=? ORDER BY created_at LIMIT 1',id,thread?.user_id||0);if(!thread||!reply||reply.user_id!==c.user.id||(c.form.get('kind')==='pozdrav'?db.get('SELECT created_at FROM users WHERE id=?',thread.user_id).created_at<Date.now()-7*DAY:reply.created_at-thread.created_at<DAY))throw Error('Prvo pomozite u temi koja čeka odgovor.');if(db.get("SELECT COUNT(*) n FROM ledger WHERE user_id=? AND reason='akcija' AND created_at>=?",c.user.id,Math.floor(Date.now()/DAY)*DAY).n>=3)throw Error('Najviše tri nagrade dnevno.');m.reward(c.user.id,10,'akcija',id);if(db.get("SELECT COUNT(*) n FROM ledger WHERE user_id=? AND reason='akcija'",c.user.id).n>=10&&!db.get("SELECT 1 FROM card_instances WHERE owner_id=? AND edition='udarnik'",c.user.id))m.mint(c.user.id,'udarnik','nagrada');}),'/akcije'));
148 post(/^\/akcije\/prevod$/,c=>action(c,()=>{const lesson=Number(c.form.get('lesson')),lang=c.form.get('lang'),body=clean(c.form.get('body'),5000);if(!Number.isInteger(lesson)||lesson<1||lesson>6||!['sl','mk'].includes(lang)||body.length<40||db.get('SELECT 1 FROM docs WHERE slug=?','lekcija-'+lesson+'-'+lang))throw Error('Prevod mora dopuniti neprevedenu lekciju.');db.run('INSERT INTO work_submissions(user_id,lesson,lang,body,created_at) VALUES(?,?,?,?,?) ON CONFLICT(lesson,lang,user_id) DO UPDATE SET body=excluded.body',c.user.id,lesson,lang,body,Date.now());},'/akcije'));
149 post(/^\/akcije\/prevod\/(\d+)$/, (c,id)=>action(c,()=>db.tx(()=>{const w=db.get('SELECT * FROM work_submissions WHERE id=? AND completed_at IS NULL',id);if(!w||w.user_id===c.user.id||!db.get('SELECT 1 FROM mandates WHERE user_id=? AND ends_at>?',c.user.id,Date.now()))throw Error('Potreban je nezavisan povjerenik.');if(db.get("SELECT COUNT(*) n FROM ledger WHERE user_id=? AND reason='akcija' AND created_at>=?",w.user_id,Math.floor(Date.now()/DAY)*DAY).n>=3)throw Error('Najviše tri nagrade dnevno.');db.run('INSERT INTO docs VALUES(?,?,?,?,?)','lekcija-'+w.lesson+'-'+w.lang,t('Lekcija')+' '+w.lesson,w.body,1,Date.now());db.run('UPDATE work_submissions SET reviewer_id=?,completed_at=? WHERE id=?',c.user.id,Date.now(),id);m.reward(w.user_id,10,'akcija','prevod:'+id);}),'/akcije'));
150 get(/^\/nivoi$/,c=>page(c,'Nivoi',`<p>${t('levels_text')}</p>${c.user?form(c,'/nivoi',Array.from({length:5},(_,i)=>select('q'+i,['Da li je učešće dobrovoljno?','Da li prijavljujemo zloupotrebu?','Da li je žalba dozvoljena?','Da li čuvamo tuđu privatnost?','Da li pravila važe za operatera?'][i],[['','Izaberi'],['da','Da'],['ne','Ne']])).join(''),'Provjeri znanje'):''}`,'prava',true));
151 post(/^\/nivoi$/,c=>{if(!Array.from({length:5},(_,i)=>c.form.get('q'+i)).every(x=>x==='da'))return c.error(422,t('Pročitajte Kućni red.'));db.run("INSERT OR IGNORE INTO lesson_done VALUES(?,'pravila',?)",c.user.id,Date.now());c.redirect('/u/'+c.user.handle);});
152 const staticPages={pravila:['Kućni red','rule_text','prava'],dinar:['DINAR','dinar_text','dinar'],sigurnost:['Sigurnost','security_text','sigurnost'],protokol:['Protokol','protocol_text','izvor'],operater:['Operater','operator_text','uprava'],'operater/postupci':['Postupci','runbooks_text','sigurnost']};
153 for(const [slug,[title,key,q]]of Object.entries(staticPages))get(new RegExp('^/'+slug+'$'),c=>page(c,title,`<section id="sadrzaj"><p>${t(key)}</p></section>${slug==='dinar'?`${economyTable()}<h2>${t('Stepenice')}</h2><p>${stats().imena} ${t('imena')} · ${stats().oglasi} ${t('oglasa')} · ${stats().narudzbe} ${t('narudžbi')}</p><h2>${t('Novac')}</h2><p>${t('no_money')}</p>${c.user?table(['Vrijeme','Promjena','Razlog'],db.all('SELECT * FROM ledger WHERE user_id=? ORDER BY id DESC LIMIT 100',c.user.id).map(r=>[time(r.created_at),r.delta,t(r.reason)])):''}`:''}${slug==='operater'?`<p>${Math.max(0,Math.ceil((Date.UTC(2027,0,26)-Date.now())/DAY))} ${t('dana do isteka mandata')}</p>${link('/registar/kljucevi','Ključevi')} · ${link('/zalbe','Žalbe')} · ${link('/blagajna','Blagajna')} · ${link('/operater/postupci','Postupci')}`:''}${slug==='protokol'?`<p id="provjera"><code>${c.build}</code> · <code>${reg.key().key_id}</code></p>${link('/protokol/vektori.json','Probni vektori')} · ${link('/registar/snimak.json','Potpisani snimak')}`:''}${slug==='operater/postupci'?`<h2>${t('Vježbe')}</h2><span class="tag" translate="no">SIMULACIJA</span><p>${esc(db.get("SELECT body FROM docs WHERE slug='vjezba'")?.body||'')}</p>`:''}`,q,slug==='dinar'));
154 get(/^\/privatnost$/,c=>page(c,'Privatnost',`<p>${t('privacy_text')}</p>${table(['Tabela','Polja'],db.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'search_%'").map(x=>[`<code>${x.name}</code>`,`<code>${db.all('PRAGMA table_info('+x.name+')').map(r=>r.name).join(', ')}</code>`]))}`));
155 get(/^\/manifest\/izvornik.pdf$/,c=>c.text(200,fs.readFileSync(path.join(import.meta.dirname,'../public/manifest.pdf')),'application/pdf'));
156 get(/^\/manifest$/,c=>page(c,'Manifest',manifest(),'mreza'));
157 get(/^\/plan$/,c=>page(c,'Plan 365',`<p>${t('Dan')} ${Math.floor((Date.now()-Date.UTC(2026,9,28))/DAY)+1} ${t('od 365')}</p><p>${t('plan_text')}</p>${planTables()}`,'plan'));
158 get(/^\/mjerila$/,c=>{const s=stats();page(c,'Mjerila',table(['Mjerilo','Vrijednost'],Object.entries(s).filter(([k])=>k!=='clanovi').map(([k,v])=>[t(k),v]))+`${table(['Republika / pokrajina','Članovi'],Object.entries(JEDINICE).map(([key,name])=>[t(name),net().nodes.filter(n=>n.jed===key).reduce((sum,n)=>sum+n.n,0)]))}<h2>${t('Uslovi za puštanje')}</h2>${table(['Provjera','Stanje'],[['Pravo porijeklo je vidljivo','u demou'],['Neispravan zapis zatvara pristup','u demou'],['Nezavisan pregled klijenta','nije u demou'],['Jasan operater i pravila','u demou'],['Odgovor na zloupotrebu do 72 h',db.get("SELECT COUNT(*) n FROM disputes WHERE status='u toku' AND deadline_at<?",Date.now()).n?'rok je prekoračen':'u roku'],['Potpisana instalacija','ne primjenjuje se: nema instalacije'],['Nema otvorene kritične prijave',db.get('SELECT COUNT(*) n FROM reports WHERE resolved_at IS NULL').n?'potreban pregled':'nema otvorenih prijava']].map(row=>row.map(t)))}<p class="fine">${t('taština')}: ${s.clanovi}</p>`,'mjerila');});
159 get(/^\/teletekst$/,c=>page(c,'Teletekst',`<div class="teletekst-page"><p class="lat">P100 YU-TELETEKST</p>${db.all('SELECT id,title FROM threads WHERE deleted=0 ORDER BY last_post_at DESC LIMIT 5').map(r=>`<p><a href="/t/${r.id}">${esc(r.title)}</a></p>`).join('')}<h2>${t('P300 Karta mreže')}</h2><pre>${teleMap(network(db))}</pre></div>`,'mreza'));
160 get(/^\/en$/,c=>page(c,'EN',`<div translate="no" lang="en"><p>${t('en')}</p></div>`));
161 const root=path.resolve(import.meta.dirname,'..');const sourceFiles=()=>['server.js',...['lib','seed','public'].flatMap(dir=>fs.readdirSync(path.join(root,dir)).filter(f=>/\.(js|mjs|css)$/.test(f)).map(f=>dir+'/'+f))];
162 get(/^\/izvor$/,c=>{const file=c.query.get('datoteka'),files=sourceFiles();if(file&&!files.includes(file))return c.error(403,t('Datoteka nije javna.'));page(c,'Izvor',`<p><code>${c.build}</code></p><ul>${files.map(f=>`<li><a class="lat" href="/izvor?datoteka=${encodeURIComponent(f)}">${f}</a></li>`).join('')}</ul>${file?`<pre>${esc(fs.readFileSync(path.join(root,file),'utf8').split('\n').map((l,i)=>String(i+1).padStart(4)+' '+l).join('\n'))}</pre>`:''}`,'izvor');});
163 get(/^\/protokol\/vektori.json$/,c=>c.text(200,fs.readFileSync(path.join(root,'lib/vektori.json'),'utf8'),'application/json'));
164 get(/^\/build.json$/,c=>c.json(200,{build:c.build}));get(/^\/healthz$/,c=>c.json(200,{ok:true,build:c.build}));
165 get(/^\/robots.txt$/,c=>c.text(200,'User-agent: *\nDisallow: /podesavanja\nDisallow: /obavjestenja\nDisallow: /pratim\nDisallow: /prijava\nDisallow: /upis\nSitemap: '+SITE+'/sitemap.xml\n'));
166 get(/^\/sitemap.xml$/,c=>c.text(200,'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+PUBLIC_ROUTES.filter(p=>!['/prijava','/upis','/povratak'].includes(p)).map(p=>`<url><loc>${SITE}${p}</loc></url>`).join('')+'</urlset>','application/xml'));
167 get(/^\/llms.txt$/,c=>c.text(200,t('naslov')+'\n'+t('opis')+'\n'+t('d1')+'\n'+t('d2')+'\n'+t('d3')+'\n'+t('d4')+'\n'+SITE+'/manifest\n'+SITE+'/protokol\n'));
168 get(/^\/humans.txt$/,c=>c.text(200,t('Izdavač')+': TEJL\n'+t('jezik')+'\n'+t('Probni likovi su izmišljeni.')+'\n'));
169 get(/^\/(?:.well-known\/)?security.txt$/,c=>c.text(200,'Contact: '+SITE+'/zalbe\nExpires: 2027-11-01T00:00:00.000Z\nPreferred-Languages: sh\nCanonical: '+SITE+'/.well-known/security.txt\n'));
170 return R;
171 }
172