source: Klonkt/test/c2s-compose.test.js@ 7d01696

main
Last change on this file since 7d01696 was 7d01696, checked in by Robin <roboburr@…>, 6 weeks ago

Posterframes voor video, ffmpeg-optioneel

shaer-zowq, de serverhelft. Bij een video-upload trekt ffmpeg een frame op
1 seconde naar <naam>.poster.jpg, best-effort en buiten de responspad: op een
machine zonder ffmpeg gebeurt er niets en breekt er niets (de clients halen
dan zelf een frame, native). Vanaf daar reist de poster naar drie plekken:

  • poster= op de gevouwen video-tag, dus het web toont een stilstaand beeld
  • opgeslagen op de c2s_attachments-entry
  • als AS2 icon op het gefedereerde Video-attachment, dus apps en andere servers krijgen de thumbnail-URL cadeau

Inkomend geldt het spiegelbeeld: een remote video-attachment met een icon
houdt hem als poster in media_json, en de C2S-inboxlees serveert hem terug.

Changed files:
src/routes/activitypub.js

  • uploadMedia: ffmpeg-posterframe voor video, best-effort

src/services/ActivityPubService.js

  • c2sCreatePost: poster op entry en tag; buildNote: icon op het attachment; mediaFromNote/timelineAttachments: inkomende posters door

test/c2s-compose.test.js

  • de poster bereikt tag, opslag en attachment; zonder posterbestand wordt er niets gegokt

remarks: 338 tests groen. ffmpeg staat NIET op de VPS; installeren is een
sudo-actie die ik niet mag doen: sudo apt-get install -y ffmpeg. Zonder dat
werkt alles, alleen maken de clients hun thumbnails zelf.

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

  • Property mode set to 100644
