Changeset feced2c in Klonkt for src/routes


Ignore:
Timestamp:
07/19/2026 05:25:26 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
5190152
Parents:
33e1dbd
git-author:
Robin <roboburr@…> (07/19/2026 05:24:58 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 05:25:26 PM)
Message:

Feature: media in replies — rich replies phase 2 (klonkt-demo-c7f)

Drop, paste or pick images/audio/video in the reply editor; they upload, show
as removable chips, travel as AS2 attachments on the federated Note, and render
in the thread.

  • POST /posts/upload-reply-media (requireSiteManager): image/audio/video by extension AND mimetype, stored as-is under /media/reply-media/ (no transcode; a reply attachment is not a track), 32MB cap, returns {url, mediaType, name}.
  • Editor: paperclip button + hidden file input (the mobile path), paste-files and drag/drop handlers, busy/error chips, image thumbnails, max 4, hidden attachments JSON field. Media-only submit allowed (text no longer required when something is attached).
  • deliverReply({attachments}): re-validates server-side — own /media/ paths only (the upload route is the sole producer, remote URLs rejected), image|audio|video mimetypes, capped at 4; stored as JSON on ap_outbox (additive column). Dedup guard now includes attachments so two media-only replies to the same parent are distinct from each other but double-submits still dedup.
  • buildNote reply branch: attachment array with Image/Audio/Video types and absolute URLs. getInteractions passes media through; fedi-node renders it (img/audio/video) for visitors too, loading the stylesheet when the owner-only editor is not on the page.

3 new tests (foreign-URL and type rejection, typed absolute Note attachments,
media-only allowed, only-invalid rejected); 91 green. Browser-verified end to
end: real upload via the endpoint, paste-event -> chip with thumbnail ->
submit -> ap_outbox row with content+language+attachments -> media rendered in
the thread -> /ap/notes/<id> serves the typed absolute attachment.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    r33e1dbd rfeced2c  
    3232const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
    3333
     34// Rich replies: media dropped/pasted into the reply editor. Images, audio and
     35// video, stored as-is (no transcode; a reply attachment is not a track).
     36const REPLY_MEDIA_DIR = path.resolve(
     37  process.env.REPLY_MEDIA_PATH ||
     38  path.join(__dirname, '..', '..', 'storage', 'media', 'reply-media')
     39);
     40fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
     41const ALLOWED_REPLY_MEDIA_EXT = new Set([
     42  '.jpg', '.jpeg', '.png', '.webp', '.gif',
     43  '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
     44  '.mp4', '.webm', '.mov',
     45]);
     46const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
     47const replyMediaUpload = multer({
     48  storage: multer.diskStorage({
     49    destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
     50    filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
     51  }),
     52  limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
     53  fileFilter: (req, file, cb) => {
     54    const ext = path.extname(file.originalname).toLowerCase();
     55    if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
     56    cb(null, true);
     57  },
     58});
     59
    3460const imageStorage = multer.diskStorage({
    3561  destination: (req, file, cb) => cb(null, POST_IMAGES_DIR),
     
    90116    } catch { /* keep the still image */ }
    91117    res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
     118  });
     119});
     120
     121// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
     122// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
     123router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
     124  replyMediaUpload.single('media')(req, res, (err) => {
     125    if (err) return res.status(400).json({ error: err.message });
     126    if (!req.file) return res.status(400).json({ error: 'No file' });
     127    const mime = String(req.file.mimetype || '');
     128    if (!/^(image|audio|video)\//.test(mime)) {
     129      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
     130      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
     131    }
     132    res.json({
     133      url: '/media/reply-media/' + req.file.filename,
     134      mediaType: mime,
     135      name: String(req.file.originalname || '').slice(0, 120),
     136    });
    92137  });
    93138});
     
    714759  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
    715760  const language = (req.body.language || '').toString();
    716   if (site && uri && (text.trim() || html.trim())) {
     761  let attachments = [];
     762  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     763  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
    717764    // Resolve + deliver in the background so Send responds instantly.
    718765    ActivityPubService.resolveRemoteNote(uri)
    719       .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language }))
     766      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments }))
    720767      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
    721768  }
     
    12601307  const text = (req.body.text || '').toString();
    12611308  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
    1262   if (parent && parent.post_id === post.id && (text.trim() || html.trim())) {
     1309  let attachments = [];
     1310  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     1311  if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
    12631312    try {
    12641313      await ActivityPubService.deliverReply(site, {
    1265         postId: post.id, postSlug: post.slug, parent, text, html,
     1314        postId: post.id, postSlug: post.slug, parent, text, html, attachments,
    12661315        language: (req.body.language || '').toString(),
    12671316      });
Note: See TracChangeset for help on using the changeset viewer.