Changeset 2086ea2 in Klonkt for src/routes


Ignore:
Timestamp:
08/12/2026 09:16:09 PM (3 months ago)
Author:
Robin <roboburr@…>
Children:
39dab6b
Parents:
5412cbb
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/ward.js

    r5412cbb r2086ea2  
    1414 */
    1515import express from 'express';
     16import multer from 'multer';
     17import fs from 'fs';
     18import { randomUUID } from 'crypto';
    1619import path from 'path';
    1720import { fileURLToPath } from 'url';
     21import { mediaDir } from '../config/paths.js';
    1822import db from '../config/database.js';
    1923import { requireAuth } from '../middleware/auth.js';
    … …  
    112116      newTitle: i18nT(L, 'ward.new_title'),
    113117      noFriends: i18nT(L, 'ward.no_friends'),
     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'),
    114129    },
    115130  };
    … …  
    361376});
    362377
     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
    363474function pwaAsset(rel, type) {
    364475  return (req, res) => {
Note: See TracChangeset for help on using the changeset viewer.