source: Klonkt/src/routes/ward.js@ 2086ea2

Last change on this file since 2086ea2 was 2086ea2, checked in by Robin <roboburr@…>, 3 months ago

Ward-PWA: de composer met media

Schrijven met foto, geluid of video, in de browser.

DEZELFDE POORT ALS DE APP, en dat is de kern. De composer bouwt precies
de AS2 Create die Shaer over C2S stuurt en geeft die aan
ingestOutboxActivity: die doet de compose-poort (par. 5), de
attachment-validatie en de zichtbaarheid. GEEN eigen posting-pad, want
dan zou de web-ward een sluiproute om de guardians heen zijn. Een test
bewaakt precies dat: een gated ward wordt ook via de browser
tegengehouden.

Media gaan eerst apart naar /ward/api/upload, in DEZELFDE bak als de
app (reply-media), zodat een foto van een app-ward en een browser-ward
niet van twee plekken federeert. De server maakt de rest af zoals altijd:
posterframe voor video, waveform voor audio. De post draagt daarna alleen
de url, dus de payload blijft klein.

Sessie-geauthenticeerd, niet met een bearer: /ward draait op je eigen
ingelogde Klonkt, dus de browser heeft geen token nodig zoals de app.

New file: (geen)
Changed files:
src/routes/ward.js

  • /api/upload: sessie-geauthenticeerd, zelfde filters/map als C2S
  • /api/post: bouwt de AS2 Create, door ingestOutboxActivity

src/views/pages/ward.ejs

  • schrijfknop, composer-dialoog met tekst, media en zichtbaarheid

src/assets/js/ward.js

  • upload-per-bestand, media-chips, plaatsen; opruiming van een dubbel geraakte doc-comment

src/assets/css/ward.css

  • composer, media-chips, tools

src/services/i18n.js

  • twaalf sleutels in nl, en, de

test/ward-pwa.test.js

  • de gate geldt ook in de browser; een vrij account plaatst met media; leeg wordt geweigerd; een vreemde kan niet uploaden

