Changeset fbfd7a1 in Klonkt


Ignore:
Timestamp:
08/13/2026 10:34:36 PM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
6c4ff7e
Parents:
4101c89
Message:

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

Files:
4 added
8 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r4101c89 rfbfd7a1  
    218218  ensureColumn('sites', 'ap_aliases', 'TEXT');
    219219  ensureColumn('sites', 'moved_to', 'TEXT');   // FEP-7628 slice 2: waarheen dit account vertrok
     220  // FEP-1580: staat de ingest-routine nog open? 1 = klaar (en dat is ook de
     221  // stand van een site die nooit iets gemigreerd heeft, want er hangt niets).
     222  // Derden pollen op deze vlag, dus hij moet ook "er valt niets te wachten"
     223  // kunnen zeggen.
     224  ensureColumn('sites', 'migration_complete', 'INTEGER DEFAULT 1');
    220225
    221226  // Per-post noindex + type
     
    550555    -- de opstartkosten waren reacties maal tijdlijnrijen.
    551556    CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
     557    -- FEP-1580: de vertaaltabel van een verhuizing. Per gemigreerd object waar
     558    -- het VANDAAN kwam en welke URI het HIER kreeg. Derden lezen deze mapping en
     559    -- werken er hun eigen inReplyTo/Like-verwijzingen mee bij; zonder deze tabel
     560    -- is "de berichten krijgen nieuwe adressen" een permanent kapotte draad.
     561    --
     562    -- De spec eist omgekeerd-chronologisch op het moment dat de kopie HIER is
     563    -- aangemaakt (niet de oorspronkelijke publicatiedatum). Daarom sorteren we
     564    -- op de autoincrement-id en niet op created_at: die heeft secondeprecisie,
     565    -- en een ingest zet er tientallen per seconde in.
     566    CREATE TABLE IF NOT EXISTS ap_migration (
     567      id INTEGER PRIMARY KEY AUTOINCREMENT,
     568      slug TEXT NOT NULL,          -- onze site, de DOELkant van de verhuizing
     569      origin TEXT NOT NULL,        -- object-URI op de broninstantie
     570      target TEXT NOT NULL,        -- de URI die het object hier kreeg
     571      source_actor TEXT NOT NULL DEFAULT '',
     572      is_public INTEGER NOT NULL DEFAULT 1,   -- niet-publieke items horen niet in een publieke pagina
     573      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     574      UNIQUE(slug, origin)
     575    );
     576    CREATE INDEX IF NOT EXISTS idx_ap_migration_slug ON ap_migration(slug, id DESC);
     577    -- De Move-activities zelf, gededupliceerd. FEP-1580 wil dat deze collectie
     578    -- een op zichzelf staand bewijs vormt voor de items in ap_migration, met een
     579    -- FEP-8b32 integrity proof van de bron-actor plus een kopie van diens
     580    -- actor-document. Wij bewaren allebei die stukken al (activity_json en
     581    -- actor_json), maar Klonkt kent 8b32 nog niet: zie shaer-j1v0. Zolang dat
     582    -- open staat is deze collectie structureel goed en niet verifieerbaar.
     583    CREATE TABLE IF NOT EXISTS ap_moves (
     584      id INTEGER PRIMARY KEY AUTOINCREMENT,
     585      slug TEXT NOT NULL,
     586      move_id TEXT NOT NULL,       -- id van de Move-activity, tevens dedup-sleutel
     587      source_actor TEXT NOT NULL,
     588      target_actor TEXT NOT NULL,
     589      activity_json TEXT NOT NULL,
     590      actor_json TEXT,             -- inline kopie van het bron-actordocument
     591      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     592      UNIQUE(slug, move_id)
     593    );
    552594    CREATE TABLE IF NOT EXISTS ap_blocks (
    553595      id INTEGER PRIMARY KEY AUTOINCREMENT,
  • src/routes/activitypub.js

    r4101c89 rfbfd7a1  
    2020import OAuth from '../services/OAuthService.js';
    2121import * as Guardianship from '../services/guardianship/index.js';
     22import * as Migration from '../services/MigrationService.js';
    2223import { getPrimarySite } from '../middleware/site.js';
    2324import multer from 'multer';
     
    655656// clients read it here instead of keeping their own state. Actor-kind
    656657// blocks only (domain blocks are instance policy, not an Orbit member).
    657 router.get('/ap/users/:slug/blocked', (req, res) => {
     658//
     659// FEP-1580 zet deze deur één spleet verder open: de bronkant MOET de blokkades
     660// beschikbaar maken voor de instantie waar je NAARTOE verhuist, zodat je
     661// zichtbaarheidsvoorkeuren meeverhuizen. De doelkant haalt ze als eerste op,
     662// want ze bepalen wat de rest te zien krijgt. Geen nieuwe collectie: deze
     663// bestond al en staat al op de actor, alleen de toegang verbreedt.
     664router.get('/ap/users/:slug/blocked', async (req, res) => {
    658665  const auth = OAuth.verifyBearer(req.headers.authorization);
    659   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     666  let slug = (auth && auth.site.slug === req.params.slug) ? auth.site.slug : null;
     667  if (!slug && req.headers['signature']) {
     668    const verified = await AP.verifyRequest(req).catch(() => null);
     669    if (verified && verified.id && AP.isMoveTarget(req.params.slug, verified.id)) slug = req.params.slug;
     670  }
     671  if (!slug) return res.status(403).end();
    660672  const base = baseUrl(req);
    661   const items = AP.listBlocks(auth.site.slug)
     673  const items = AP.listBlocks(slug)
    662674    .filter((b) => b.kind === 'actor')
    663675    .map((b) => b.target);
    664676  AP.sendAP(res, {
    665677    '@context': AP.AP_CONTEXT,
    666     id: `${base}/ap/users/${auth.site.slug}/blocked`,
     678    id: `${base}/ap/users/${slug}/blocked`,
    667679    type: 'OrderedCollection',
    668680    totalItems: items.length,
    669681    orderedItems: items,
    670   });
     682  }, 'private, no-store');
    671683});
    672684
     
    10311043});
    10321044
     1045// ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
     1046//
     1047// Publiek leesbaar, want dat is het hele doel: een derde die een oude URI in
     1048// zijn database heeft leest hier wat de nieuwe is. Zonder deze collectie blijft
     1049// elke reactie op een verhuisd bericht naar een dood adres wijzen.
     1050//
     1051// Niet-publieke items komen er alleen in voor een lezer die ze mocht zien. De
     1052// spec: Moves voor objecten die niet aan as:Public gericht zijn MOGEN NIET
     1053// publiek getoond worden. Een lijst met de URIs van je fan-only posts is een
     1054// lek, ook al staat de inhoud er niet bij.
     1055router.get('/ap/users/:slug/migration', async (req, res) => {
     1056  const site = publicSite(req.params.slug);
     1057  if (!site) return res.status(404).end();
     1058  let alles = false;
     1059  const auth = OAuth.verifyBearer(req.headers.authorization);
     1060  if (auth && auth.site.slug === site.slug) alles = true;
     1061  else if (req.headers['signature']) {
     1062    const v = await AP.verifyRequest(req).catch(() => null);
     1063    // Een geverifieerde volger zat in het publiek van de fan-only posts, dus
     1064    // die mag ook weten waar ze heen zijn.
     1065    if (v && v.id && AP.outboxAudience(site.slug, { verifiedActor: v.id }) === 'friend') alles = true;
     1066  }
     1067  AP.sendAP(res, Migration.buildMigration(baseUrl(req), site, { page: paginaNr(req), alles }),
     1068    alles ? 'private, no-store' : undefined);
     1069});
     1070
     1071// De Moves die de vertaaltabel rechtvaardigen. Altijd publiek: een bewijs dat
     1072// je moet kunnen nakijken heeft niets aan een slot.
     1073//
     1074// LET OP: zonder FEP-8b32 (shaer-j1v0) staat hier geen handtekening onder. De
     1075// collectie is structureel goed en niet verifieerbaar, en een derde die de spec
     1076// streng volgt mag hem daarom weigeren. Bewust geen leeg proof-veld erbij.
     1077router.get('/ap/users/:slug/moves', (req, res) => {
     1078  const site = publicSite(req.params.slug);
     1079  if (!site) return res.status(404).end();
     1080  AP.sendAP(res, Migration.buildMoves(baseUrl(req), site));
     1081});
     1082
    10331083// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
    10341084router.get('/ap/users/:slug/featured', (req, res) => {
  • src/server.js

    r4101c89 rfbfd7a1  
    5151import guardianRoutes from './routes/guardian.js';
    5252import adminMediaRoutes from './routes/admin-media.js';
     53import adminMigrateRoutes from './routes/admin-migrate.js';
    5354import circleRoutes from './routes/circle.js';
    5455import epkRoutes from './routes/epk.js';
     
    397398}
    398399app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too)
     400app.use('/admin/migrate', adminMigrateRoutes); // posts + media naar/van een andere Klonkt
    399401app.use('/admin/sites', adminSitesRoutes);
    400402app.use('/admin/users', adminUsersRoutes);
  • src/services/ActivityPubService.js

    r4101c89 rfbfd7a1  
    391391    // separate state.
    392392    blocked: `${id}/blocked`,
     393    // FEP-1580: de vertaaltabel van een verhuizing plus de Moves die hem
     394    // rechtvaardigen. Deze twee staan er ALTIJD, ook leeg, en dat is met opzet:
     395    // de FEP wijst er apart op dat "een verhuizing zonder objecten" en "een
     396    // server die dit niet kent" anders niet uit elkaar te houden zijn.
     397    migration: `${id}/migration`,
     398    moves: `${id}/moves`,
    393399    // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
    394400    // (guardianship module owns these).
     
    18391845  if (aud === 'public') return true;
    18401846  if (aud === 'direct' || !site || !actorUri) return false;
     1847  // FEP-1580: dezelfde regel als in outboxAudience, en hier net zo hard nodig.
     1848  // De outbox geeft de LIJST vrij; zonder deze tak strandt de doelinstantie
     1849  // alsnog op elke losse Note die niet publiek is.
     1850  if (isMoveTarget(site.slug, actorUri)) return true;
    18411851  try {
    18421852    const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri);
     
    54065416 * (request-target) host date, the set verifyRequest checks.
    54075417 */
    5408 async function signedGetJson(slug, url, onStatus) {
     5418export async function signedGetJson(slug, url, onStatus) {
    54095419  try {
    54105420    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     
    59825992  if (!verifiedActor) return 'public';
    59835993  if (isBlockedAny(verifiedActor)) return 'blocked';
     5994  // FEP-1580, Source Instance: wie ondertekend vraagt namens de actor waar wij
     5995  // NAARTOE verhuisd zijn, moet behandeld worden alsof wij het zelf vragen.
     5996  // Anders kan de nieuwe instantie alleen het publieke deel ophalen en verhuist
     5997  // je fan-only geschiedenis niet mee.
     5998  if (isMoveTarget(slug, verifiedActor)) return 'friend';
    59845999  try {
    59856000    if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
     
    59876002  if (isWardGuardian(slug, verifiedActor)) return 'friend';
    59886003  return 'public';
     6004}
     6005
     6006/**
     6007 * FEP-1580, de hele autorisatie van de bronkant in één predicaat.
     6008 *
     6009 * De spec zegt: behandel een verzoek dat namens de DOEL-actor getekend is alsof
     6010 * de BRON-actor het deed, voor zichtbaarheid en toegang. Wij hangen dat aan
     6011 * `moved_to`, en dat mag omdat moveAccount() `no_backreference` weigert: het
     6012 * veld komt er alleen te staan als de doel-actor ons al in `alsoKnownAs` had.
     6013 * Dus staat er iets, dan heeft iemand met beheer op BEIDE kanten dat gewild.
     6014 * Een typefout kan hier niet binnenkomen, want die haalt de move zelf niet.
     6015 *
     6016 * Dat dit veilig is leunt op de keyId-binding in verifyRequest (shaer-xd8i):
     6017 * zonder die controle kon een actor tekenen met de sleutel van een buurman op
     6018 * dezelfde host, en dan is "wie tekende dit" te zacht om je hele geschiedenis
     6019 * aan af te geven.
     6020 */
     6021export function isMoveTarget(slug, actorUri) {
     6022  if (!slug || !actorUri) return false;
     6023  try {
     6024    const row = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
     6025    return !!(row && row.moved_to && row.moved_to === actorUri);
     6026  } catch { return false; }
    59896027}
    59906028
     
    67166754export default {
    67176755  movedLock,
     6756  // FEP-1580 bronkant. Vergeet je hem hier, dan werpt elke route die hem
     6757  // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
     6758  // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
     6759  isMoveTarget, signedGetJson,
    67186760  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
    67196761  deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
  • src/services/ArchiveImportService.js

    r4101c89 rfbfd7a1  
    2626import { MEDIA_ROOT } from '../config/paths.js';
    2727import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
     28import * as Migration from './MigrationService.js';
    2829
    2930const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
     
    255256    }
    256257
    257     schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
     258    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
    258259    rapport.posts += 1;
    259260  }
     
    360361      }
    361362    }
     363
     364    // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
     365    // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
     366    // behandeld moeten worden als objecten die van de bron zijn opgehaald.
     367    // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
     368    // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
     369    //
     370    // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
     371    // oude URI al naar het goede object en valt er niets te vertalen.
     372    if (!idsBehouden && eigenOrigin) {
     373      for (const s of schrijf) {
     374        if (s.soort !== 'post' || !s.oudeUri) continue;
     375        Migration.recordMigrated(site.slug, {
     376          origin: s.oudeUri,
     377          target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
     378          sourceActor: manifest.actor || '',
     379          isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
     380        });
     381      }
     382    }
    362383  })();
    363384
  • src/services/ap-core.js

    r4101c89 rfbfd7a1  
    6464    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
    6565    votersCount: 'toot:votersCount',
     66    // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
     67    // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
     68    // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
     69    // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
     70    // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
     71    // `migration`, want daar leest een consument op.
     72    migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
     73    moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
     74    migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
     75    migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
     76    migratedAt: 'https://w3id.org/fep/1580/migratedAt',
    6677    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
    6778    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
  • src/services/i18n.js

    r4101c89 rfbfd7a1  
    7676    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Afbeeldingen', 'admin.media_videos': "Video's", 'admin.videos_count': "video's", 'admin.videos_empty': "Nog geen video's geupload.", 'admin.videos_del_confirm': 'Deze video verwijderen?', 'admin.media_count': 'afbeeldingen', 'admin.media_unused': 'ongebruikt', 'admin.media_cleanup': 'Ongebruikt opruimen', 'admin.media_cleanup_confirm': 'Alle ongebruikte afbeeldingen verwijderen?', 'admin.media_empty': 'Nog geen afbeeldingen geüpload.', 'admin.media_copy': 'Kopieer URL', 'admin.media_del_confirm': 'Deze afbeelding verwijderen?',
    7777    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Reacties', 'admin.b_seo': '🔎 SEO',
    78     'admin.b_settings': '⚙️ Instellingen', 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
     78    'admin.b_settings': '⚙️ Instellingen', "mig.title": "Migreren", "mig.pull_title": "Ophalen bij je oude Klonkt", "mig.pull_hint": "Als je al verhuisd bent, kan deze Klonkt je berichten rechtstreeks bij de oude ophalen. Je hoeft geen code over te typen: de verhuizing zelf is de sleutel, want beide kanten hebben elkaar toen al erkend.", "mig.pull_source": "Adres van je oude account", "mig.pull_source_hint": "Ingevuld vanuit je aliassen. Klopt dit niet, pas het dan aan.", "mig.pull_btn": "Berichten ophalen", "mig.pull_done": "Opgehaald", "mig.pull_failed": "Ophalen lukte niet", "mig.r_blocks": "blokkades overgenomen", "mig.state": "{n} berichten hebben een verwijzing van hun oude adres.", "mig.state_done": "De lijst is compleet.", "mig.state_busy": "De lijst is nog niet compleet.", "mig.e_no_source": "Er is geen oud account bekend. Vul het adres hierboven in.", "mig.e_unreachable": "De oude Klonkt is niet bereikbaar.", "mig.e_not_moved_here": "Dat account is niet naar deze Klonkt verhuisd, dus het geeft ons niets.", "mig.e_no_backreference": "Dit account noemt dat oude adres niet als eigen alias. Zet het eerst bij je aliassen, anders kan niemand zien dat jij het bent.", "mig.e_no_outbox": "De oude Klonkt heeft geen berichtenlijst.", "mig.e_partial": "Halverwege gestopt. Wat binnen is blijft staan; probeer het nog eens.", "mig.e_config": "Deze Klonkt weet zijn eigen adres niet.", "mig.e_crash": "Er ging iets onverwachts mis.", "mig.e_points_at": "Dat account wijst naar:", "mig.lead": "Neem je berichten en media mee naar een andere Klonkt, of haal ze hiernaartoe. Hetzelfde archief dat het serverscript maakt, maar dan hier.", "mig.export_title": "Meenemen", "mig.export_hint": "Dit maakt een zip met je berichten, de reacties erop, en de media die erbij hoort.", "mig.c_posts": "berichten", "mig.c_replies": "reacties", "mig.c_media": "mediabestanden", "mig.c_following": "volgingen", "mig.c_size": "groot", "mig.missing": "Let op: van {n} mediaverwijzing(en) staat het bestand niet meer op schijf. Die gaan dus niet mee.", "mig.too_big": "Dit archief is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.", "mig.export_btn": "Archief downloaden (.zip)", "mig.export_none": "Er valt nog niets te exporteren.", "mig.import_title": "Hierheen halen", "mig.import_hint": "Kies een archief. Hij kijkt eerst wat er zou gebeuren en verandert nog niets.", "mig.file_label": "Archiefbestand (.zip)", "mig.overwrite_label": "Bestaande berichten overschrijven", "mig.overwrite_hint": "Standaard wordt een bericht dat er al staat overgeslagen. Overschrijven verwijdert ook een bericht met dezelfde webadres-naam maar een ander id, en dat komt niet terug.", "mig.check_btn": "Controleren", "mig.check_hint": "Dit verandert nog niets. Je krijgt eerst te zien wat er zou gebeuren.", "mig.r_dry": "Wat er zou gebeuren", "mig.r_done": "Ge\u00efmporteerd", "mig.r_would": "zouden erbij komen", "mig.r_imported": "erbij gekomen", "mig.r_skipped": "overgeslagen (staan er al)", "mig.r_overwritten": "overschreven", "mig.r_media": "mediabestanden", "mig.r_media_missing": "media ontbrak in het archief", "mig.r_new_ids": "Dit archief komt van een ander adres. De berichten krijgen daarom nieuwe adressen en gelden voor de fediverse als nieuwe berichten; reacties die elders naar de oude adressen wijzen komen niet mee.", "mig.r_confirm_hint": "Ziet het er goed uit? Kies hetzelfde bestand nog een keer om het echt te doen.", "mig.r_confirm_btn": "Nu echt importeren", "mig.follows_title": "Wie je volgt", "mig.follows_hint": "Je volglijst zit ook in het archief, en je kunt hem los downloaden en terugzetten bij Connect.", "mig.follows_btn": "Naar Connect", "admin.b_migrate": "\ud83d\udce6 Migreren", 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
    7979    'admin.t_admin': 'Beheer', 'admin.t_settings': 'Instellingen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Perskit bewerken', 'admin.t_newsletter': 'Nieuwsbrief', 'admin.t_playlists': 'Afspeellijsten', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Nieuwe site', 'admin.t_editsite': 'Bewerken: {title}', 'admin.t_stats': 'Statistieken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Gebruikers', 'admin.t_hub': 'Mijn Klonkt Hub', 'admin.t_manual': 'Handleiding', 'aset.premium_gate': '{feature} is premium — koppel Patreon in Beheer → Instellingen.',
    8080    'admin.b_makesite': '🎨 Maak je site aan', 'admin.b_circle': '🔗 Cirkel', 'admin.b_stats': '📊 Statistieken',
     
    10441044    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Images', 'admin.media_videos': 'Videos', 'admin.videos_count': 'videos', 'admin.videos_empty': 'No videos uploaded yet.', 'admin.videos_del_confirm': 'Delete this video?', 'admin.media_count': 'images', 'admin.media_unused': 'unused', 'admin.media_cleanup': 'Delete unused', 'admin.media_cleanup_confirm': 'Delete all unused images?', 'admin.media_empty': 'No images uploaded yet.', 'admin.media_copy': 'Copy URL', 'admin.media_del_confirm': 'Delete this image?',
    10451045    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Comments', 'admin.b_seo': '🔎 SEO',
    1046     'admin.b_settings': '⚙️ Settings', 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
     1046    'admin.b_settings': '⚙️ Settings', "mig.title": "Migrate", "mig.pull_title": "Fetch from your old Klonkt", "mig.pull_hint": "If you already moved, this Klonkt can fetch your posts straight from the old one. No code to type over: the move itself is the key, because both sides already recognised each other back then.", "mig.pull_source": "Address of your old account", "mig.pull_source_hint": "Filled in from your aliases. Change it if this is not right.", "mig.pull_btn": "Fetch posts", "mig.pull_done": "Fetched", "mig.pull_failed": "Fetching did not work", "mig.r_blocks": "blocks carried over", "mig.state": "{n} posts have a pointer from their old address.", "mig.state_done": "The list is complete.", "mig.state_busy": "The list is not complete yet.", "mig.e_no_source": "No old account is known. Fill in the address above.", "mig.e_unreachable": "The old Klonkt cannot be reached.", "mig.e_not_moved_here": "That account did not move to this Klonkt, so it gives us nothing.", "mig.e_no_backreference": "This account does not list that old address as its own alias. Add it to your aliases first, otherwise nobody can see it is you.", "mig.e_no_outbox": "The old Klonkt has no post list.", "mig.e_partial": "Stopped halfway. What arrived stays; try again.", "mig.e_config": "This Klonkt does not know its own address.", "mig.e_crash": "Something unexpected went wrong.", "mig.e_points_at": "That account points at:", "mig.lead": "Take your posts and media to another Klonkt, or bring them here. The same archive the server script makes, but from here.", "mig.export_title": "Take with you", "mig.export_hint": "This builds a zip with your posts, their replies, and the media that belongs to them.", "mig.c_posts": "posts", "mig.c_replies": "replies", "mig.c_media": "media files", "mig.c_following": "follows", "mig.c_size": "in size", "mig.missing": "Note: for {n} media reference(s) the file is no longer on disk. Those will not travel.", "mig.too_big": "This archive is too large for the web interface. Use scripts/export-archive.mjs on the server.", "mig.export_btn": "Download archive (.zip)", "mig.export_none": "Nothing to export yet.", "mig.import_title": "Bring here", "mig.import_hint": "Pick an archive. It first shows what would happen and changes nothing yet.", "mig.file_label": "Archive file (.zip)", "mig.overwrite_label": "Overwrite existing posts", "mig.overwrite_hint": "By default a post that already exists is skipped. Overwriting also deletes a post with the same web address but a different id, and that does not come back.", "mig.check_btn": "Check", "mig.check_hint": "This changes nothing yet. You will see what would happen first.", "mig.r_dry": "What would happen", "mig.r_done": "Imported", "mig.r_would": "would be added", "mig.r_imported": "added", "mig.r_skipped": "skipped (already here)", "mig.r_overwritten": "overwritten", "mig.r_media": "media files", "mig.r_media_missing": "media missing from the archive", "mig.r_new_ids": "This archive comes from another address. The posts get new addresses and count as new posts on the fediverse; replies elsewhere pointing at the old addresses will not follow.", "mig.r_confirm_hint": "Looks right? Pick the same file once more to actually do it.", "mig.r_confirm_btn": "Import for real", "mig.follows_title": "Who you follow", "mig.follows_hint": "Your following list is in the archive too, and you can download and restore it separately under Connect.", "mig.follows_btn": "Go to Connect", "admin.b_migrate": "\ud83d\udce6 Migrate", 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
    10471047    'admin.t_admin': 'Admin', 'admin.t_settings': 'Settings', 'admin.t_audio': 'Audio tracks', 'admin.t_epk': 'Edit press kit', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'New site', 'admin.t_editsite': 'Edit: {title}', 'admin.t_stats': 'Statistics', 'admin.t_updates': 'Updates', 'admin.t_users': 'Users', 'admin.t_hub': 'My Klonkt Hub', 'admin.t_manual': 'Manual', 'aset.premium_gate': '{feature} is a premium feature — connect Patreon in Admin → Settings.',
    10481048    'admin.b_makesite': '🎨 Create your site', 'admin.b_circle': '🔗 Circle', 'admin.b_stats': '📊 Statistics',
     
    20062006    'admin.b_media': '🎬 Medien', 'admin.t_media': 'Medien', 'admin.media_images': 'Bilder', 'admin.media_videos': 'Videos', 'admin.videos_count': 'Videos', 'admin.videos_empty': 'Noch keine Videos hochgeladen.', 'admin.videos_del_confirm': 'Dieses Video löschen?', 'admin.media_count': 'Bilder', 'admin.media_unused': 'ungenutzt', 'admin.media_cleanup': 'Ungenutzte löschen', 'admin.media_cleanup_confirm': 'Alle ungenutzten Bilder löschen?', 'admin.media_empty': 'Noch keine Bilder hochgeladen.', 'admin.media_copy': 'URL kopieren', 'admin.media_del_confirm': 'Dieses Bild löschen?',
    20072007    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Kommentare', 'admin.b_seo': '🔎 SEO',
    2008     'admin.b_settings': '⚙️ Einstellungen', 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
     2008    'admin.b_settings': '⚙️ Einstellungen', "mig.title": "Migrieren", "mig.pull_title": "Von deinem alten Klonkt holen", "mig.pull_hint": "Wenn du schon umgezogen bist, kann dieses Klonkt deine Beitr\u00e4ge direkt beim alten holen. Kein Code zum Abtippen: der Umzug selbst ist der Schl\u00fcssel, denn beide Seiten haben sich damals schon anerkannt.", "mig.pull_source": "Adresse deines alten Kontos", "mig.pull_source_hint": "Aus deinen Aliassen eingetragen. Stimmt das nicht, \u00e4ndere es.", "mig.pull_btn": "Beitr\u00e4ge holen", "mig.pull_done": "Geholt", "mig.pull_failed": "Holen hat nicht geklappt", "mig.r_blocks": "Blockierungen \u00fcbernommen", "mig.state": "{n} Beitr\u00e4ge haben einen Verweis von ihrer alten Adresse.", "mig.state_done": "Die Liste ist vollst\u00e4ndig.", "mig.state_busy": "Die Liste ist noch nicht vollst\u00e4ndig.", "mig.e_no_source": "Es ist kein altes Konto bekannt. Trage die Adresse oben ein.", "mig.e_unreachable": "Das alte Klonkt ist nicht erreichbar.", "mig.e_not_moved_here": "Dieses Konto ist nicht zu diesem Klonkt umgezogen, also gibt es uns nichts.", "mig.e_no_backreference": "Dieses Konto f\u00fchrt die alte Adresse nicht als eigenen Alias. Trage sie erst bei deinen Aliassen ein, sonst kann niemand sehen, dass du es bist.", "mig.e_no_outbox": "Das alte Klonkt hat keine Beitragsliste.", "mig.e_partial": "Auf halbem Weg gestoppt. Was da ist, bleibt; versuche es noch einmal.", "mig.e_config": "Dieses Klonkt kennt seine eigene Adresse nicht.", "mig.e_crash": "Etwas Unerwartetes ist schiefgegangen.", "mig.e_points_at": "Dieses Konto verweist auf:", "mig.lead": "Nimm deine Beitr\u00e4ge und Medien zu einem anderen Klonkt mit, oder hole sie hierher. Dasselbe Archiv wie das Server-Skript, nur von hier aus.", "mig.export_title": "Mitnehmen", "mig.export_hint": "Das erstellt ein Zip mit deinen Beitr\u00e4gen, den Antworten darauf und den zugeh\u00f6rigen Medien.", "mig.c_posts": "Beitr\u00e4ge", "mig.c_replies": "Antworten", "mig.c_media": "Mediendateien", "mig.c_following": "Folge ich", "mig.c_size": "gro\u00df", "mig.missing": "Achtung: Bei {n} Medienverweis(en) liegt die Datei nicht mehr auf der Platte. Die kommen nicht mit.", "mig.too_big": "Dieses Archiv ist zu gro\u00df f\u00fcr die Weboberfl\u00e4che. Nutze scripts/export-archive.mjs auf dem Server.", "mig.export_btn": "Archiv herunterladen (.zip)", "mig.export_none": "Noch nichts zu exportieren.", "mig.import_title": "Hierher holen", "mig.import_hint": "W\u00e4hle ein Archiv. Es zeigt zuerst, was passieren w\u00fcrde, und \u00e4ndert noch nichts.", "mig.file_label": "Archivdatei (.zip)", "mig.overwrite_label": "Vorhandene Beitr\u00e4ge \u00fcberschreiben", "mig.overwrite_hint": "Standardm\u00e4\u00dfig wird ein bereits vorhandener Beitrag \u00fcbersprungen. \u00dcberschreiben l\u00f6scht auch einen Beitrag mit derselben Webadresse aber anderer id, und der kommt nicht zur\u00fcck.", "mig.check_btn": "Pr\u00fcfen", "mig.check_hint": "Das \u00e4ndert noch nichts. Du siehst zuerst, was passieren w\u00fcrde.", "mig.r_dry": "Was passieren w\u00fcrde", "mig.r_done": "Importiert", "mig.r_would": "k\u00e4men dazu", "mig.r_imported": "dazugekommen", "mig.r_skipped": "\u00fcbersprungen (schon vorhanden)", "mig.r_overwritten": "\u00fcberschrieben", "mig.r_media": "Mediendateien", "mig.r_media_missing": "Medien fehlten im Archiv", "mig.r_new_ids": "Dieses Archiv kommt von einer anderen Adresse. Die Beitr\u00e4ge bekommen neue Adressen und gelten im Fediverse als neue Beitr\u00e4ge; Antworten anderswo auf die alten Adressen kommen nicht mit.", "mig.r_confirm_hint": "Sieht gut aus? W\u00e4hle dieselbe Datei noch einmal, um es wirklich zu tun.", "mig.r_confirm_btn": "Jetzt wirklich importieren", "mig.follows_title": "Wem du folgst", "mig.follows_hint": "Deine Folgeliste ist auch im Archiv, und du kannst sie unter Connect separat herunterladen und zur\u00fcckspielen.", "mig.follows_btn": "Zu Connect", "admin.b_migrate": "\ud83d\udce6 Migrieren", 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
    20092009    'admin.t_admin': 'Verwaltung', 'admin.t_settings': 'Einstellungen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Pressemappe bearbeiten', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Termine', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Neue Site', 'admin.t_editsite': 'Bearbeiten: {title}', 'admin.t_stats': 'Statistiken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Benutzer', 'admin.t_hub': 'Mein Klonkt Hub', 'admin.t_manual': 'Handbuch', 'aset.premium_gate': '{feature} ist eine Premium-Funktion — verbinde Patreon in Verwaltung → Einstellungen.',
    20102010    'admin.b_makesite': '🎨 Seite erstellen', 'admin.b_circle': '🔗 Kreis', 'admin.b_stats': '📊 Statistik',
  • src/views/pages/admin.ejs

    r4101c89 rfbfd7a1  
    3232      <% } %>
    3333      <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
     34      <% if (primarySite) { %><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a><% } %>
    3435      <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
    3536    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
Note: See TracChangeset for help on using the changeset viewer.