File size: 5.9 KB
Line 
1// The app's composer posts over C2S. A top-level Note with media used to lose
2// it silently: c2sCreatePost read only the content, so a photo post arrived
3// naked while the very same attachments worked fine on replies and DMs.
4import { test } from 'node:test';
5import assert from 'node:assert/strict';
6
7process.env.DATABASE_PATH = ':memory:';
8process.env.PUBLIC_BASE_URL = 'https://test.example';
9
10const dbMod = await import('../src/config/database.js');
11const db = dbMod.default;
12dbMod.initializeDatabase();
13const AP = (await import('../src/services/ActivityPubService.js')).default;
14
15db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
16 .run('u1', 'robin', 'u1@t', 'x', 'god');
17db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
18const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('s1' ? 'kid' : 'kid');
19const user = db.prepare('SELECT * FROM users WHERE id = ?').get('u1');
20
21test('a C2S post carries its media: into the web content and out as AS2 attachments', async () => {
22 const r = await AP.ingestOutboxActivity(site, user, {
23 type: 'Create',
24 object: {
25 type: 'Note',
26 content: '<p>kijk dan</p>',
27 source: { content: 'kijk dan', mediaType: 'text/plain' },
28 to: ['https://test.example/ap/users/kid/followers'],
29 cc: ['https://www.w3.org/ns/activitystreams#Public'],
30 attachment: [
31 { type: 'Image', url: '/media/reply-media/foto.jpg', mediaType: 'image/jpeg', name: 'ons plein' },
32 { type: 'Audio', url: '/media/reply-media/opname.m4a', mediaType: 'audio/mp4' },
33 // Not ours: a remote URL must never be laundered into our media.
34 { type: 'Image', url: 'https://evil.test/x.jpg', mediaType: 'image/jpeg' },
35 ],
36 },
37 });
38 assert.equal(r.status, 201, 'the post is created');
39
40 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
41 assert.match(post.content, /<img src="\/media\/reply-media\/foto\.jpg" alt="ons plein">/, 'the web shows the photo');
42 assert.match(post.content, /<audio controls[^>]+src="\/media\/reply-media\/opname\.m4a">/, 'and plays the recording');
43 assert.ok(!post.content.includes('evil.test'), 'the stranger stays out');
44
45 const note = AP.buildNote('https://test.example', site, post);
46 const att = note.attachment || [];
47 const img = att.find((a) => a.url.endsWith('/media/reply-media/foto.jpg'));
48 const aud = att.find((a) => a.url.endsWith('/media/reply-media/opname.m4a'));
49 assert.ok(img && img.type === 'Image', 'the photo federates as an Image');
50 assert.equal(img.url, 'https://test.example/media/reply-media/foto.jpg', 'absolute, so any server can fetch it');
51 assert.equal(img.name, 'ons plein', 'alt text rides along');
52 assert.ok(aud, 'the recording federates too');
53 assert.equal(aud.type, 'Audio', 'as an Audio, not an Image: the stored mediaType wins over the extension map');
54 assert.equal(att.filter((a) => a.url.endsWith('foto.jpg')).length, 1, 'inline img + stored row dedupe to one');
55});
56
57test("a video's poster frame rides the tag, the store and the federated attachment", async () => {
58 // The upload leg writes <name>.poster.jpg next to a video when ffmpeg is
59 // around (shaer-zowq). From there it must reach three places: the poster=
60 // on the folded tag (web), the stored entry, and the AS2 icon on the
61 // federated attachment (apps and other servers).
62 const os = await import('os');
63 const fsm = await import('fs');
64 const pathm = await import('path');
65 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
66 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
67 fsm.writeFileSync(pathm.join(root, 'reply-media', 'film.mp4.poster.jpg'), 'x');
68 const prev = process.env.MEDIA_PATH;
69 process.env.MEDIA_PATH = root;
70 try {
71 const r = await AP.ingestOutboxActivity(site, user, {
72 type: 'Create',
73 object: {
74 type: 'Note', content: '<p>filmpje</p>',
75 to: ['https://test.example/ap/users/kid/followers'],
76 attachment: [{ type: 'Video', url: '/media/reply-media/film.mp4', mediaType: 'video/mp4' }],
77 },
78 });
79 assert.equal(r.status, 201);
80 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
81 assert.match(post.content, /poster="\/media\/reply-media\/film\.mp4\.poster\.jpg"/, 'the web tag shows the still');
82 const note = AP.buildNote('https://test.example', site, post);
83 const vid = (note.attachment || []).find((a) => a.url.endsWith('film.mp4'));
84 assert.ok(vid && vid.type === 'Video');
85 assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg',
86 'the poster federates as the attachment icon');
87 } finally {
88 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
89 }
90});
91
92test('a video without a poster simply has none: no guessed icon', async () => {
93 const r = await AP.ingestOutboxActivity(site, user, {
94 type: 'Create',
95 object: {
96 type: 'Note', content: '<p>kaal</p>',
97 to: ['https://test.example/ap/users/kid/followers'],
98 attachment: [{ type: 'Video', url: '/media/reply-media/zonder.mp4', mediaType: 'video/mp4' }],
99 },
100 });
101 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
102 assert.ok(!post.content.includes('poster='), 'no poster attr without a poster file');
103 const vid = (AP.buildNote('https://test.example', site, post).attachment || []).find((a) => a.url.endsWith('zonder.mp4'));
104 assert.equal(vid.icon, undefined);
105});
106
107test('a media-only post is a post, not an empty-note error', async () => {
108 const r = await AP.ingestOutboxActivity(site, user, {
109 type: 'Create',
110 object: {
111 type: 'Note', content: '',
112 to: ['https://test.example/ap/users/kid/followers'],
113 attachment: [{ type: 'Image', url: '/media/reply-media/alleen.jpg', mediaType: 'image/png' }],
114 },
115 });
116 assert.equal(r.status, 201, 'a picture can be the whole message');
117});
Note: See TracBrowser for help on using the repository browser.