source: Klonkt/test/c2s-compose.test.js@ 6dd26e5

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

De audio-tegel opgevrolijkt: golfvorm als thumbnail en een echte titel

Een audiopost uit Shaer was op Klonkt een kale gradient met (untitled)
(Robins schermafdruk, 30-7). Twee dingen tegelijk gefixt.

De thumbnail: de upload-leg tekent bij audio nu een golfvorm met de
gebundelde ffmpeg (showwavespic, wit op transparant) naar
<naam>.poster.png, precies zoals video zijn stilstaand beeld krijgt. Die
golfvorm rijdt mee als data-poster op de audio-tag (voor de tegels), in
c2s_attachments en als AS2-icon op het gefedereerde Audio-attachment. De
tegel toont hem OVER de eigen hue-gradient, dus elke audiopost houdt
zijn kleur en krijgt zijn vorm.

De titel: C2S-posts hebben geen titel, dus elke Shaer-post toonde
(untitled). Tegels en kaarten leiden de titel nu af: eerst de eigen
woorden van de post (afgekapt op 64), anders een vriendelijk typelabel
(muzieknoot audio, pijl video), en pas als er echt niets is (untitled).
Een tegel die zijn beeld al toont forceert geen label meer.

Changed files:
src/routes/activitypub.js

  • uploadMedia: audio/* -> showwavespic-golfvorm naar .poster.png (best-effort, zelfde patroon als de videoposter)

src/services/ActivityPubService.js

  • c2sCreatePost: posterExt per type (video .jpg, audio .png); de audio-tag krijgt data-poster

src/views/partials/post-tile.ejs

  • audio-detectie + golfvorm over de gradient; titelcascade (titel -> tekst -> typelabel)

src/views/partials/post-card.ejs

  • zelfde spiegel: golfvorm als cover met eigen accent-gradient, zelfde titelcascade

src/assets/css/style.css

  • .grid-tile-wave: contain, padding, transparant op de gradient

test/c2s-compose.test.js

  • audio-golfvormketen: data-poster op de tag, poster in de store, icon op het gefedereerde attachment

remarks: bestaande audioposts kregen hun upload voor deze code en hebben
dus nog geen golfvorm-bestand; die tonen nu wel het muzieknoot-label. Een
verse audiopost laat het hele effect zien. ffmpeg-commando en beide
templates hier lokaal gesmoke-test (RGBA-png, render ok), 345 tests
groen.

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

  • Property mode set to 100644
File size: 12.4 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 // The FEDERATED content carries no media tags: they ride as attachments,
47 // and the tags' relative /media srcs are dead everywhere but our own web.
48 // Leaving them in showed every remote reader a broken player above the
49 // working one (Robins schermafdruk, 30-7).
50 assert.ok(!/<(video|audio|img)\b/i.test(note.content), 'the note content is clean of media tags');
51 const att = note.attachment || [];
52 const img = att.find((a) => a.url.endsWith('/media/reply-media/foto.jpg'));
53 const aud = att.find((a) => a.url.endsWith('/media/reply-media/opname.m4a'));
54 assert.ok(img && img.type === 'Image', 'the photo federates as an Image');
55 assert.equal(img.url, 'https://test.example/media/reply-media/foto.jpg', 'absolute, so any server can fetch it');
56 assert.equal(img.name, 'ons plein', 'alt text rides along');
57 assert.ok(aud, 'the recording federates too');
58 assert.equal(aud.type, 'Audio', 'as an Audio, not an Image: the stored mediaType wins over the extension map');
59 assert.equal(att.filter((a) => a.url.endsWith('foto.jpg')).length, 1, 'inline img + stored row dedupe to one');
60});
61
62test("a video's poster frame rides the tag, the store and the federated attachment", async () => {
63 // The upload leg writes <name>.poster.jpg next to a video when ffmpeg is
64 // around (shaer-zowq). From there it must reach three places: the poster=
65 // on the folded tag (web), the stored entry, and the AS2 icon on the
66 // federated attachment (apps and other servers).
67 const os = await import('os');
68 const fsm = await import('fs');
69 const pathm = await import('path');
70 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
71 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
72 fsm.writeFileSync(pathm.join(root, 'reply-media', 'film.mp4.poster.jpg'), 'x');
73 const prev = process.env.MEDIA_PATH;
74 process.env.MEDIA_PATH = root;
75 try {
76 const r = await AP.ingestOutboxActivity(site, user, {
77 type: 'Create',
78 object: {
79 type: 'Note', content: '<p>filmpje</p>',
80 to: ['https://test.example/ap/users/kid/followers'],
81 attachment: [{ type: 'Video', url: '/media/reply-media/film.mp4', mediaType: 'video/mp4' }],
82 },
83 });
84 assert.equal(r.status, 201);
85 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
86 assert.match(post.content, /poster="\/media\/reply-media\/film\.mp4\.poster\.jpg"/, 'the web tag shows the still');
87 const note = AP.buildNote('https://test.example', site, post);
88 const vid = (note.attachment || []).find((a) => a.url.endsWith('film.mp4'));
89 assert.ok(vid && vid.type === 'Video');
90 assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg',
91 'the poster federates as the attachment icon');
92 // NO cover (Robins besluit): a cover next to the content showed the same
93 // video twice on the post page. The tiles derive their picture from the
94 // content (post-tile/post-card), so the post model stays single-source.
95 assert.equal(post.cover_video_url, null);
96 assert.equal(post.cover_image_url, null);
97 assert.equal((note.attachment || []).filter((a) => a.url.endsWith('film.mp4')).length, 1,
98 'and the video federates exactly once');
99 } finally {
100 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
101 }
102});
103
104test("an audio note's waveform rides the tag, the store and the federated attachment", async () => {
105 // Same courtesy as the video poster (Robins vraag, 30-7: de kale
106 // audio-tegel): the upload leg draws <name>.poster.png (showwavespic).
107 // From there it must reach the data-poster on the folded tag (the tiles
108 // read it), the stored entry, and the AS2 icon on the federated Audio.
109 const os = await import('os');
110 const fsm = await import('fs');
111 const pathm = await import('path');
112 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
113 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
114 fsm.writeFileSync(pathm.join(root, 'reply-media', 'lied.m4a.poster.png'), 'x');
115 const prev = process.env.MEDIA_PATH;
116 process.env.MEDIA_PATH = root;
117 try {
118 const r = await AP.ingestOutboxActivity(site, user, {
119 type: 'Create',
120 object: {
121 type: 'Note', content: '',
122 to: ['https://test.example/ap/users/kid/followers'],
123 attachment: [{ type: 'Audio', url: '/media/reply-media/lied.m4a', mediaType: 'audio/mp4' }],
124 },
125 });
126 assert.equal(r.status, 201);
127 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
128 assert.match(post.content, /data-poster="\/media\/reply-media\/lied\.m4a\.poster\.png"/, 'the tag carries the waveform for the tiles');
129 const note = AP.buildNote('https://test.example', site, post);
130 const aud = (note.attachment || []).find((a) => a.url.endsWith('lied.m4a'));
131 assert.ok(aud && aud.type === 'Audio');
132 assert.equal(aud.icon && aud.icon.url, 'https://test.example/media/reply-media/lied.m4a.poster.png',
133 'the waveform federates as the attachment icon');
134 } finally {
135 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
136 }
137});
138
139test('a video without a poster simply has none: no guessed icon', async () => {
140 const r = await AP.ingestOutboxActivity(site, user, {
141 type: 'Create',
142 object: {
143 type: 'Note', content: '<p>kaal</p>',
144 to: ['https://test.example/ap/users/kid/followers'],
145 attachment: [{ type: 'Video', url: '/media/reply-media/zonder.mp4', mediaType: 'video/mp4' }],
146 },
147 });
148 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
149 assert.ok(!post.content.includes('poster='), 'no poster attr without a poster file');
150 const vid = (AP.buildNote('https://test.example', site, post).attachment || []).find((a) => a.url.endsWith('zonder.mp4'));
151 assert.equal(vid.icon, undefined);
152});
153
154test('the OUTBOX serves the attachment too, not only the delivered Create', async () => {
155 // Root of the broken iPhone player (Robins schermafdrukken, 30-7): the
156 // outbox SELECT did not include c2s_attachments, so a note pulled via the
157 // outbox (backfill, boiert.eu) had NO Video attachment and readers fell
158 // back to the content tag with its relative, dead src. The delivered copy
159 // was fine, which is why one device worked and the other did not. This
160 // locks the outbox contract: the same narrow SELECT, through buildNote,
161 // must carry the attachment.
162 const r = await AP.ingestOutboxActivity(site, user, {
163 type: 'Create',
164 object: {
165 type: 'Note', content: '<p>buiten</p>',
166 to: ['https://test.example/ap/users/kid/followers'],
167 cc: ['https://www.w3.org/ns/activitystreams#Public'],
168 attachment: [{ type: 'Video', url: '/media/reply-media/buiten.mp4', mediaType: 'video/mp4' }],
169 },
170 });
171 const row = db.prepare(
172 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
173 FROM posts WHERE id = ?`).get(r.id);
174 const note = AP.buildNote('https://test.example', site, row);
175 const vid = (note.attachment || []).find((a) => a.url.endsWith('buiten.mp4'));
176 assert.ok(vid, 'the outbox-shaped row still yields the Video attachment');
177 assert.equal(vid.type, 'Video');
178 assert.ok(!/<video\b/i.test(note.content), 'and the content stays clean');
179});
180
181test('C2S Delete takes an own post back, and only an own post', async () => {
182 // Long-press delete in the app (Robins verzoek, 30-7): the child changes
183 // their mind, the post goes, followers get the Tombstone.
184 const r = await AP.ingestOutboxActivity(site, user, {
185 type: 'Create',
186 object: {
187 type: 'Note', content: '<p>weg hiermee</p>',
188 to: ['https://test.example/ap/users/kid/followers'],
189 cc: ['https://www.w3.org/ns/activitystreams#Public'],
190 },
191 });
192 assert.equal(r.status, 201);
193 const del = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: r.url });
194 assert.equal(del.status, 202, 'an own note deletes');
195 assert.equal(db.prepare('SELECT COUNT(*) c FROM posts WHERE id = ?').get(r.id).c, 0, 'and the row is gone');
196
197 const unknown = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: 'https://test.example/ap/notes/bestaat-niet' });
198 assert.equal(unknown.status, 404, 'an unknown note is a clear no');
199
200 // Another account's post on this server: refused, row untouched.
201 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u2', 'ander', 'u2@t', 'x', 'user');
202 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'ander', 'ander', 'u2');
203 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at, created_at, updated_at)
204 VALUES ('p-ander', 's2', 'u2', 'n-ander', '', '<p>van een ander</p>', 'published', datetime('now'), datetime('now'), datetime('now'))`).run();
205 const foreign = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: 'https://test.example/ap/notes/p-ander' });
206 assert.equal(foreign.status, 403, "someone else's post is not yours to take back");
207 assert.equal(db.prepare('SELECT COUNT(*) c FROM posts WHERE id = ?').get('p-ander').c, 1, 'and it stays');
208});
209
210test('selfAuthor: the byline for your own outbox notes', () => {
211 // The owner's app reads its own posts from the outbox, which carried no
212 // author info: every card but your own had a header (Robins melding, 30-7).
213 const me = AP.selfAuthor('https://test.example', site);
214 assert.equal(me.name, 'kid');
215 assert.equal(me.handle, '@kid@test.example');
216});
217
218test('a media-only post is a post, not an empty-note error', async () => {
219 const r = await AP.ingestOutboxActivity(site, user, {
220 type: 'Create',
221 object: {
222 type: 'Note', content: '',
223 to: ['https://test.example/ap/users/kid/followers'],
224 attachment: [{ type: 'Image', url: '/media/reply-media/alleen.jpg', mediaType: 'image/png' }],
225 },
226 });
227 assert.equal(r.status, 201, 'a picture can be the whole message');
228 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
229 assert.equal(post.cover_image_url, null, 'no cover: the tile reads the photo from the content');
230});
Note: See TracBrowser for help on using the repository browser.