| 1 | // A reply to a friends-only post, however its address is spelled.
|
|---|
| 2 | //
|
|---|
| 3 | // The chain that broke on Shaer (Robins schermafdruk, 2-8: "Server said 502:
|
|---|
| 4 | // cannot_resolve_inReplyTo"): a friends-visibility post stores fan_only = 1,
|
|---|
| 5 | // the /ap/notes route hid every fan_only post from EVERYONE without ever
|
|---|
| 6 | // reading the Signature header, and resolveRemoteNote fetched even the
|
|---|
| 7 | // server's OWN notes over public HTTPS. So the signed resolution the reply
|
|---|
| 8 | // path performs knocked on a door that could never open, and every reply to
|
|---|
| 9 | // a friends-only post (Shaer's default!) died before delivery. Public posts
|
|---|
| 10 | // resolved fine, which made it look intermittent.
|
|---|
| 11 | //
|
|---|
| 12 | // Two fixes, two test groups. One: a note living on this server resolves
|
|---|
| 13 | // from the DB, no HTTP, in any spelling of our own host (đŠĩ.example IS
|
|---|
| 14 | // xn--zz9h.example, Barts WebFinger-les). Two: the /ap/notes route now does
|
|---|
| 15 | // authorized fetch: a verified follower earns the friends-only Note, a
|
|---|
| 16 | // stranger keeps getting the exact same 404 as before, and 'direct' is never
|
|---|
| 17 | // served over GET at all.
|
|---|
| 18 | import { test } from 'node:test';
|
|---|
| 19 | import assert from 'node:assert/strict';
|
|---|
| 20 |
|
|---|
| 21 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 22 | process.env.PUBLIC_BASE_URL = 'https://xn--zz9h.example';
|
|---|
| 23 |
|
|---|
| 24 | const dbMod = await import('../src/config/database.js');
|
|---|
| 25 | const db = dbMod.default;
|
|---|
| 26 | dbMod.initializeDatabase();
|
|---|
| 27 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 28 | const express = (await import('express')).default;
|
|---|
| 29 | const routes = (await import('../src/routes/activitypub.js')).default;
|
|---|
| 30 |
|
|---|
| 31 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 32 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 33 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
|
|---|
| 34 | .run('s1', 'kid', 'kid', 'u1');
|
|---|
| 35 | const site = db.prepare("SELECT * FROM sites WHERE slug = 'kid'").get();
|
|---|
| 36 | const user = db.prepare("SELECT * FROM users WHERE id = 'u1'").get();
|
|---|
| 37 |
|
|---|
| 38 | const insertPost = db.prepare(`INSERT INTO posts
|
|---|
| 39 | (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
|
|---|
| 40 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'),datetime('now'))`);
|
|---|
| 41 | insertPost.run('p-friends', 's1', 'n-friends', 'u1', '', '<p>alleen vrienden</p>', '', 'published', 'post', 'nl', 1, 'friends');
|
|---|
| 42 | insertPost.run('p-public', 's1', 'n-public', 'u1', '', '<p>iedereen</p>', '', 'published', 'post', 'nl', 0, 'public');
|
|---|
| 43 | insertPost.run('p-direct', 's1', 'n-direct', 'u1', '', '<p>persoonlijk</p>', '', 'published', 'post', 'nl', 1, 'direct');
|
|---|
| 44 |
|
|---|
| 45 | const app = express();
|
|---|
| 46 | app.use(routes);
|
|---|
| 47 | const server = app.listen(0);
|
|---|
| 48 | await new Promise((r) => server.once('listening', r));
|
|---|
| 49 | const port = server.address().port;
|
|---|
| 50 | test.after(() => server.close());
|
|---|
| 51 |
|
|---|
| 52 | const apGet = (path, headers = {}) =>
|
|---|
| 53 | fetch(`http://127.0.0.1:${port}${path}`, { headers: { Accept: 'application/activity+json', ...headers } });
|
|---|
| 54 |
|
|---|
| 55 | // ââ EÊn: de eigen note resolven zonder HTTP ââââââââââââââââââââââââââââââ
|
|---|
| 56 |
|
|---|
| 57 | test('a C2S reply to the own friends-only post resolves its parent locally', async () => {
|
|---|
| 58 | // There is no server behind xn--zz9h.example: if the parent resolution
|
|---|
| 59 | // still went over HTTP this would 502. It resolves from the DB instead.
|
|---|
| 60 | const r = await AP.ingestOutboxActivity(site, user, {
|
|---|
| 61 | type: 'Create',
|
|---|
| 62 | object: {
|
|---|
| 63 | type: 'Note', content: '<p>hoi</p>', source: { content: 'hoi' },
|
|---|
| 64 | inReplyTo: 'https://xn--zz9h.example/ap/notes/p-friends',
|
|---|
| 65 | to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
|
|---|
| 66 | },
|
|---|
| 67 | });
|
|---|
| 68 | assert.equal(r.status, 201, JSON.stringify(r));
|
|---|
| 69 | // And it threads under the post: findThreadTarget recognized the URL as ours.
|
|---|
| 70 | const row = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(r.id);
|
|---|
| 71 | assert.equal(row && row.post_id, 'p-friends');
|
|---|
| 72 | });
|
|---|
| 73 |
|
|---|
| 74 | test('the unicode spelling of our own host is still our own host', async () => {
|
|---|
| 75 | // Foundation, Node and every browser silently punycode a URL; a typed one
|
|---|
| 76 | // arrives verbatim. Both spellings must reach the same parent (the same
|
|---|
| 77 | // lesson WebFinger learned on 2-8, now on the reply path).
|
|---|
| 78 | const r = await AP.ingestOutboxActivity(site, user, {
|
|---|
| 79 | type: 'Create',
|
|---|
| 80 | object: {
|
|---|
| 81 | type: 'Note', content: '<p>nogmaals</p>', source: { content: 'nogmaals' },
|
|---|
| 82 | inReplyTo: 'https://\u{1FA75}.example/ap/notes/p-friends', // đŠĩ.example â xn--zz9h.example
|
|---|
| 83 | to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
|
|---|
| 84 | },
|
|---|
| 85 | });
|
|---|
| 86 | assert.equal(r.status, 201, JSON.stringify(r));
|
|---|
| 87 | const row = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(r.id);
|
|---|
| 88 | assert.equal(row && row.post_id, 'p-friends');
|
|---|
| 89 | });
|
|---|
| 90 |
|
|---|
| 91 | test('a reply to a nonexistent own note still fails, loudly', async () => {
|
|---|
| 92 | const r = await AP.ingestOutboxActivity(site, user, {
|
|---|
| 93 | type: 'Create',
|
|---|
| 94 | object: {
|
|---|
| 95 | type: 'Note', content: '<p>niks</p>', source: { content: 'niks' },
|
|---|
| 96 | inReplyTo: 'https://xn--zz9h.example/ap/notes/bestaat-niet',
|
|---|
| 97 | to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
|
|---|
| 98 | },
|
|---|
| 99 | });
|
|---|
| 100 | assert.equal(r.status, 502);
|
|---|
| 101 | assert.equal(r.error, 'cannot_resolve_inReplyTo');
|
|---|
| 102 | });
|
|---|
| 103 |
|
|---|
| 104 | // ââ Twee: de leespoort (authorized fetch) ââââââââââââââââââââââââââââââââ
|
|---|
| 105 |
|
|---|
| 106 | test('mayReadNote: the full matrix', () => {
|
|---|
| 107 | const friends = { fan_only: 1, ap_visibility: 'friends' };
|
|---|
| 108 | const direct = { fan_only: 1, ap_visibility: 'direct' };
|
|---|
| 109 | const pub = { fan_only: 0, ap_visibility: 'public' };
|
|---|
| 110 | const oma = 'https://elders.example/ap/users/oma';
|
|---|
| 111 | db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES (?,?,?)')
|
|---|
| 112 | .run('kid', oma, 'https://elders.example/inbox');
|
|---|
| 113 |
|
|---|
| 114 | assert.equal(AP.mayReadNote(site, pub, null), true, 'public needs nobody');
|
|---|
| 115 | assert.equal(AP.mayReadNote(site, friends, oma), true, 'a follower earns the friends-only note');
|
|---|
| 116 | assert.equal(AP.mayReadNote(site, friends, 'https://elders.example/ap/users/vreemde'), false, 'a stranger does not');
|
|---|
| 117 | assert.equal(AP.mayReadNote(site, friends, null), false, 'no verified actor, no note');
|
|---|
| 118 | assert.equal(AP.mayReadNote(site, direct, oma), false, 'direct is never served over GET');
|
|---|
| 119 |
|
|---|
| 120 | // A blocked actor's signed fetch earns the empty set (the standing rule),
|
|---|
| 121 | // follower row or not.
|
|---|
| 122 | db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES ('kid', ?, 'actor')").run(oma);
|
|---|
| 123 | assert.equal(AP.mayReadNote(site, friends, oma), false, 'actor block wins from the follower row');
|
|---|
| 124 | db.prepare('DELETE FROM ap_blocks').run();
|
|---|
| 125 | db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES ('kid', 'elders.example', 'domain')").run();
|
|---|
| 126 | assert.equal(AP.mayReadNote(site, friends, oma), false, 'domain block covers its actors');
|
|---|
| 127 | db.prepare('DELETE FROM ap_blocks').run();
|
|---|
| 128 | });
|
|---|
| 129 |
|
|---|
| 130 | test('the route: a stranger keeps the exact same 404, public stays public', async () => {
|
|---|
| 131 | // Unsigned: the friends-only note does not exist for you.
|
|---|
| 132 | assert.equal((await apGet('/ap/notes/p-friends')).status, 404);
|
|---|
| 133 | // A signature that cannot be verified is no signature. The keyId points at
|
|---|
| 134 | // a blocked IP so the SSRF guard refuses it instantly: same null outcome as
|
|---|
| 135 | // an unreachable host, without a DNS lookup that can hang the test.
|
|---|
| 136 | assert.equal((await apGet('/ap/notes/p-friends', {
|
|---|
| 137 | Signature: 'keyId="https://127.0.0.1:1/u/x#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="aGVsbG8="',
|
|---|
| 138 | Date: new Date().toUTCString(),
|
|---|
| 139 | })).status, 404);
|
|---|
| 140 | // Direct is addressed to people: never served over GET, signed or not.
|
|---|
| 141 | assert.equal((await apGet('/ap/notes/p-direct')).status, 404);
|
|---|
| 142 | // And the public post is untouched by the gate.
|
|---|
| 143 | const pub = await apGet('/ap/notes/p-public');
|
|---|
| 144 | assert.equal(pub.status, 200);
|
|---|
| 145 | const note = await pub.json();
|
|---|
| 146 | assert.equal(note.id, 'https://xn--zz9h.example/ap/notes/p-public');
|
|---|
| 147 | });
|
|---|