Changeset 61d52ed in Klonkt
- Timestamp:
- 10/14/2026 04:25:21 PM (5 hours ago)
- Branches:
- main
- Parents:
- 6e9bc70
- git-author:
- Bart <bart@…> (10/14/2026 04:23:29 PM)
- git-committer:
- Bart <bart@…> (10/14/2026 04:25:21 PM)
- Files:
-
- 3 edited
-
src/routes/activitypub.js (modified) (3 diffs)
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
test/thread.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r6e9bc70 r61d52ed 1411 1411 }); 1412 1412 1413 // De poorten op één vreemde note, voor de thread en voor de losse note: 1414 // beeld, muziek, video en emoji, per verzoek en buiten elke cache om. De 1415 // emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de 1416 // ingesloten actor, niet meer in een eigen emoji-kaart ernaast. 1417 function readerGates(site, isWard) { 1418 const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard); 1419 const images = gate('gate_images'), audio = gate('gate_music'), video = gate('gate_video'); 1420 const emoji = gate('gate_custom_emoji'); 1421 return (n) => ({ 1422 ...n, 1423 attachment: AP.gateAttachments(n.attachment, { images, audio, video }), 1424 tag: emoji ? n.tag : AP.stripEmojiTags(n.tag), 1425 attributedTo: (!emoji && n.attributedTo && typeof n.attributedTo === 'object') 1426 ? { ...n.attributedTo, tag: undefined } : n.attributedTo, 1427 }); 1428 } 1429 1413 1430 // ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ──── 1414 1431 // … … 1446 1463 // poorten als de tijdlijn -- per verzoek, buiten de threadcache om. 1447 1464 const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard); 1448 const gate2 = (col) => Guardianship.wardGateAllowed(auth.site[col], isWard);1449 1465 const kring = threadsOpen ? { notes: uit.notes, hidden: 0 } : AP.filterThreadToCircle(auth.site.slug, uit.notes); 1450 const imagesOk = gate2('gate_images'), musicOk = gate2('gate_music'), emojiOk = gate2('gate_custom_emoji'); 1451 const videoOk = gate2('gate_video'); 1452 uit.notes = kring.notes.map((n) => ({ 1453 ...n, 1454 attachment: AP.gateAttachments(n.attachment, { images: imagesOk, audio: musicOk, video: videoOk }), 1455 tag: emojiOk ? n.tag : AP.stripEmojiTags(n.tag), 1456 // De emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de 1457 // ingesloten actor, niet meer in een eigen emoji-kaart ernaast. 1458 attributedTo: (!emojiOk && n.attributedTo && typeof n.attributedTo === 'object') 1459 ? { ...n.attributedTo, tag: undefined } : n.attributedTo, 1460 })); 1466 uit.notes = kring.notes.map(readerGates(auth.site, isWard)); 1461 1467 uit.hidden = kring.hidden; 1462 1468 // Liked/boosted per antwoord, BUITEN de cache om: de genormaliseerde notes … … 1481 1487 }); 1482 1488 1489 // ── Eén note, voor wie een gesprek naar boven volgt ──────────────── 1490 // 1491 // De thread hierboven geeft wat onder een post hangt; dit geeft de post zelf. 1492 // Een lezer die een antwoord kreeg en de ouder niet, kan die ouder bij een 1493 // server in secure mode alleen ondertekend halen, en de sleutel staat hier. 1494 // Bearer-only en niets bewaren, net als de thread. Dezelfde weigering-of- 1495 // storing en dezelfde poorten; voor een ward met een dichte kring is een note 1496 // van buiten de kring er niet, en dat wordt gezegd in plaats van verzwegen. 1497 router.get('/ap/users/:slug/object', async (req, res) => { 1498 const auth = OAuth.verifyBearer(req.headers.authorization); 1499 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end(); 1500 const objectUri = String(req.query.uri || ''); 1501 if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'uri must be an https URI' }); 1502 const uit = await AP.getObject(auth.site.slug, objectUri); 1503 if (!uit.found) { 1504 const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus); 1505 return res.status(geweigerd ? 404 : 502) 1506 .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined }); 1507 } 1508 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })(); 1509 const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard); 1510 if (!threadsOpen && !AP.filterThreadToCircle(auth.site.slug, [uit.note]).notes.length) { 1511 return res.status(404).json({ error: 'outside the circle', 'shaer:hidden': 1 }); 1512 } 1513 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...readerGates(auth.site, isWard)(uit.note) }, 'private, no-store'); 1514 }); 1515 1483 1516 router.get('/ap/notes/:id/replies', (req, res) => { 1484 1517 const base = baseUrl(req); -
src/services/ActivityPubService.js
r6e9bc70 r61d52ed 2496 2496 2497 2497 /** 2498 * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer. 2499 * 2500 * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De 2501 * kringfilter (de dichte stand van shaer:externalThreads) woont in 2502 * filterThreadToCircle en de beeld/muziek/emoji-poorten in 2503 * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de 2504 * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde 2505 * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling: 2506 * een blokkade is onzichtbaar, ook als getal. 2498 * Eén vreemde note in de vorm die de C2S-lezer krijgt: geschoonde HTML, de 2499 * byline ingesloten in attributedTo, alleen emoji-tags. Gedeeld door de thread 2500 * (antwoorden) en getObject (één note), zodat die twee niet uit elkaar lopen. 2501 * 2502 * `parentFallback` is voor de thread: een antwoord in de replies-collectie van 2503 * X dat zelf geen inReplyTo noemt, hangt aantoonbaar onder X. Voor één losse 2504 * note is er zo'n bewijs niet, en dan blijft inReplyTo leeg. 2507 2505 */ 2508 export async function getThread(slug, objectUri) { 2509 const key = `${slug}|${objectUri}`; 2510 const hit = threadViewCache.get(key); 2511 if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out; 2512 2513 // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is 2514 // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook 2515 // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil 2516 // kunnen zeggen, anders wijst de melding naar de verkeerde partij. 2517 let sourceStatus = 0; 2518 const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; }); 2519 const note = await get(objectUri); 2520 const repliesRef = note && note.replies; 2521 let coll = null; 2522 if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef); 2523 else if (repliesRef && typeof repliesRef === 'object') { 2524 coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef 2525 : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef); 2526 } 2527 // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE 2528 // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina": 2529 // Mastodon serveert `first` als inline-pagina met LEGE items en een `next` 2530 // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet 2531 // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding 2532 // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die 2533 // gaan door de lokale kortsluiting en hebben orderedItems meteen vol. 2534 let items = []; 2535 let node = coll; 2536 if (node && node.first && !collectionItems(node).length) { 2537 node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first; 2538 } 2539 let pages = 0; 2540 while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) { 2541 items.push(...collectionItems(node)); 2542 if (!node.next) break; 2543 node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next; 2544 } 2545 items = items.slice(0, THREAD_VIEW_LIMIT); 2546 2547 // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar 2548 // afwachten is een halve minuut kijken naar een spinner. 2549 const objs = await Promise.all(items.map(async (it) => { 2550 const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it); 2551 return (o && o.id && o.attributedTo) ? o : null; 2552 })); 2553 2554 const kept = []; 2555 for (const o of objs) { 2556 if (!o) continue; 2557 const actorUri = actorUriOf(o.attributedTo); 2558 if (!actorUri || isBlockedAny(actorUri)) continue; // een blokkade telt niet mee 2559 kept.push({ o, actorUri }); 2560 } 2561 2562 // Bylines: één fetch per unieke auteur, niet één per antwoord. 2563 const authors = new Map(); 2564 await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => { 2565 authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null)); 2566 })); 2567 2568 const notes = kept.map(({ o, actorUri }) => ({ 2506 function readerNote(o, actorUri, authorDoc, parentFallback = null) { 2507 return { 2569 2508 id: o.id, 2570 2509 type: 'Note', 2571 2510 // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar 2572 2511 // elke AP-lezer hem zoekt, en niet in een eigen property ernaast. 2573 attributedTo: actorObject(actorUri, actorInfo(author s.get(actorUri), actorUri)),2574 inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,2512 attributedTo: actorObject(actorUri, actorInfo(authorDoc, actorUri)), 2513 inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || parentFallback || undefined, 2575 2514 content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)), 2576 2515 url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined, … … 2597 2536 return out.length ? out : undefined; 2598 2537 })(), 2599 })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || ''))); 2538 }; 2539 } 2540 2541 /** 2542 * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer. 2543 * 2544 * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De 2545 * kringfilter (de dichte stand van shaer:externalThreads) woont in 2546 * filterThreadToCircle en de beeld/muziek/emoji-poorten in 2547 * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de 2548 * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde 2549 * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling: 2550 * een blokkade is onzichtbaar, ook als getal. 2551 */ 2552 export async function getThread(slug, objectUri) { 2553 const key = `${slug}|${objectUri}`; 2554 const hit = threadViewCache.get(key); 2555 if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out; 2556 2557 // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is 2558 // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook 2559 // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil 2560 // kunnen zeggen, anders wijst de melding naar de verkeerde partij. 2561 let sourceStatus = 0; 2562 const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; }); 2563 const note = await get(objectUri); 2564 const repliesRef = note && note.replies; 2565 let coll = null; 2566 if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef); 2567 else if (repliesRef && typeof repliesRef === 'object') { 2568 coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef 2569 : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef); 2570 } 2571 // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE 2572 // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina": 2573 // Mastodon serveert `first` als inline-pagina met LEGE items en een `next` 2574 // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet 2575 // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding 2576 // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die 2577 // gaan door de lokale kortsluiting en hebben orderedItems meteen vol. 2578 let items = []; 2579 let node = coll; 2580 if (node && node.first && !collectionItems(node).length) { 2581 node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first; 2582 } 2583 let pages = 0; 2584 while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) { 2585 items.push(...collectionItems(node)); 2586 if (!node.next) break; 2587 node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next; 2588 } 2589 items = items.slice(0, THREAD_VIEW_LIMIT); 2590 2591 // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar 2592 // afwachten is een halve minuut kijken naar een spinner. 2593 const objs = await Promise.all(items.map(async (it) => { 2594 const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it); 2595 return (o && o.id && o.attributedTo) ? o : null; 2596 })); 2597 2598 const kept = []; 2599 for (const o of objs) { 2600 if (!o) continue; 2601 const actorUri = actorUriOf(o.attributedTo); 2602 if (!actorUri || isBlockedAny(actorUri)) continue; // een blokkade telt niet mee 2603 kept.push({ o, actorUri }); 2604 } 2605 2606 // Bylines: één fetch per unieke auteur, niet één per antwoord. 2607 const authors = new Map(); 2608 await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => { 2609 authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null)); 2610 })); 2611 2612 const notes = kept.map(({ o, actorUri }) => readerNote(o, actorUri, authors.get(actorUri), objectUri)) 2613 .sort((a, b) => String(a.published || '').localeCompare(String(b.published || ''))); 2600 2614 2601 2615 const out = { notes, found: !!note, sourceStatus }; … … 2606 2620 } 2607 2621 return out; 2622 } 2623 2624 /** 2625 * Eén note, ondertekend opgehaald, in dezelfde vorm als een thread-antwoord. 2626 * 2627 * De thread hierboven geeft alleen wat ONDER een post hangt. Wie een gesprek 2628 * naar boven wil volgen -- een antwoord kwam binnen, de ouder nooit -- kan die 2629 * ouder zonder de sleutel die hier woont niet halen bij een server in secure 2630 * mode. Niets wordt bewaard, net als bij de thread. 2631 * 2632 * inReplyTo staat er alleen als de bron hem gaf: een verzonnen ouder is erger 2633 * dan geen. Een geblokkeerde auteur maakt de note onvindbaar, met dezelfde 2634 * status als een bron die nee zegt -- een blokkade hoort onzichtbaar te zijn. 2635 */ 2636 export async function getObject(slug, objectUri) { 2637 let sourceStatus = 0; 2638 const o = localNoteObject(objectUri, slug) 2639 || await signedGetJson(slug, objectUri, (st) => { sourceStatus = st; }); 2640 if (!o || !o.id || !o.attributedTo) return { note: null, found: false, sourceStatus }; 2641 const actorUri = actorUriOf(o.attributedTo); 2642 if (!actorUri || isBlockedAny(actorUri)) return { note: null, found: false, sourceStatus: 404 }; 2643 const author = localActorObject(actorUri) || await signedGetJson(slug, actorUri).catch(() => null); 2644 return { note: readerNote(o, actorUri, author), found: true, sourceStatus }; 2608 2645 } 2609 2646 … … 4346 4383 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, 4347 4384 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker, 4348 sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,4385 sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, getObject, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio, 4349 4386 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections, 4350 4387 noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget, -
test/thread.test.js
r6e9bc70 r61d52ed 240 240 assert.equal(broken.body.error, 'source unreachable'); 241 241 }); 242 243 // ── Eén note, naar boven (de object-route) ─────────────────────────── 244 // 245 // De thread geeft alleen wat onder een post hangt. Een lezer die een antwoord 246 // kreeg en de ouder niet, vraagt de ouder hier. Wat hier stil kan breken: een 247 // ouder die verzonnen wordt (de thread-fallback die meelekt), een geblokkeerde 248 // die toch zichtbaar wordt, en een route zonder bearer-check. 249 test('getObject geeft de note zelf, met alleen de ouder die de bron noemde', async (t) => { 250 const vorige = globalThis.fetch; 251 t.after(() => { globalThis.fetch = vorige; }); 252 const KIND_REPLY = 'https://203.0.113.60/notes/antwoord'; 253 const WORTEL = 'https://203.0.113.60/notes/wortel'; 254 globalThis.fetch = async (url) => { 255 const u = String(url); 256 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } }); 257 if (u === KIND_REPLY) return json({ id: KIND_REPLY, type: 'Note', attributedTo: TANTE, inReplyTo: WORTEL, content: '<p>ja <script>x</script></p>', published: '2026-09-07T13:42:26Z' }); 258 if (u === WORTEL) return json({ id: WORTEL, type: 'Note', attributedTo: TANTE, content: '<p>begin</p>' }); 259 if (u === TANTE) return json(actorDoc(TANTE, 'tante')); 260 return new Response('not found', { status: 404 }); 261 }; 262 263 const antwoord = await AP.getObject('kind', KIND_REPLY); 264 assert.equal(antwoord.found, true); 265 assert.equal(antwoord.note.inReplyTo, WORTEL, 'de ouder die de bron gaf'); 266 assert.equal(antwoord.note.attributedTo.name, 'tante'); 267 assert.ok(!antwoord.note.content.includes('<script'), 'geschoond'); 268 269 const wortel = await AP.getObject('kind', WORTEL); 270 assert.equal(wortel.found, true); 271 assert.equal(wortel.note.inReplyTo, undefined, 'geen ouder verzonnen voor een wortel'); 272 }); 273 274 test('getObject: een geblokkeerde auteur is er niet, een weigering is een weigering', async (t) => { 275 const vorige = globalThis.fetch; 276 t.after(() => { globalThis.fetch = vorige; }); 277 const VAN_NAAR = 'https://203.0.113.40/notes/van-naar'; 278 globalThis.fetch = async (url) => (String(url) === VAN_NAAR 279 ? new Response(JSON.stringify({ id: VAN_NAAR, type: 'Note', attributedTo: GEBLOKT, content: 'x' }), { status: 200 }) 280 : new Response('nope', { status: 410 })); 281 282 const geblokt = await AP.getObject('kind', VAN_NAAR); 283 assert.equal(geblokt.found, false); 284 assert.equal(geblokt.note, null); 285 286 const weg = await AP.getObject('kind', 'https://203.0.113.61/notes/weg'); 287 assert.equal(weg.found, false); 288 assert.equal(weg.sourceStatus, 410); 289 }); 290 291 test('de object-route: alleen met de eigen bearer, en een weigering is 404', async (t) => { 292 const crypto = await import('crypto'); 293 const express = (await import('express')).default; 294 const routes = (await import('../src/routes/activitypub.js')).default; 295 296 // Een eigen site zonder guardians, zodat de poorten van de tests hierboven 297 // (de ward met de dichte emoji-poort) hier niet meetellen. 298 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s-lezer', 'lezer', 'Lezer', 'u1'); 299 const bearer = 'test-token-' + 'e'.repeat(24); 300 db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)') 301 .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'lezer', 'read write'); 302 303 const app = express(); 304 app.use(routes); 305 const server = app.listen(0); 306 t.after(() => server.close()); 307 await new Promise((r) => server.once('listening', r)); 308 309 const vorige = globalThis.fetch; 310 t.after(() => { globalThis.fetch = vorige; }); 311 const OUDER = 'https://203.0.113.62/notes/ouder'; 312 globalThis.fetch = async (url) => { 313 const u = String(url); 314 if (u === OUDER) return new Response(JSON.stringify({ id: OUDER, type: 'Note', attributedTo: VREEMDE, content: '<p>de ouder</p>', inReplyTo: BRON }), { status: 200 }); 315 if (u === VREEMDE) return new Response(JSON.stringify(actorDoc(VREEMDE, 'vreemde')), { status: 200 }); 316 return new Response('nope', { status: 404 }); 317 }; 318 const ask = async (slug, uri, auth = `Bearer ${bearer}`) => { 319 const r = await realFetch( 320 `http://127.0.0.1:${server.address().port}/ap/users/${slug}/object?uri=${encodeURIComponent(uri)}`, 321 { headers: auth ? { Authorization: auth } : {} }); 322 return { status: r.status, body: r.status === 403 ? null : await r.json() }; 323 }; 324 325 assert.equal((await ask('lezer', OUDER, null)).status, 403, 'zonder bearer niets'); 326 assert.equal((await ask('kind', OUDER)).status, 403, 'niet met de bearer van een ander'); 327 assert.equal((await ask('lezer', 'http://203.0.113.62/notes/ouder')).status, 400, 'alleen https'); 328 329 const ok = await ask('lezer', OUDER); 330 assert.equal(ok.status, 200); 331 assert.equal(ok.body.id, OUDER); 332 assert.equal(ok.body.inReplyTo, BRON); 333 assert.equal(ok.body.attributedTo.name, 'vreemde'); 334 335 const dicht = await ask('lezer', 'https://203.0.113.63/notes/dicht'); 336 assert.equal(dicht.status, 404); 337 assert.equal(dicht.body.error, 'not shared by source'); 338 });
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)