Changeset fbfd7a1 in Klonkt
- Timestamp:
- 08/13/2026 10:34:36 PM (4 weeks ago)
- Branches:
- main
- Children:
- 6c4ff7e
- Parents:
- 4101c89
- Files:
-
- 4 added
- 8 edited
-
src/config/database.js (modified) (2 diffs)
-
src/routes/activitypub.js (modified) (3 diffs)
-
src/routes/admin-migrate.js (added)
-
src/server.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
src/services/ArchiveImportService.js (modified) (3 diffs)
-
src/services/MigrationService.js (added)
-
src/services/ap-core.js (modified) (1 diff)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/admin-migrate.ejs (added)
-
src/views/pages/admin.ejs (modified) (1 diff)
-
test/fep1580-migration.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r4101c89 rfbfd7a1 218 218 ensureColumn('sites', 'ap_aliases', 'TEXT'); 219 219 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'); 220 225 221 226 // Per-post noindex + type … … 550 555 -- de opstartkosten waren reacties maal tijdlijnrijen. 551 556 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 ); 552 594 CREATE TABLE IF NOT EXISTS ap_blocks ( 553 595 id INTEGER PRIMARY KEY AUTOINCREMENT, -
src/routes/activitypub.js
r4101c89 rfbfd7a1 20 20 import OAuth from '../services/OAuthService.js'; 21 21 import * as Guardianship from '../services/guardianship/index.js'; 22 import * as Migration from '../services/MigrationService.js'; 22 23 import { getPrimarySite } from '../middleware/site.js'; 23 24 import multer from 'multer'; … … 655 656 // clients read it here instead of keeping their own state. Actor-kind 656 657 // 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. 664 router.get('/ap/users/:slug/blocked', async (req, res) => { 658 665 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(); 660 672 const base = baseUrl(req); 661 const items = AP.listBlocks( auth.site.slug)673 const items = AP.listBlocks(slug) 662 674 .filter((b) => b.kind === 'actor') 663 675 .map((b) => b.target); 664 676 AP.sendAP(res, { 665 677 '@context': AP.AP_CONTEXT, 666 id: `${base}/ap/users/${ auth.site.slug}/blocked`,678 id: `${base}/ap/users/${slug}/blocked`, 667 679 type: 'OrderedCollection', 668 680 totalItems: items.length, 669 681 orderedItems: items, 670 } );682 }, 'private, no-store'); 671 683 }); 672 684 … … 1031 1043 }); 1032 1044 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. 1055 router.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. 1077 router.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 1033 1083 // ── Featured (pinned posts → Mastodon "Featured" tab) ───────────── 1034 1084 router.get('/ap/users/:slug/featured', (req, res) => { -
src/server.js
r4101c89 rfbfd7a1 51 51 import guardianRoutes from './routes/guardian.js'; 52 52 import adminMediaRoutes from './routes/admin-media.js'; 53 import adminMigrateRoutes from './routes/admin-migrate.js'; 53 54 import circleRoutes from './routes/circle.js'; 54 55 import epkRoutes from './routes/epk.js'; … … 397 398 } 398 399 app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too) 400 app.use('/admin/migrate', adminMigrateRoutes); // posts + media naar/van een andere Klonkt 399 401 app.use('/admin/sites', adminSitesRoutes); 400 402 app.use('/admin/users', adminUsersRoutes); -
src/services/ActivityPubService.js
r4101c89 rfbfd7a1 391 391 // separate state. 392 392 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`, 393 399 // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues 394 400 // (guardianship module owns these). … … 1839 1845 if (aud === 'public') return true; 1840 1846 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; 1841 1851 try { 1842 1852 const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri); … … 5406 5416 * (request-target) host date, the set verifyRequest checks. 5407 5417 */ 5408 async function signedGetJson(slug, url, onStatus) {5418 export async function signedGetJson(slug, url, onStatus) { 5409 5419 try { 5410 5420 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); … … 5982 5992 if (!verifiedActor) return 'public'; 5983 5993 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'; 5984 5999 try { 5985 6000 if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend'; … … 5987 6002 if (isWardGuardian(slug, verifiedActor)) return 'friend'; 5988 6003 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 */ 6021 export 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; } 5989 6027 } 5990 6028 … … 6716 6754 export default { 6717 6755 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, 6718 6760 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection, 6719 6761 deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE, -
src/services/ArchiveImportService.js
r4101c89 rfbfd7a1 26 26 import { MEDIA_ROOT } from '../config/paths.js'; 27 27 import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js'; 28 import * as Migration from './MigrationService.js'; 28 29 29 30 const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); … … 255 256 } 256 257 257 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });258 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null }); 258 259 rapport.posts += 1; 259 260 } … … 360 361 } 361 362 } 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 } 362 383 })(); 363 384 -
src/services/ap-core.js
r4101c89 rfbfd7a1 64 64 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount). 65 65 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', 66 77 // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet 67 78 // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en -
src/services/i18n.js
r4101c89 rfbfd7a1 76 76 '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?', 77 77 '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', 79 79 '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.', 80 80 'admin.b_makesite': '🎨 Maak je site aan', 'admin.b_circle': '🔗 Cirkel', 'admin.b_stats': '📊 Statistieken', … … 1044 1044 '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?', 1045 1045 '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', 1047 1047 '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.', 1048 1048 'admin.b_makesite': '🎨 Create your site', 'admin.b_circle': '🔗 Circle', 'admin.b_stats': '📊 Statistics', … … 2006 2006 '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?', 2007 2007 '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', 2009 2009 '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.', 2010 2010 'admin.b_makesite': '🎨 Seite erstellen', 'admin.b_circle': '🔗 Kreis', 'admin.b_stats': '📊 Statistik', -
src/views/pages/admin.ejs
r4101c89 rfbfd7a1 32 32 <% } %> 33 33 <% 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><% } %> 34 35 <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a> 35 36 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
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)