Changeset 67f7150 in Klonkt


Ignore:
Timestamp:
07/30/2026 11:26:35 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
6dd26e5
Parents:
a9da2c0
Message:

C2S Delete voor eigen notes en shaer:author op de eigen outbox

Twee dingen uit de app (Robins verzoek, 30-7). Een: long-press delete op
een eigen post. De C2S outbox accepteert nu Delete voor notes van dit
account: eigen posts (Tombstone naar volgers, dan cascade zoals de
web-route) en eigen outbound replies (via deliverOutboxDelete). Andermans
posts en onbekende notes krijgen een helder 403/404.

Twee: eigen posts hadden in de app geen header. De timeline-entries
dragen shaer:author, maar de eigen outbox-leg niet, dus elke kaart had
een byline behalve je eigen. De outbox zet nu op de friend-leg de eigen
display-info (titel, handle, avatar) als shaer:author op elke note.

Changed files:
src/services/ActivityPubService.js

  • case 'Delete' in ingestOutboxActivity: eigen post -> deliverDelete + cascade (comments, FTS, post); eigen outbox-reply -> tombstone; vreemd -> 403/404
  • selfAuthor(base, site): eigen byline in shaer:author-vorm

src/routes/activitypub.js

  • outbox friend-leg verrijkt elke note met shaer:author

test/c2s-compose.test.js

  • delete-test (eigen wel, andermans niet, onbekend 404) en selfAuthor-test

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

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    ra9da2c0 r67f7150  
    110110     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
    111111  ).all(site.id);
    112   AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), audience === 'friend' ? 'private, no-store' : undefined);
     112  const ob = AP.buildOutbox(baseUrl(req), site, posts);
     113  if (audience === 'friend') {
     114    // The owner's app builds its feed from this leg, and every note here is
     115    // by the site itself: give it the same `shaer:author` byline the timeline
     116    // entries carry, so your own cards get a header too (avatar + name).
     117    const me = AP.selfAuthor(baseUrl(req), site);
     118    for (const it of ob.orderedItems) {
     119      if (it && it.object && typeof it.object === 'object') it.object['shaer:author'] = me;
     120    }
     121  }
     122  AP.sendAP(res, ob, audience === 'friend' ? 'private, no-store' : undefined);
    113123});
    114124
  • src/services/ActivityPubService.js

    ra9da2c0 r67f7150  
    760760  if (user) out.preferredUsername = user;
    761761  if (d.icon) out.icon = { type: 'Image', url: d.icon };
     762  return out;
     763}
     764
     765// The site's OWN display info in the same shape as `shaer:author` on timeline
     766// entries. The owner's app reads its own posts from the outbox, which carried
     767// no author info, so every card but your own had a byline (Robins melding,
     768// 30-7: geen header van self op eigen posts).
     769export function selfAuthor(base, site) {
     770  const out = {
     771    name: site.title || site.slug,
     772    handle: `@${site.slug}@${String(base).replace(/^https?:\/\//, '')}`,
     773    url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
     774  };
     775  if (site.profile_photo) {
     776    out.icon = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
     777  }
    762778  return out;
    763779}
     
    23742390        return { status: 400, error: 'unsupported_undo' };
    23752391      }
    2376       // Delete/Update of arbitrary objects need the post-edit pipeline; tracked
     2392      // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
     2393      // app). Scope stays narrow: this account's posts and outbound replies,
     2394      // nothing else. The web delete route is the model: Tombstone to the
     2395      // followers first, then the cascade, so nobody keeps a live copy of a
     2396      // post the child took back.
     2397      case 'Delete': {
     2398        const targetUri = c2sIdOf(object);
     2399        if (!targetUri) return { status: 400, error: 'missing_object' };
     2400        const pid = postIdFromNoteUrl(targetUri, base);
     2401        if (pid) {
     2402          const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
     2403          if (post) {
     2404            if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
     2405            if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
     2406            db.transaction(() => {
     2407              db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
     2408              try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
     2409              db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
     2410            })();
     2411            return { status: 202, url: targetUri };
     2412          }
     2413          // Same /ap/notes/ namespace: one of our outbound replies/messages.
     2414          // deliverOutboxDelete checks the site itself and tombstones too.
     2415          if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
     2416        }
     2417        return { status: 404, error: 'not_your_note' };
     2418      }
     2419      // Update of arbitrary objects needs the post-edit pipeline; tracked
    23772420      // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
    23782421      default:
     
    41144157  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    41154158  noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
    4116   getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
     4159  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor,
    41174160};
  • test/c2s-compose.test.js

    ra9da2c0 r67f7150  
    144144});
    145145
     146test('C2S Delete takes an own post back, and only an own post', async () => {
     147  // Long-press delete in the app (Robins verzoek, 30-7): the child changes
     148  // their mind, the post goes, followers get the Tombstone.
     149  const r = await AP.ingestOutboxActivity(site, user, {
     150    type: 'Create',
     151    object: {
     152      type: 'Note', content: '<p>weg hiermee</p>',
     153      to: ['https://test.example/ap/users/kid/followers'],
     154      cc: ['https://www.w3.org/ns/activitystreams#Public'],
     155    },
     156  });
     157  assert.equal(r.status, 201);
     158  const del = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: r.url });
     159  assert.equal(del.status, 202, 'an own note deletes');
     160  assert.equal(db.prepare('SELECT COUNT(*) c FROM posts WHERE id = ?').get(r.id).c, 0, 'and the row is gone');
     161
     162  const unknown = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: 'https://test.example/ap/notes/bestaat-niet' });
     163  assert.equal(unknown.status, 404, 'an unknown note is a clear no');
     164
     165  // Another account's post on this server: refused, row untouched.
     166  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u2', 'ander', 'u2@t', 'x', 'user');
     167  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'ander', 'ander', 'u2');
     168  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at, created_at, updated_at)
     169              VALUES ('p-ander', 's2', 'u2', 'n-ander', '', '<p>van een ander</p>', 'published', datetime('now'), datetime('now'), datetime('now'))`).run();
     170  const foreign = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: 'https://test.example/ap/notes/p-ander' });
     171  assert.equal(foreign.status, 403, "someone else's post is not yours to take back");
     172  assert.equal(db.prepare('SELECT COUNT(*) c FROM posts WHERE id = ?').get('p-ander').c, 1, 'and it stays');
     173});
     174
     175test('selfAuthor: the byline for your own outbox notes', () => {
     176  // The owner's app reads its own posts from the outbox, which carried no
     177  // author info: every card but your own had a header (Robins melding, 30-7).
     178  const me = AP.selfAuthor('https://test.example', site);
     179  assert.equal(me.name, 'kid');
     180  assert.equal(me.handle, '@kid@test.example');
     181});
     182
    146183test('a media-only post is a post, not an empty-note error', async () => {
    147184  const r = await AP.ingestOutboxActivity(site, user, {
Note: See TracChangeset for help on using the changeset viewer.