remarks: 903 tests groen. Opnemen van audio/video in de browser zelf
(MediaRecorder) is nog niet gebouwd; nu kies je een bestand. Bead volgt.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 21.7 KB
RevLine 
[303bfc4]1/**
2 * De Ward PWA (FEP-633c): de tegenhanger van /guardian, maar dan vanuit het
3 * kind gezien. Shaer doet dit als native app; dit is dezelfde ward-kant in de
4 * browser, voor wie geen app kan of wil installeren.
5 *
6 * Plak 1 is bewust HET VANGNET en niet de sociale kant: wie over mij waakt,
7 * of dat vangnet ook echt heel is, wat er bij mijn guardians ligt te wachten,
8 * en de knop om hulp te vragen. De feed en de composer heeft Klonkt al op de
9 * gewone site; die dubbelen we hier niet zomaar.
10 *
11 * Alles is gescoped op een site die de ingelogde gebruiker BEZIT, precies als
12 * bij /guardian (?site=slug kiest er een als je er meerdere hebt). Views
13 * dragen geen inline scripts (CSP): de logica staat in /assets/js/ward.js.
14 */
15import express from 'express';
[2086ea2]16import multer from 'multer';
17import fs from 'fs';
18import { randomUUID } from 'crypto';
[303bfc4]19import path from 'path';
20import { fileURLToPath } from 'url';
[2086ea2]21import { mediaDir } from '../config/paths.js';
[303bfc4]22import db from '../config/database.js';
23import { requireAuth } from '../middleware/auth.js';
24import AP from '../services/ActivityPubService.js';
25import * as Guardianship from '../services/guardianship/index.js';
26// offers en carriesGuardians komen NIET als naamruimte uit index.js (die
27// exporteert er losse namen van), dus rechtstreeks uit hun eigen module.
28import * as Offers from '../services/guardianship/offers.js';
29import { carriesGuardians } from '../services/guardianship/context.js';
30import { t as i18nT, resolveLang } from '../services/i18n.js';
[a5a6249]31import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
[303bfc4]32
33const router = express.Router();
34const __dir = path.dirname(fileURLToPath(import.meta.url));
35
36/** De handelende site: ?site=slug als je hem bezit, anders je eerste. */
37function siteForUser(req) {
38 const userId = req.session.user.id;
39 const want = String(req.query.site || req.body?.site || '').trim();
40 if (want) {
41 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
42 if (s) return s;
43 }
44 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
45}
46
47/**
48 * Alles wat het scherm in één keer nodig heeft.
49 *
50 * Bewust ZONDER netwerk: dit draait bij elke paginaweergave, en een trage
51 * guardian-server mag de eigen pagina van een kind niet ophouden. Wat een
52 * remote fetch vraagt (de §4-controle) zit in een eigen endpoint dat de app
53 * ná het tekenen ophaalt.
54 */
55function wardState(site, L) {
56 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
57 const me = AP.actorId(base, site.slug);
58 const guardianRows = Guardianship.listGuardians(site.slug);
59 const uris = guardianRows.map((g) => g.other_uri).filter(Boolean);
60 const statuses = Guardianship.availability.statusesFor(site.slug, uris, Date.now());
61 const byUri = new Map(statuses.map((s) => [s.id || s.uri, s]));
62
63 return {
64 me,
65 slug: site.slug,
66 // Wie over mij waakt. De beschikbaarheid staat erbij omdat een guardian
67 // die weg is de echte maat van je vangnet verandert (§3.6).
68 guardians: guardianRows.map((g) => {
69 const st = byUri.get(g.other_uri) || {};
70 return {
71 uri: g.other_uri,
72 handle: g.other_handle || AP.deriveHandleFor?.(g.other_uri) || g.other_uri,
73 availability: st['shaer:availability'] || st.availability || 'active',
74 awayUntil: st['shaer:awayUntil'] || st.awayUntil || null,
75 };
76 }),
77 // Aanbiedingen waar IK een partij in ben: iemand wil mijn guardian worden,
78 // of er loopt een verzoek om er een te laten gaan.
79 offers: Offers.listForParty(site.slug, me).map((o) => Offers.queueItem(o, me)),
80 // Wat ik wilde volgen en wat bij mijn guardians ligt (§5.3 uitgaand).
81 // Dit is de derde uitkomst die Shaer ook toont: niet verstuurd, niet
82 // mislukt, maar wachtend op iemand die je kent.
83 waitingFollows: Guardianship.outgoing.listForWard(site.slug).map((o) => ({
84 id: o.id,
85 target: o.target_uri,
86 handle: o.target_handle || o.target_uri,
87 status: o.status,
88 createdAt: o.created_at,
89 })),
90 strings: {
91 title: i18nT(L, 'ward.app_name'),
92 guardians: i18nT(L, 'ward.guardians'),
93 noGuardians: i18nT(L, 'ward.no_guardians'),
94 offers: i18nT(L, 'ward.offers'),
95 waiting: i18nT(L, 'ward.waiting'),
96 help: i18nT(L, 'ward.help'),
[a5a6249]97 feed: i18nT(L, 'ward.feed'),
98 feedEmpty: i18nT(L, 'ward.feed_empty'),
99 buoyHint: i18nT(L, 'ward.buoy_hint'),
100 helpWords: i18nT(L, 'ward.help_words'),
101 helpSend: i18nT(L, 'ward.help_send'),
102 helpSent: i18nT(L, 'ward.help_sent'),
103 cancel: i18nT(L, 'ward.cancel'),
[084b046]104 toSky: i18nT(L, 'ward.to_sky'),
105 toCards: i18nT(L, 'ward.to_cards'),
106 skyEmpty: i18nT(L, 'ward.sky_empty'),
[5218863]107 handHint: i18nT(L, 'ward.hand_hint'),
108 close: i18nT(L, 'ward.close'),
109 writeHere: i18nT(L, 'ward.write_here'),
110 send: i18nT(L, 'ward.send'),
111 wave: i18nT(L, 'ward.wave'),
112 waveCall: i18nT(L, 'ward.wave_call'),
113 waveThanks: i18nT(L, 'ward.wave_thanks'),
114 notNow: i18nT(L, 'ward.not_now'),
115 waveTo: i18nT(L, 'ward.wave_to'),
[5412cbb]116 newTitle: i18nT(L, 'ward.new_title'),
117 noFriends: i18nT(L, 'ward.no_friends'),
[2086ea2]118 write: i18nT(L, 'ward.write'),
119 post: i18nT(L, 'ward.post'),
120 visibility: i18nT(L, 'ward.visibility'),
121 visFriends: i18nT(L, 'ward.vis_friends'),
122 visPublic: i18nT(L, 'ward.vis_public'),
123 addPhoto: i18nT(L, 'ward.add_photo'),
124 addAudio: i18nT(L, 'ward.add_audio'),
125 addVideo: i18nT(L, 'ward.add_video'),
126 posted: i18nT(L, 'ward.posted'),
127 uploading: i18nT(L, 'ward.uploading'),
128 tooBig: i18nT(L, 'ward.too_big'),
[303bfc4]129 },
130 };
131}
132
133// ── De PWA-pagina ────────────────────────────────────────────────────────
134router.get('/', requireAuth, (req, res) => {
135 const site = siteForUser(req);
136 const L = resolveLang(req);
137 if (!site) return res.status(404).send('No site for this account.');
138 // Losse PWA-pagina, dus NIET via renderPage: de CSP-nonce moet hier zelf
139 // geïnjecteerd worden, anders blokkeert strict-dynamic ward.js en doet geen
140 // enkele knop iets (zelfde valkuil als bij /guardian).
141 res.render('pages/ward', {
142 state: wardState(site, L),
143 lang: L,
144 t: (k, v) => i18nT(L, k, v),
145 cspNonce: res.locals.cspNonce,
146 }, (err, html) => {
147 if (err) { console.error('[ward] render error', err); return res.status(500).send('Internal Server Error'); }
148 res.send(injectCspNonce(html, res.locals.cspNonce));
149 });
150});
151
152// ── JSON voor verversen ──────────────────────────────────────────────────
153router.get('/api/state', requireAuth, (req, res) => {
154 const site = siteForUser(req);
155 if (!site) return res.status(404).json({ error: 'no_site' });
156 res.json(wardState(site, resolveLang(req)));
157});
158
159/**
160 * De §4-controle: is mijn vangnet wel heel?
161 *
162 * Een "guardian" met eigen guardians is er geen (§1), en een escalatie
163 * daarheen komt nergens aan. Dat is precies wat een ward moet kunnen zien.
164 * Apart endpoint omdat het per guardian een remote fetch kost; de pagina
165 * tekent eerst en vult dit daarna aan.
166 *
167 * Drie uitkomsten, net als in de apps: in orde, misvormd, of onbereikbaar.
168 * Onbereikbaar is géén goedkeuring, dus het krijgt een eigen waarde in plaats
169 * van te ontbreken.
170 */
171router.get('/api/guardian-checks', requireAuth, async (req, res) => {
172 const site = siteForUser(req);
173 if (!site) return res.status(404).json({ error: 'no_site' });
174 const uris = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
175 const out = {};
176 await Promise.all(uris.map(async (uri) => {
177 const doc = await AP.fetchActor(uri).catch(() => null);
178 if (!doc) { out[uri] = 'unreachable'; return; }
179 out[uri] = carriesGuardians(doc) ? 'malformed' : 'sound';
180 }));
181 res.json({ checks: out });
182});
183
[a5a6249]184/**
185 * De feed: wat er binnenkomt van de mensen die je volgt.
186 *
187 * Zelfde bron en dezelfde partial als de Krant, Berichten en het
188 * guardian-dashboard (renderNoteBody), zodat een post er overal hetzelfde
189 * uitziet: opmaak, media, quote-kaart en embed. Kale content sturen zou hier
190 * een lege regel opleveren waar een foto hoort.
191 */
192router.get('/api/feed', requireAuth, (req, res) => {
193 const site = siteForUser(req);
194 if (!site) return res.status(404).json({ error: 'no_site' });
195 const L = resolveLang(req);
196 const items = AP.getTimeline(site.slug, 60, 0).map((p) => ({
197 id: p.id,
198 uri: p.object_uri || p.url,
199 author: p.author_handle || p.author_name || p.author_uri,
200 authorName: p.author_name,
201 authorIcon: p.author_icon,
202 url: p.url,
203 published: p.published || p.created_at,
204 whenText: formatDateTime(p.published || p.created_at),
205 cw: p.cw || null,
206 bodyHtml: renderNoteBody(p, L),
207 }));
208 res.json({ items });
209});
210
211/**
212 * De reddingsboei (§5.2.1): sleep hem op een post en je guardians krijgen hem.
213 *
214 * Shaer maakt hier een SCHERMAFDRUK van de kaart, met al het gedoe van buiten
215 * beeld renderen. Op de server is dat onnodig: wij weten wélke note het is, dus
216 * de hulpvraag draagt de note zelf mee. Dat is niet alleen minder werk maar ook
217 * eerlijker: een guardian ziet de echte post en niet een plaatje dat er ooit op
218 * leek.
219 *
220 * Gaat naar ALLE guardians tegelijk, als direct bericht. Geen post, nooit
221 * boostbaar. Zonder guardians is er niets om te vragen en zegt dat het ook.
222 */
223router.post('/api/help', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
224 const site = siteForUser(req);
225 if (!site) return res.status(404).json({ error: 'no_site' });
226 const guardians = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
227 if (!guardians.length) return res.status(409).json({ error: 'no_guardians' });
228
229 const noteId = String(req.body?.note || '').trim();
230 const words = String(req.body?.words || '').trim().slice(0, 500);
231 // De post waar het om gaat, als die van deze tijdlijn komt. Niet verplicht:
232 // een kind mag ook om hulp vragen zonder dat er een post bij hoort.
233 const row = noteId ? AP.getTimeline(site.slug, 200, 0).find((p) => String(p.id) === noteId) : null;
234 const link = row && (row.url || row.object_uri);
235
236 const text = [words || 'Ik heb hulp nodig.', link ? `\n\n${link}` : ''].join('');
237 const sent = await Guardianship.deliverDirectNote(site, {
238 recipients: guardians,
239 text,
240 language: site.language || 'nl',
241 helpRequest: true,
242 }).catch(() => null);
243
244 if (!sent) return res.status(502).json({ error: 'not_delivered' });
245 res.json({ ok: true, guardians: guardians.length, note: link || null });
246});
247
[084b046]248/**
249 * De gesprekken voor de hemel: iedereen met wie dit kind persoonlijk berichten
250 * heeft gewisseld, met hoe lang geleden dat voor het laatst was.
251 *
252 * De hemel toont GESPREKKEN, geen posts. Directe berichten leven in
253 * ap_mentions en niet in de tijdlijn, precies omdat een bericht aan genoemde
254 * mensen geen post is. Per tegenpartij houden we het jongste bericht: dat is
255 * wat de afstand tot het midden bepaalt.
256 */
257router.get('/api/conversations', requireAuth, (req, res) => {
258 const site = siteForUser(req);
259 if (!site) return res.status(404).json({ error: 'no_site' });
260 const now = Date.now();
261 const byUri = new Map();
262 for (const m of AP.getDirectMessages(site.slug, 200)) {
263 if (!m.actor_uri) continue;
264 const when = Date.parse(m.published || m.created_at || '') || now;
265 const seen = byUri.get(m.actor_uri);
266 if (!seen || when > seen.when) {
267 byUri.set(m.actor_uri, {
268 uri: m.actor_uri,
269 name: m.actor_name || m.actor_handle || m.actor_uri,
270 handle: m.actor_handle || m.actor_uri,
271 icon: m.actor_icon || null,
272 when,
273 });
274 }
275 }
276 const items = [...byUri.values()]
277 .sort((a, b) => b.when - a.when)
278 .map((c) => ({ ...c, ageMs: Math.max(0, now - c.when) }));
279 res.json({ items });
280});
281
[5218863]282/**
283 * Eén gesprek, beide kanten, nieuwste eerst, met een cursor voor "meer".
284 *
285 * Dezelfde bron als de C2S-lezing die Shaer gebruikt (conversationHistory):
286 * inkomend zijn de ap_mentions, uitgaand de eigen directe notes. De body komt
287 * af van de server, net als in de feed; de client hoeft alleen bubbels te
288 * tekenen en te weten welke kant elk bericht op wijst.
289 */
290router.get('/api/thread', requireAuth, (req, res) => {
291 const site = siteForUser(req);
292 if (!site) return res.status(404).json({ error: 'no_site' });
293 const other = String(req.query.with || '');
294 if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with_must_be_actor_uri' });
295 const L = resolveLang(req);
296 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
297
298 const page = AP.conversationHistory(site.slug, other, {
299 before: req.query.before ? String(req.query.before) : null,
300 limit: req.query.limit,
301 });
302 const inRefs = page.rows.filter((r) => r.direction === 'in').map((r) => r.ref);
303 const inMap = new Map(AP.messageRowsByUri(site.slug, inRefs).map((m) => [m.object_uri, m]));
304
305 const items = page.rows.map((r) => {
306 if (r.direction === 'in') {
307 const m = inMap.get(r.ref);
308 if (!m) return null;
309 return {
310 dir: 'in', id: m.object_uri,
311 whenText: formatDateTime(m.published || m.created_at),
312 bodyHtml: renderNoteBody(m, L),
313 wave: !!m.wave, help: !!m.help_request,
314 };
315 }
316 const n = AP.getOutboxNote(base, r.ref);
317 if (!n) return null;
318 return {
319 dir: 'out', id: n.id,
320 whenText: formatDateTime(n.published),
321 // Eigen notes zijn door onszelf gebouwd en gesaniteerd bij het maken.
322 bodyHtml: n.content || '',
323 wave: n['shaer:wave'] === true,
324 };
325 }).filter(Boolean);
326
327 // Openen is lezen. De markering is optellend (maximum), dus een oud toestel
328 // kan nooit iets weer ongelezen maken.
329 const nieuwsteIn = items.find((i) => i.dir === 'in');
330 if (nieuwsteIn) { try { AP.markRead(site.slug, nieuwsteIn.id); } catch { /* lezen faalt stil */ } }
331
332 res.json({ items, more: page.more, oldest: page.oldest });
333});
334
335/**
336 * Een bericht in een gesprek, of een zwaai (§5, shaer:wave).
337 *
338 * Dezelfde draad als de boei (deliverDirectNote): een direct bericht aan één
339 * persoon, nooit een post, nooit boostbaar. De wave-vlag reist mee zodat de
340 * andere kant hem als zwaai toont en niet als kale tekst.
341 */
342router.post('/api/message', requireAuth, express.json({ limit: '8kb' }), async (req, res) => {
343 const site = siteForUser(req);
344 if (!site) return res.status(404).json({ error: 'no_site' });
345 const to = String(req.body?.to || '').trim();
346 const text = String(req.body?.text || '').trim().slice(0, 2000);
347 if (!/^https?:\/\//i.test(to)) return res.status(400).json({ error: 'to_must_be_actor_uri' });
348 if (!text) return res.status(400).json({ error: 'empty' });
349
350 const sent = await Guardianship.deliverDirectNote(site, {
351 recipients: [to],
352 text,
353 language: site.language || 'nl',
354 wave: req.body?.wave === true,
355 }).catch(() => null);
356 if (!sent) return res.status(502).json({ error: 'not_delivered' });
357 res.json({ ok: true });
358});
359
[5412cbb]360/**
361 * De vrienden, voor de (+): iedereen met een volgrelatie, welke kant op dan
362 * ook. Dezelfde bron als de Connect-pagina (listConnections), want de vraag
363 * is dezelfde: wie ken je. De hemel toont alleen met wie je al SPRAK; dit is
364 * de deur naar de rest.
365 */
366router.get('/api/friends', requireAuth, (req, res) => {
367 const site = siteForUser(req);
368 if (!site) return res.status(404).json({ error: 'no_site' });
369 const items = AP.listConnections(site.slug).map((c) => ({
370 uri: c.actor_uri,
371 name: c.name || c.handle || c.actor_uri,
372 handle: c.handle || c.actor_uri,
373 icon: c.icon || null,
374 }));
375 res.json({ items });
376});
377
[2086ea2]378// Dezelfde media-map en dezelfde filters als de AP C2S-upload (/uploadMedia):
379// een ward die via de app post en een ward die via de browser post moeten in
380// dezelfde bak landen, anders federeren hun foto's van twee plekken.
381const WARD_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
382fs.mkdirSync(WARD_MEDIA_DIR, { recursive: true });
383const WARD_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
384const wardUpload = multer({
385 storage: multer.diskStorage({
386 destination: (req, file, cb) => cb(null, WARD_MEDIA_DIR),
387 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
388 }),
389 limits: { fileSize: 32 * 1024 * 1024 },
390 fileFilter: (req, file, cb) => {
391 const ext = path.extname(file.originalname || '').toLowerCase();
392 if (!WARD_MEDIA_EXT.has(ext)) return cb(new Error('media_type'));
393 cb(null, true);
394 },
395});
396
397/**
398 * Media uploaden voor de composer. Sessie-geauthenticeerd, want /ward draait
399 * op je eigen ingelogde Klonkt; de AP-upload eromheen eist een bearer die de
400 * browser niet heeft. Geeft { url, mediaType, name } terug, precies de vorm
401 * die een note als attachment draagt.
402 */
403router.post('/api/upload', requireAuth, (req, res) => {
404 const site = siteForUser(req);
405 if (!site) return res.status(404).json({ error: 'no_site' });
406 wardUpload.single('file')(req, res, (err) => {
407 if (err) return res.status(400).json({ error: err.message === 'media_type' ? 'media_type' : 'upload_failed' });
408 if (!req.file) return res.status(400).json({ error: 'no_file' });
409 const mime = String(req.file.mimetype || '');
410 if (!/^(image|audio|video)\//.test(mime)) {
411 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
412 return res.status(400).json({ error: 'media_type' });
413 }
414 res.status(201).json({
415 url: '/media/reply-media/' + req.file.filename,
416 mediaType: mime,
417 name: String(req.file.originalname || '').slice(0, 120),
418 });
419 });
420});
421
422/**
423 * Een post maken. Bouwt precies de AS2 Create die Shaer over C2S stuurt en
424 * geeft hem aan ingestOutboxActivity: die doet de compose-poort (§5), de
425 * attachment-validatie en de zichtbaarheid. GEEN eigen posting-pad, want dan
426 * zou de web-ward langs een gate kunnen die de app-ward tegenhoudt.
427 *
428 * De zichtbaarheid volgt Shaers eigen keuzes: 'public' luidop, 'friends'
429 * alleen naar volgers, 'direct' naar genoemde mensen. Standaard 'friends',
430 * want een besloten post is de veiligere default voor een kind.
431 */
432router.post('/api/post', requireAuth, express.json({ limit: '16kb' }), async (req, res) => {
433 const site = siteForUser(req);
434 if (!site) return res.status(404).json({ error: 'no_site' });
435 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
436 const me = AP.actorId(base, site.slug);
437
438 const text = String(req.body?.text || '').trim().slice(0, 5000);
439 const media = Array.isArray(req.body?.media) ? req.body.media.slice(0, 4) : [];
440 if (!text && !media.length) return res.status(400).json({ error: 'empty' });
441
442 const vis = ['public', 'friends'].includes(req.body?.visibility) ? req.body.visibility : 'friends';
443 const followers = `${me}/followers`;
444 // Publiek luidop = Public in to; vrienden = alleen volgers, geen Public.
445 const to = vis === 'public' ? ['https://www.w3.org/ns/activitystreams#Public'] : [followers];
446
447 const attachment = media
448 .filter((m) => m && typeof m.url === 'string' && /^\/media\//.test(m.url) && /^(image|audio|video)\//.test(String(m.mediaType || '')))
449 .map((m) => ({
450 type: m.mediaType.startsWith('image/') ? 'Image' : m.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
451 mediaType: m.mediaType,
452 url: m.url,
453 name: String(m.name || '').slice(0, 120),
454 }));
455
456 const escaped = text.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
457 const content = escaped ? `<p>${escaped.replace(/\n/g, '<br>')}</p>` : '';
458
459 const r = await AP.ingestOutboxActivity(site, req.session.user, {
460 type: 'Create',
461 object: {
462 type: 'Note',
463 content,
464 source: { content: text, mediaType: 'text/plain' },
465 to, cc: [],
466 attachment: attachment.length ? attachment : undefined,
467 },
468 }).catch((e) => ({ status: 500, error: String(e && e.message || e) }));
469
470 if (!r || r.status >= 400) return res.status(r?.status || 502).json({ error: r?.error || 'post_failed' });
471 res.status(201).json({ ok: true, url: r.url || null });
472});
473
[303bfc4]474function pwaAsset(rel, type) {
475 return (req, res) => {
476 res.set('Cache-Control', 'no-cache');
477 res.type(type);
478 res.sendFile(path.join(__dir, '..', 'assets', rel));
479 };
480}
481router.get('/app.js', pwaAsset('js/ward.js', 'application/javascript'));
[084b046]482// De hemel is een eigen module, zodat model en renderer los staan van de rest.
483router.get('/sky.js', pwaAsset('js/ward-sky.js', 'application/javascript'));
[303bfc4]484router.get('/app.css', pwaAsset('css/ward.css', 'text/css'));
485
486router.get('/manifest.webmanifest', (req, res) => {
487 const site = res.locals.site;
488 res.set('Cache-Control', 'no-cache');
489 res.json({
490 id: `klonkt-ward-${site?.slug || 'ward'}`,
491 name: 'Klonkt Ward',
492 short_name: 'Ward',
493 description: 'Wie over je waakt, en wat er op hen wacht.',
494 scope: '/ward/',
495 start_url: '/ward?source=pwa',
496 display: 'standalone',
497 display_override: ['standalone', 'minimal-ui'],
498 orientation: 'any',
499 background_color: '#141a24',
500 theme_color: '#5a32e6',
501 lang: site?.language || 'nl',
502 icons: [
503 { src: '/ward/icon.svg', sizes: 'any', type: 'image/svg+xml' },
504 ],
505 });
506});
507
508// Het schild uit de guardianship-taal, in het ward-accent.
509router.get('/icon.svg', (req, res) => {
510 const svg = `<?xml version="1.0" encoding="UTF-8"?>
511<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
512 <rect width="64" height="64" rx="14" fill="#141a24"/>
513 <path d="M32 12l16 6v14c0 10-6.6 16.6-16 20-9.4-3.4-16-10-16-20V18z" fill="#5a32e6"/>
514 <path d="M24 32l6 6 12-12" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
515</svg>`;
516 res.set('Cache-Control', 'public, max-age=86400');
517 res.type('image/svg+xml').send(svg);
518});
519
520export default router;
Note: See TracBrowser for help on using the repository browser.