diff --git a/API.md b/API.md index 2b6d7a16..fdbd4e48 100644 --- a/API.md +++ b/API.md @@ -221,7 +221,8 @@ key. an invite. - `POST /api/federation/sessions` — anyone. Finishes it, as a sign-in. - `POST /api/federation/events` — another instance. Its signed news of one of - its users here: a new name, email or avatar, or the end of their sessions. + its users here: a new name, email or avatar, or the end of their sessions. A + 4xx tells it to give up on the event, a 429 or a 5xx to try again. - `GET /api/federation/peers` — anyone the trusted instances, `federation:manage` every listed one. - `PUT /api/federation/peers`, `DELETE /api/federation/peers/:host` — diff --git a/api/remote/gerbeur-instance.ts b/api/remote/gerbeur-instance.ts index a5bb9ba4..ddb1ba36 100644 --- a/api/remote/gerbeur-instance.ts +++ b/api/remote/gerbeur-instance.ts @@ -38,10 +38,12 @@ const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`; /** * Keeps each instance's metadata and keys for 10 minutes, so that a sign-in * or an event doesn't fetch them each time. A key set is refetched sooner - * when a token names a key it lacks ({@link fetchKeys} with `fresh`). + * when a token names a key it lacks ({@link fetchKeys} with `fresh`), at most + * once a minute: anyone can send a token naming a key that doesn't exist. */ const metadataCache = new TtlCache(10 * 60_000, 500); const keysCache = new TtlCache(10 * 60_000, 500); +const keysRefetched = new TtlCache(60_000, 500); /** * Checks that `url` may be reached: https on a public address, or anything @@ -119,7 +121,7 @@ export async function fetchMetadata(origin: string): Promise { /** * Returns the public keys of the instance at `origin`, cached unless `fresh` - * asks to refetch them. + * asks to refetch them and they weren't in the last minute. * * @throws {Error} When they can't be fetched. */ @@ -127,8 +129,10 @@ export async function fetchKeys( origin: string, fresh = false, ): Promise { - const hit = fresh ? undefined : keysCache.get(origin); + const refetch = fresh && !keysRefetched.get(origin); + const hit = refetch ? undefined : keysCache.get(origin); if (hit) return hit; + if (refetch) keysRefetched.set(origin, true); const { jwks_uri } = await fetchMetadata(origin); const body = await readJson(await request(jwks_uri), `${origin}'s keys`); if ( @@ -221,4 +225,5 @@ export async function fetchAvatar(url: string): Promise { export function clearInstanceCaches(): void { metadataCache.clear(); keysCache.clear(); + keysRefetched.clear(); } diff --git a/api/routes/federation.ts b/api/routes/federation.ts index 9e4f54b2..3a23d40f 100644 --- a/api/routes/federation.ts +++ b/api/routes/federation.ts @@ -11,14 +11,17 @@ * instance. * - `POST /api/federation/sessions` — public. Finishes it * (`FederationSessionRequest`) and answers the user and their tokens, as a - * sign-in does. Rate-limited per instance. + * sign-in does. Each sign-in started finishes once, so the limit on + * `/logins` bounds it. * - `POST /api/federation/events` — public, server to server. Applies a * home instance's signed event about one of its users (`{ event }`). + * Rate-limited per instance; a 4xx tells the sender to give up on the + * event, a 429 or a 5xx to try again. * - `GET /api/federation/peers` — optional sign-in. Answers the trusted * instances, or every listed one to `federation:manage`. * - `PUT /api/federation/peers` — permission `federation:manage`. Trusts or - * blocks an instance (`SetPeerRequest`) and answers it; 502 when it - * doesn't answer as a gerbeur. + * blocks an instance (`SetPeerRequest`) and answers it; 502 when one to + * trust doesn't answer as a gerbeur. * - `DELETE /api/federation/peers/:host` — permission `federation:manage`. * Takes an instance off the lists. * diff --git a/api/routes/password-resets.ts b/api/routes/password-resets.ts index 05a0cdb0..29fdaae6 100644 --- a/api/routes/password-resets.ts +++ b/api/routes/password-resets.ts @@ -6,7 +6,8 @@ * - `POST /api/password-resets` — public. Emails a reset link to the account * with that `email`, if any; always answers 200. * - `PUT /api/password-resets/:token` — public, with a reset token. Sets the - * `newPassword`. + * `newPassword`, which ends the user's sessions here and on the instances + * they signed in to. * * @module */ @@ -20,6 +21,7 @@ import { requestPasswordReset, resetPassword, } from "../services/password-reset-service.ts"; +import { notifyClients } from "../services/identity-provider-service.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -48,7 +50,8 @@ router.put("/:token", async (ctx) => { if (typeof newPassword !== "string") { throw new APIException(400, "Invalid request"); } - await resetPassword(ctx.params.token, newPassword); + const userId = await resetPassword(ctx.params.token, newPassword); + await notifyClients(userId, { type: "revoke" }); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); diff --git a/api/services/federation-peer-service.ts b/api/services/federation-peer-service.ts index 0311ff26..35d38259 100644 --- a/api/services/federation-peer-service.ts +++ b/api/services/federation-peer-service.ts @@ -65,12 +65,14 @@ export function listPeers(trustedOnly: boolean): FederationPeer[] { /** * Puts the instance `instance` names on the list of `status`, for `/manage`, - * and returns it. It must answer as a gerbeur instance, whose name is kept. - * Blocking one ends the sessions of its users here at once: their tokens stop - * counting by `getSessionState`, and this closes their sockets. + * and returns it. A trusted one must answer as a gerbeur instance, whose name + * is kept; a blocked one is blocked whether it answers or not, keeping the + * name it had. Blocking one ends the sessions of its users here at once: + * their tokens stop counting by `getSessionState`, and this closes their + * sockets. * * @throws {APIException} 400 when `instance` names no instance, or this one, - * 502 when it doesn't answer as a gerbeur instance. + * 502 when an instance to trust doesn't answer as a gerbeur instance. */ export async function setPeer( instance: string, @@ -81,17 +83,20 @@ export async function setPeer( if (issuer === PUBLIC_URL) { throw new APIException(400, "That is this instance"); } - let name: string; + let name: string | null = null; try { name = (await fetchMetadata(issuer)).gerbeur_site_name; } catch (err) { - console.error(`[federation] ${issuer} can't be added:`, err); - throw new APIException(502, "That address doesn't answer as a gerbeur"); + console.error(`[federation] ${issuer} doesn't answer:`, err); + if (status === "trusted") { + throw new APIException(502, "That address doesn't answer as a gerbeur"); + } } db.prepare( `INSERT INTO federation_peers (issuer, status, name, created_at) VALUES (?, ?, ?, ?) - ON CONFLICT (issuer) DO UPDATE SET status = excluded.status, name = excluded.name;`, + ON CONFLICT (issuer) DO UPDATE SET status = excluded.status, + name = coalesce(excluded.name, name);`, ).run(issuer, status, name, new Date().toISOString()); if (status === "blocked") { diff --git a/api/services/federation-service.ts b/api/services/federation-service.ts index 5e514af2..48ae0875 100644 --- a/api/services/federation-service.ts +++ b/api/services/federation-service.ts @@ -22,11 +22,12 @@ * @module */ -import { decodeJwt } from "@panva/jose"; +import { decodeJwt, type JSONWebKeySet } from "@panva/jose"; import { createHash, randomBytes } from "node:crypto"; import { APIException } from "../lib/api-exception.ts"; import { db, transaction } from "../db/connection.ts"; import { verifyInstanceToken } from "../lib/jwt.ts"; +import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { type FederationLoginRequest, type FederationSessionRequest, @@ -65,6 +66,13 @@ import { broadcastUserUpdated, updateClientUsername } from "./ws-service.ts"; /** How long a sign-in may take, from the login page to the callback. */ const LOGIN_TTL_MS = 10 * 60_000; +/** + * Allows an instance a burst of 120 events, as when many of its users change + * at once, then one a second. Each may fetch its keys, so a stranger posting + * events in an instance's name can't make this one hammer it. + */ +const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 }; + /** * Returns the origin of the instance `instance` names, checked as a home: not * this instance, nor a blocked one. @@ -157,50 +165,73 @@ export async function startLogin( return url.href; } +/** + * Returns the keys of the instance at `issuer`, refetched when `fresh` asks. + * + * @throws {APIException} 502 when they can't be fetched: the sender may try + * again, which it wouldn't after a 4xx. + */ +async function keysOf(issuer: string, fresh: boolean): Promise { + try { + return await fetchKeys(issuer, fresh); + } catch (err) { + console.error(`[federation] ${issuer}'s keys failed:`, err); + throw new APIException(502, "That instance's keys can't be fetched"); + } +} + /** * Returns the payload of `token`, an identity token or an event from the * instance at `issuer`, once its signature checks against that instance's * keys and it is addressed to this one. The keys are refetched once when the * token names one they lack. * - * @throws {APIException} 401 when it doesn't verify. + * @throws {APIException} 401 when it doesn't verify, 502 when the keys can't + * be fetched. */ async function verifyFrom( token: string, issuer: string, ): Promise> { const claims = { issuer, audience: ISSUER }; - try { - try { - return await verifyInstanceToken(token, await fetchKeys(issuer), claims); - } catch (err) { - if ((err as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY") { - throw err; - } - return await verifyInstanceToken( - token, - await fetchKeys(issuer, true), - claims, - ); + let failure: unknown; + for (const fresh of [false, true]) { + if ( + fresh && + (failure as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY" + ) { + break; + } + const keys = await keysOf(issuer, fresh); + try { + return await verifyInstanceToken(token, keys, claims); + } catch (err) { + failure = err; } - } catch (err) { - console.error(`[federation] a token from ${issuer} failed:`, err); - throw new APIException(401, "Invalid token"); } + console.error(`[federation] a token from ${issuer} failed:`, failure); + throw new APIException(401, "Invalid token"); } /** - * Returns `claims` once checked: a name a local user could have, and an email. - * - * @throws {APIException} 502 when the home sent something else. + * Returns `claims` from the instance at `issuer` once checked: a name a local + * user could have, an email, and an avatar on that instance, so that it can't + * make this server fetch from elsewhere. Returns null when they aren't. */ -function checkClaims(claims: unknown): IdentityClaims { +function checkClaims(claims: unknown, issuer: string): IdentityClaims | null { if ( !isIdentityClaims(claims) || !USERNAME_RE.test(claims.preferred_username) || !EMAIL_RE.test(claims.email) ) { - throw new APIException(502, "The instance sent an invalid identity"); + return null; + } + if (claims.picture !== undefined) { + try { + if (new URL(claims.picture).origin !== issuer) return null; + } catch { + return null; + } } return claims; } @@ -255,7 +286,10 @@ export async function completeLogin( if (payload.nonce !== login.nonce || typeof payload.sub !== "string") { throw new APIException(401, "Invalid token"); } - const claims = checkClaims(payload); + const claims = checkClaims(payload, issuer); + if (!claims) { + throw new APIException(502, "The instance sent an invalid identity"); + } const subject = payload.sub; const identity = db.prepare( @@ -342,10 +376,13 @@ async function applyClaims( * Applies the signed event `token` from a user's home, for * `POST /api/federation/events`: `update` brings their account in step, * `revoke` ends their sessions here. Only an instance some account here comes - * from may send one, about one of those accounts. + * from, and not blocked, may send one, about one of those accounts, and at + * most {@link EVENT_LIMIT}'s rate. The sender gives up on an event at a 4xx + * other than 429, and tries it again later otherwise. * * @throws {APIException} 400 when it isn't an event of a known instance about - * one of its users; 401 when it doesn't verify. + * one of its users; 401 when it doesn't verify; 403 when the instance is + * blocked; 429 when it sends too many; 502 when its keys can't be fetched. */ export async function receiveEvent(token: string): Promise { let issuer: unknown; @@ -361,6 +398,12 @@ export async function receiveEvent(token: string): Promise { ) { throw new APIException(400, "Unknown instance"); } + if (getPeerStatus(issuer) === "blocked") { + throw new APIException(403, "This instance is blocked here"); + } + if (!takeToken(`federation-events:${issuer}`, EVENT_LIMIT)) { + throw new APIException(429, "Too many events"); + } const payload = await verifyFrom(token, issuer); const identity = db.prepare( `SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`, @@ -370,7 +413,9 @@ export async function receiveEvent(token: string): Promise { } if (payload.type === "revoke") { revokeSessions(identity.user_id, new Date(), "Signed out by home"); - } else { - await applyClaims(identity.user_id, issuer, checkClaims(payload.claims)); + return; } + const claims = checkClaims(payload.claims, issuer); + if (!claims) throw new APIException(400, "Invalid event"); + await applyClaims(identity.user_id, issuer, claims); } diff --git a/api/services/identity-provider-service.ts b/api/services/identity-provider-service.ts index 8aead0bb..f6d8c0f6 100644 --- a/api/services/identity-provider-service.ts +++ b/api/services/identity-provider-service.ts @@ -13,7 +13,7 @@ * the instance key. * 3. While the user's grant lasts, the client hears of their changes: a new * name, email or avatar, and the end of their sessions after a password - * change. {@link notifyClients} queues the signed events in + * change or reset. {@link notifyClients} queues the signed events in * `federation_outbox`, and {@link outboxJob} delivers them. * * Only a local account signs in elsewhere: a user who came from another @@ -26,7 +26,12 @@ import { createHash, randomBytes } from "node:crypto"; import { APIException } from "../lib/api-exception.ts"; import { db, transaction } from "../db/connection.ts"; -import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts"; +import { + AVATARS_DIR, + FRONTEND_URL, + OG_SITE_NAME, + PUBLIC_URL, +} from "../config.ts"; import { signInstanceToken } from "../lib/jwt.ts"; import type { Job } from "../lib/scheduler.ts"; import { @@ -105,9 +110,23 @@ function sha256(value: string): string { return createHash("sha256").update(value).digest("base64url"); } +/** + * Returns the version of `user`'s avatar: when its file was last written, + * which only a new avatar does, unlike `updatedAt`, which every profile edit + * bumps. + */ +function avatarVersion(user: User): number { + try { + return Deno.statSync(`${AVATARS_DIR}/${user.id}`).mtime?.getTime() ?? 0; + } catch { + return user.updatedAt?.getTime() ?? 0; + } +} + /** * Returns what this instance tells another of `user`: their name, email and - * avatar, whose URL changes with each new one. + * avatar, whose URL changes with each new one, so that a client fetches it + * again only then. */ export function identityClaims(user: User): IdentityClaims { return { @@ -115,7 +134,7 @@ export function identityClaims(user: User): IdentityClaims { email: user.email, ...(user.avatarMime && { picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${ - user.updatedAt?.getTime() ?? 0 + avatarVersion(user) }`, }), }; @@ -348,7 +367,8 @@ export async function revokeGrant(userId: string, host: string): Promise { * `clientIds`: `update` when their name, email or avatar changed, `revoke` * when their sessions there must end. The routes that change a user call it, * and for an `update`, `event` may be just the type: the claims are read now. - * The events are queued in the outbox and their delivery starts at once. + * The events are queued in the outbox and their delivery starts at once, + * after those queued before for the same instance ({@link deliverTo}). */ export async function notifyClients( userId: string, @@ -381,52 +401,80 @@ export async function notifyClients( now.toISOString(), ); } - deliverOutbox().catch((err) => - console.error("[federation] delivery failed:", err) - ); + for (const clientId of clients) deliverTo(clientId); } -/** Set while {@link deliverOutbox} runs, so that two runs don't overlap. */ -let delivering = false; +/** + * The instances whose events {@link deliverTo} is sending, so that two runs + * don't send the same events, and those whose events came meanwhile, which + * the running one sends too once done. + */ +const delivering = new Set(); +const queuedMeanwhile = new Set(); /** - * Sends each due event of the outbox to its instance. One it took, or refused - * for good, leaves the outbox; one that failed waits twice as long as the time - * before, up to 6 hours, until it expires after 3 days. + * Sends the outbox's events to each instance that has some, every instance at + * once so that one that doesn't answer delays none of the others. */ export async function deliverOutbox(): Promise { - if (delivering) return; - delivering = true; + db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run( + new Date(Date.now() - EVENT_TTL_MS).toISOString(), + ); + const clients = db.prepare( + `SELECT DISTINCT client_id FROM federation_outbox;`, + ).all() as { client_id: string }[]; + await Promise.all(clients.map((c) => deliverTo(c.client_id))); +} + +/** + * Sends the events for the instance `clientId` in the order they were made, + * as long as it takes them, since each `update` carries all the claims and a + * late older one would undo a newer one. One it took, or refused for good, + * leaves the outbox. The first that fails stops the run, and waits twice as + * long as the time before, up to 6 hours, with the events behind it, until + * it expires after 3 days. + */ +async function deliverTo(clientId: string): Promise { + if (delivering.has(clientId)) { + queuedMeanwhile.add(clientId); + return; + } + delivering.add(clientId); try { - const now = new Date(); - db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run( - new Date(now.getTime() - EVENT_TTL_MS).toISOString(), - ); - const due = db.prepare( - `SELECT id, client_id, event, attempts FROM federation_outbox - WHERE next_attempt_at <= ? ORDER BY created_at LIMIT 100;`, - ).all(now.toISOString()) as { - id: string; - client_id: string; - event: string; - attempts: number; - }[]; - for (const row of due) { - try { - if (!await postEvent(row.client_id, row.event)) { - console.error(`[federation] ${row.client_id} refused an event`); - } - db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id); - } catch (err) { - const wait = Math.min(60_000 * 2 ** row.attempts, MAX_RETRY_MS); - db.prepare( - `UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`, - ).run(new Date(Date.now() + wait).toISOString(), row.id); - console.error(`[federation] ${row.client_id} unreachable:`, err); - } - } + do { + queuedMeanwhile.delete(clientId); + await sendDue(clientId); + } while (queuedMeanwhile.has(clientId)); + } catch (err) { + console.error(`[federation] delivery to ${clientId} failed:`, err); } finally { - delivering = false; + delivering.delete(clientId); + } +} + +/** Runs one pass of {@link deliverTo}. */ +async function sendDue(clientId: string): Promise { + for (;;) { + const row = db.prepare( + `SELECT id, event, attempts, next_attempt_at FROM federation_outbox + WHERE client_id = ? ORDER BY created_at, rowid LIMIT 1;`, + ).get(clientId) as + | { id: string; event: string; attempts: number; next_attempt_at: string } + | undefined; + if (!row || row.next_attempt_at > new Date().toISOString()) return; + try { + if (!await postEvent(clientId, row.event)) { + console.error(`[federation] ${clientId} refused an event`); + } + db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id); + } catch (err) { + const wait = Math.min(60_000 * 2 ** row.attempts, MAX_RETRY_MS); + db.prepare( + `UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`, + ).run(new Date(Date.now() + wait).toISOString(), row.id); + console.error(`[federation] ${clientId} unreachable:`, err); + return; + } } } diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index 1efd0797..5e0db686 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -65,8 +65,9 @@ export async function requestPasswordReset(email: string): Promise { } /** - * Sets `newPassword` for the user of a reset token and marks the token used. - * Through `updateUser`, this ends the user's other sessions. + * Sets `newPassword` for the user of a reset token, marks the token used, and + * returns the user's id. Through `updateUser`, this ends the user's sessions + * here; the route tells the instances they signed in to. * * @throws {APIException} 400 when the password's length is out of bounds or * the token is invalid or expired, 404 when the token isn't on record or its @@ -75,7 +76,7 @@ export async function requestPasswordReset(email: string): Promise { export async function resetPassword( token: string, newPassword: string, -): Promise { +): Promise { if ( newPassword.length < VALIDATION.PASSWORD_MIN || newPassword.length > VALIDATION.PASSWORD_MAX @@ -118,4 +119,5 @@ export async function resetPassword( .run(token); throw err; } + return payload.userId; } diff --git a/api/test/services/federation-service.test.ts b/api/test/services/federation-service.test.ts index 5b487e37..67d3691f 100644 --- a/api/test/services/federation-service.test.ts +++ b/api/test/services/federation-service.test.ts @@ -14,6 +14,7 @@ import { test, } from "../helpers.ts"; import { assert, assertEquals, assertMatch } from "@std/assert"; +import { clearInstanceCaches } from "../../remote/gerbeur-instance.ts"; import type { FederationLoginResponse, FederationPeer, @@ -265,6 +266,64 @@ test("federation: the home's events rename, update and sign out its users", asyn assertEquals((await get("/api/me", { auth })).status, 401); }); +test("federation: a home's events are refused from a blocked home, with an avatar elsewhere, and put off when its keys fail", async () => { + const home = await fakeInstance(HOME); + let keysDown = false; + using net = stubNetwork((url, init) => + keysDown && url.pathname === "/.well-known/jwks.json" + ? new Response("", { status: 503 }) + : home.handle(url, init) ?? new Response("", { status: 404 }) + ); + await trust(HOME); + await signIn(home, ALICE); + const send = async (payload: Record) => + (await post("/api/federation/events", { + json: { event: await home.event(ALICE.sub, payload) }, + })).status; + + // Answered 5xx, so that the home tries again rather than give up. + keysDown = true; + clearInstanceCaches(); + assertEquals(await send({ type: "revoke" }), 502); + keysDown = false; + + const avatars = net.calls.filter((u) => u.pathname.endsWith("/avatar")) + .length; + assertEquals( + await send({ + type: "update", + claims: { ...ALICE, picture: "https://elsewhere.test/a.png" }, + }), + 400, + ); + assertEquals( + net.calls.filter((u) => u.pathname.endsWith("/avatar")).length, + avatars, + ); + + await trust(HOME, "blocked"); + assertEquals( + await send({ + type: "update", + claims: { ...ALICE, preferred_username: "mallory" }, + }), + 403, + ); + assertEquals( + (await get("/api/users/@alice@home.test")).data.username, + "alice@home.test", + ); +}); + +test("federation: an instance that doesn't answer can be blocked, not trusted", async () => { + using _net = network(); + assertEquals((await trust("https://down.test")).status, 502); + const blocked = await trust("https://down.test", "blocked"); + assertEquals(blocked.status, 200); + assertEquals(blocked.data.status, "blocked"); + assertEquals(blocked.data.name, null); +}); + test("federation: an account from another instance changes its password, email, avatar and name there", async () => { const home = await fakeInstance(HOME); using _net = network(home); diff --git a/api/test/services/identity-provider-service.test.ts b/api/test/services/identity-provider-service.test.ts index ad5e4598..317f6f75 100644 --- a/api/test/services/identity-provider-service.test.ts +++ b/api/test/services/identity-provider-service.test.ts @@ -6,8 +6,10 @@ import { del, type FakeInstance, fakeInstance, + formWithFile, get, patch, + PNG, post, put, stubNetwork, @@ -16,7 +18,15 @@ import { import { assert, assertEquals } from "@std/assert"; import { createHash } from "node:crypto"; import { decodeJwt, type JSONWebKeySet } from "@panva/jose"; -import { verifyInstanceToken } from "../../lib/jwt.ts"; +import { + createPasswordResetToken, + verifyInstanceToken, +} from "../../lib/jwt.ts"; +import { + deliverOutbox, + identityClaims, +} from "../../services/identity-provider-service.ts"; +import { getUserById } from "../../services/user-service.ts"; import type { AuthorizeResponse, FederationGrant, @@ -266,3 +276,65 @@ test("identity provider: an event an instance fails to take waits in the outbox" assert(row); assertEquals(row.attempts, 1); }); + +test("identity provider: a password reset ends the user's sessions where they signed in", async () => { + const fake = await fakeInstance(CLIENT); + using _net = network(fake); + const user = await createUser(); + await authorize(user.auth, pkce().challenge); + const token = await createPasswordResetToken(user.id); + db.prepare( + `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, + ).run(token, user.id, new Date(Date.now() + 3600_000).toISOString()); + assertEquals( + (await put(`/api/password-resets/${token}`, { + json: { newPassword: "reset-password-1" }, + })).status, + 200, + ); + await until(() => fake.received.length === 1); + assertEquals(decodeJwt(fake.received[0]).type, "revoke"); +}); + +test("identity provider: an instance gets its events in order, a failed one holding back the next", async () => { + const fake = await fakeInstance(CLIENT); + let down = true; + using _net = stubNetwork((url, init) => + down && url.pathname === "/api/federation/events" + ? new Response("", { status: 503 }) + : fake.handle(url, init) ?? new Response("", { status: 404 }) + ); + const user = await createUser(); + await authorize(user.auth, pkce().challenge); + await patch("/api/me", { auth: user.auth, json: { email: "a@new.test" } }); + await until(() => + !!db.prepare(`SELECT 1 FROM federation_outbox WHERE attempts > 0;`).get() + ); + + // Up again, but the first event still waits: the second must wait behind. + down = false; + await patch("/api/me", { auth: user.auth, json: { email: "b@new.test" } }); + await new Promise((r) => setTimeout(r, 50)); + assertEquals(fake.received.length, 0); + + db.prepare(`UPDATE federation_outbox SET next_attempt_at = ?;`).run( + new Date().toISOString(), + ); + await deliverOutbox(); + assertEquals( + fake.received.map((e) => (decodeJwt(e).claims as { email: string }).email), + ["a@new.test", "b@new.test"], + ); +}); + +test("identity provider: the avatar's URL changes with the avatar only", async () => { + const user = await createUser(); + await put("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG) }); + const before = identityClaims(getUserById(user.id)).picture; + assert(before); + await new Promise((r) => setTimeout(r, 5)); + await patch("/api/me", { auth: user.auth, json: { description: "hi" } }); + assertEquals(identityClaims(getUserById(user.id)).picture, before); + await put("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG) }); + assert(identityClaims(getUserById(user.id)).picture !== before); +}); diff --git a/locales/en.mjs b/locales/en.mjs index f9f3d663..6ad002a5 100644 --- a/locales/en.mjs +++ b/locales/en.mjs @@ -1 +1,3 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Disconnect\"],\"-K9EZb\":[\"Add email…\"],\"-Ya-b9\":[\"Failed to save\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2rwzb1\":[\"You haven't signed in to another gerbeur with this account yet.\"],\"2wxgft\":[\"Rename\"],\"2ygf_L\":[\"← Back\"],\"32TndD\":[\"Blocked\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Edit email\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4OjqAQ\":[\"Keep editing\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5NZ3tg\":[\"Turning off…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6C4kKS\":[\"Other instances\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6_dCYd\":[\"Overview\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Nothing happened this week.\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8rPWOT\":[\"Can't sign in there\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9eEYEq\":[\"This sign-in link is incomplete.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AEfu-D\":[\"In collections\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"AS7WoE\":[\"Start fresh\"],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"AnRu_j\":[\"Cancel reply\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"CJkEfx\":[\"Block\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"E417RG\":[\"You won't get the weekly digest any more. You can turn it back on in your settings.\"],\"EBU6O6\":[\"Connection lost — this message may not have been sent\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"EarrCe\":[\"Search emoji\"],\"Evcm2Z\":[\"Close without posting? The attached file won't be kept — everything you typed will.\"],\"EwVrWF\":[\"The dump couldn't be removed. Try again.\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FdTlEG\":[\"Track \",[\"0\"]],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GCWCCo\":[\"Close player\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GY6uSc\":[\"Next →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"Gv5nA5\":[\"Unexpected response format\"],\"H4MGkI\":[\"Expand player\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"H_PnT8\":[\"Insert emoji\"],\"I-x669\":[\"Invitees\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"JrFTcr\":[\"Connecting…\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"KDw4GX\":[\"Try again\"],\"KKZxBU\":[\"Uploading \",[\"0\"],\"%\"],\"K_F6pa\":[\"Saving…\"],\"KtYF-t\":[\"Sounds\"],\"L-rMC9\":[\"Reset to default\"],\"L7_rsx\":[\"Uploading image…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"MbkoRn\":[\"Or join with your account on another gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"MrIc3x\":[\"Or log in with your account on another gerbeur\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" new\"]}]],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"QctOgc\":[\"Could not connect to the server. Check your connection and try again.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RLe7Vk\":[\"Checking…\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"RvVJBH\":[\"Refreshing stream…\"],\"S1xV5B\":[\"At most \",[\"maxLength\"],\" characters.\"],\"SBTElJ\":[\"Searching…\"],\"SLaP1b\":[\"Not from here\"],\"SMaFdc\":[\"Unsubscribe\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T8ejtZ\":[\"This comment couldn't be deleted. Try again.\"],\"T91vKp\":[\"Play\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Your account lives on \",[\"home\"],\": sign in to other instances from there.\"],\"TAtbho\":[\"Loading messages…\"],\"TN382O\":[\"Invalid link\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") asks to sign you in with your account here, <1>@\",[\"username\"],\". It will receive your username, your email address and your avatar.\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOV0wi\":[\"Signing you in…\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"V38P77\":[\"Only its poster or a moderator can edit this dump.\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VsMqsD\":[\"Its address, like music.example\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W5A0Ly\":[\"An unexpected error occurred.\"],\"W9FRBT\":[\"Like\"],\"WAcnnR\":[\"The image couldn't be uploaded.\"],\"WE_r9K\":[\"Failed to create category\"],\"WMw63e\":[\"Couldn't reach that instance\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"XWbTfu\":[\"last used \",[\"0\"]],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YWnSuq\":[\"This unsubscribe link is missing or malformed.\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"Z5HWHd\":[\"On\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZWxjuD\":[\"Some images couldn't be uploaded.\"],\"ZmD2o6\":[\"Create & Add\"],\"_0oJwI\":[\"Trust\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_I1_HN\":[\"Lowercase letters, numbers and single hyphens only\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Sign in elsewhere\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"az8lvo\":[\"Off\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"b_1P1D\":[\"Renaming…\"],\"b_VouM\":[\"Your account lives on \",[\"home\"],\": your password, email address, avatar and username change there.\"],\"bzSI52\":[\"Discard\"],\"cILfnJ\":[\"Remove file\"],\"cNu4my\":[[\"0\"],\" more online: \",[\"restNames\"]],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"dau1Pl\":[[\"unseenCount\"],\" new messages\"],\"digest.community\":[\"Community\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"digest.invitedBy\":[\"invited by @\",[\"username\"]],\"digest.invitedByYou\":[\"invited by you\"],\"digest.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"digest.newMembers\":[\"New members\"],\"digest.newPlaylists\":[\"New public playlists\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"digest.topMonth\":[\"Top this month\"],\"digest.topWeek\":[\"Top this week\"],\"digest.weekly\":[\"Weekly digest\"],\"digest.weekly.off\":[\"Turn off weekly digests\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"email.digest.addedTo\":[\"added to \",[\"playlist\"]],\"email.digest.andMore\":[\"and \",[\"n\"],\" more\"],\"email.digest.by\":[\"by @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message this week\"],\"other\":[\"#\",\" messages this week\"]}]],\"email.digest.communityPage\":[\"See it all on the community page\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"new follower\"],\"other\":[\"new followers\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"comment like\"],\"other\":[\"comment likes\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"playlist follow\"],\"other\":[\"playlist follows\"]}]],\"email.digest.forYou\":[\"For you\"],\"email.digest.hl.comment\":[[\"who\"],\" commented on your dump \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" liked your comment on \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" mentioned you in a comment on \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" mentioned everyone in \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" mentioned you in \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" mentioned you in the playlist \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" followed your playlist \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" started following you\"],\"email.digest.inChat\":[\"In the chat\"],\"email.digest.monthly.reason\":[\"You're getting the monthly recap because it's on in your settings.\"],\"email.digest.monthly.subject\":[\"The month on \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"The month on \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" more on your Followed feed\"],\"email.digest.network\":[\"From people you follow\"],\"email.digest.openChat\":[\"Open the chat\"],\"email.digest.playlistMeta\":[\"by @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new member\"],\"other\":[\"#\",\" new members\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new dump from people you follow\"],\"other\":[\"#\",\" new dumps from people you follow\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new playlist\"],\"other\":[\"#\",\" new playlists\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the month\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the month\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the week\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the week\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"See the notification\"],\"other\":[\"See all \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" upvote\"],\"other\":[\"#\",\" upvotes\"]}],\" and \",[\"comments\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}],\".\"],\"email.digest.upload\":[\"upload\"],\"email.digest.weekly.reason\":[\"You're getting the weekly digest because it's on in your settings.\"],\"email.digest.weekly.subject\":[\"Your week on \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Your week, \",[\"username\"]],\"email.digest.yours\":[\"yours\"],\"email.reset.body\":[\"Someone asked to reset the password of your \",[\"site\"],\" account. The button below lets you choose a new one. It works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.button\":[\"Reset password\"],\"email.reset.fallback\":[\"If the button doesn't work, paste this link into your browser:\"],\"email.reset.hi\":[\"Hi \",[\"username\"],\",\"],\"email.reset.ignore\":[\"If you didn't ask for this, ignore this email. Your password stays the same.\"],\"email.reset.preheader\":[\"The link works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.reason\":[\"You're getting this email because a password reset was requested for this address.\"],\"email.reset.subject\":[\"Reset your \",[\"site\"],\" password\"],\"email.reset.title\":[\"Reset your password\"],\"email.settings\":[\"Settings\"],\"email.welcome.body\":[\"# Welcome to \",[\"site\"],\"!\\n\\nHi **\",[\"username\"],\"**,\\n\\nYour account has been created successfully. Welcome aboard!\"],\"email.welcome.button\":[\"Open \",[\"site\"]],\"email.welcome.digests\":[\"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.\"],\"email.welcome.preheader\":[\"Your account is ready.\"],\"email.welcome.reason\":[\"You're getting this email because an account was created on \",[\"site\"],\" with this address.\"],\"email.welcome.subject\":[\"Welcome to \",[\"site\"]],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"f3cEDO\":[\"Change image\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"fn1ZQX\":[\"Sign in to \",[\"name\"],\"?\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gIpLuy\":[\"A link is required\"],\"gL9KKi\":[\"Who can see it\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"goWEhR\":[\"Previous track\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Back to the login page\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jTGs8A\":[\"This site doesn't send email, so no digest goes out.\"],\"jZNg18\":[\"Account on <0>\",[\"host\"],\"\"],\"jbernk\":[\"Loading profile…\"],\"jnrIbz\":[\"Upvoted (\",[\"0\"],\")\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"k4b5_X\":[\"edited\"],\"kLttbL\":[\"Registration failed\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"m9Uf3I\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite.\"],\"mDJo8W\":[\"Dumps (\",[\"0\"],\")\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" mentioned everyone in <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"oW3yYY\":[\"This month\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"q018gJ\":[\"Couldn't add that instance\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"qVdjD5\":[\"Trusted\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qnUJBI\":[\"The new order couldn't be saved. Try again.\"],\"qsqt0w\":[\"Invite link\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rn6SBY\":[\"Unmute\"],\"sEXJPi\":[\"Link or file\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"sgX7B-\":[\"Single use, valid for 7 days.\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"tOHgQw\":[\"Continue as @\",[\"username\"]],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"tyiiHe\":[\"Nothing happened this month.\"],\"u1lDX2\":[\"Fetching preview…\"],\"u8pttd\":[\"Change it on \",[\"home\"]],\"uACFmj\":[\"Log in with \",[\"0\"]],\"uD0qXQ\":[\"Drop a file here\"],\"uISnsY\":[\"Could not delete\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"v-Wp--\":[\"Open source\"],\"v6Lbin\":[\"An instance's address, like music.example\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"w61W3L\":[\"Remove \",[\"0\"]],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xGVfLh\":[\"Continue\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y1G_DI\":[\"Could not rename\"],\"y6sq5j\":[\"Following\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yByRxz\":[\"This week\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yxlcv5\":[\"Turn off the monthly recap?\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file +/*eslint-disable*/ export const messages = JSON.parse( + '{"-K0AvT":["Disconnect"],"-K9EZb":["Add email…"],"-Ya-b9":["Failed to save"],"-iDCtG":["Your settings"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2rwzb1":["You haven\'t signed in to another gerbeur with this account yet."],"2wxgft":["Rename"],"2ygf_L":["← Back"],"32TndD":["Blocked"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5NZ3tg":["Turning off…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6C4kKS":["Other instances"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6_dCYd":["Overview"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7noMWp":["Nothing happened this week."],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"8JplJJ":["Unsubscribed"],"8Ug9jB":["Related"],"8ZsakT":["Password"],"8rPWOT":["Can\'t sign in there"],"92o1-U":["Failed to delete"],"9BruTc":["Why?"],"9eEYEq":["This sign-in link is incomplete."],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AEfu-D":["In collections"],"AHZflp":["Chat"],"AQbgNR":["Follow ",["targetUsername"]],"AS7WoE":["Start fresh"],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"AeXO77":["Account"],"AhNP40":["Seek"],"AnRu_j":["Cancel reply"],"AwUsnG":["Instances"],"B4LeYP":["Delete failed (",["0"],")"],"BjitBo":["Download ",["0"]],"BlpJ2G":["Picked up where you left off."],"CI50ct":["Upvoted"],"CJkEfx":["Block"],"C_qQE4":["Already dumped by ",["0"]," ",["1"]],"Cf_gxi":["Turn off the weekly digest?"],"Cj24wt":["Registering…"],"CnqayG":["Couldn\'t update. Try again."],"D8DRYV":["Delete this message?"],"DI3NBO":["Open on ",["0"]],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"E417RG":["You won\'t get the weekly digest any more. You can turn it back on in your settings."],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"Gv5nA5":["Unexpected response format"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IF7RiR":["Cover"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"IhK90M":["Letters, numbers and underscores only"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JLasL-":["Couldn\'t unsubscribe"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"]," commented on <1>",["1"],""],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"JrFTcr":["Connecting…"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"MbkoRn":["Or join with your account on another gerbeur"],"Mj6oCJ":[["0"]," is typing…"],"MrIc3x":["Or log in with your account on another gerbeur"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"Nu4oKW":["Description"],"O32mmL":["Edit description"],"O3oNi5":["Email"],"Oprv1v":["Password (min. ",["0"]," characters)"],"P5MuiB":["Add image"],"PJTy3N":[["totalUnread","plural",{"one":["#"," new"],"other":["#"," new"]}]],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"QctOgc":["Could not connect to the server. Check your connection and try again."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RLe7Vk":["Checking…"],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"SLaP1b":["Not from here"],"SMaFdc":["Unsubscribe"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TAkYGZ":["Your account lives on ",["home"],": sign in to other instances from there."],"TAtbho":["Loading messages…"],"TN382O":["Invalid link"],"ToADL_":["<0>",["name"]," (",["host"],") asks to sign you in with your account here, <1>@",["username"],". It will receive your username, your email address and your avatar."],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOV0wi":["Signing you in…"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"V38P77":["Only its poster or a moderator can edit this dump."],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VsMqsD":["Its address, like music.example"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W5A0Ly":["An unexpected error occurred."],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WMw63e":["Couldn\'t reach that instance"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"XWbTfu":["last used ",["0"]],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YWnSuq":["This unsubscribe link is missing or malformed."],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"]," liked your comment on <1>",["1"],""],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_0oJwI":["Trust"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_I1_HN":["Lowercase letters, numbers and single hyphens only"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_eZzBf":["Sign in elsewhere"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"b_1P1D":["Renaming…"],"b_VouM":["Your account lives on ",["home"],": your password, email address, avatar and username change there."],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"digest.community":["Community"],"digest.count.comments":[["n","plural",{"one":["comment"],"other":["comments"]}]],"digest.count.upvotes":[["n","plural",{"one":["upvote"],"other":["upvotes"]}]],"digest.invitedBy":["invited by @",["username"]],"digest.invitedByYou":["invited by you"],"digest.monthly":["Monthly recap"],"digest.monthly.off":["Turn off monthly recaps"],"digest.newMembers":["New members"],"digest.newPlaylists":["New public playlists"],"digest.stat.chat":[["n","plural",{"one":["chat message"],"other":["chat messages"]}]],"digest.stat.dumps":[["n","plural",{"one":["dump"],"other":["dumps"]}]],"digest.topMonth":["Top this month"],"digest.topWeek":["Top this week"],"digest.weekly":["Weekly digest"],"digest.weekly.off":["Turn off weekly digests"],"dqwCfR":["Couldn\'t load a preview for this link — check it, or post it as-is and refresh the preview later."],"dznB-Y":["Only images can be dropped here."],"eFSqvc":["Failed to post reply"],"eIEHHV":["Change skin tone"],"eOfXq3":["Title is required."],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"eloqXQ":["You can post it anyway."],"email.digest.addedTo":["added to ",["playlist"]],"email.digest.andMore":["and ",["n"]," more"],"email.digest.by":["by @",["username"]],"email.digest.chatWeek":[["n","plural",{"one":["#"," message this week"],"other":["#"," messages this week"]}]],"email.digest.communityPage":["See it all on the community page"],"email.digest.count.followers":[["n","plural",{"one":["new follower"],"other":["new followers"]}]],"email.digest.count.likes":[["n","plural",{"one":["comment like"],"other":["comment likes"]}]],"email.digest.count.mentions":[["n","plural",{"one":["mention"],"other":["mentions"]}]],"email.digest.count.playlistFollows":[["n","plural",{"one":["playlist follow"],"other":["playlist follows"]}]],"email.digest.forYou":["For you"],"email.digest.hl.comment":[["who"]," commented on your dump ",["target"]],"email.digest.hl.commentLike":[["who"]," liked your comment on ",["target"]],"email.digest.hl.mentionComment":[["who"]," mentioned you in a comment on ",["target"]],"email.digest.hl.mentionEveryone":[["who"]," mentioned everyone in ",["target"]],"email.digest.hl.mentionIn":[["who"]," mentioned you in ",["target"]],"email.digest.hl.mentionPlaylist":[["who"]," mentioned you in the playlist ",["target"]],"email.digest.hl.playlistFollow":[["who"]," followed your playlist ",["target"]],"email.digest.hl.userFollow":[["who"]," started following you"],"email.digest.inChat":["In the chat"],"email.digest.monthly.reason":["You\'re getting the monthly recap because it\'s on in your settings."],"email.digest.monthly.subject":["The month on ",["site"],", ",["range"]],"email.digest.monthly.title":["The month on ",["site"]],"email.digest.more":[["n"]," more on your Followed feed"],"email.digest.network":["From people you follow"],"email.digest.openChat":["Open the chat"],"email.digest.playlistMeta":["by @",["username"]," · ",["n","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"email.digest.pre.members":[["n","plural",{"one":["#"," new member"],"other":["#"," new members"]}]],"email.digest.pre.network":[["n","plural",{"one":["#"," new dump from people you follow"],"other":["#"," new dumps from people you follow"]}]],"email.digest.pre.playlists":[["n","plural",{"one":["#"," new playlist"],"other":["#"," new playlists"]}]],"email.digest.pre.topMonth":[["n","plural",{"one":["the most upvoted dump of the month"],"other":["the ","#"," most upvoted dumps of the month"]}]],"email.digest.pre.topWeek":[["n","plural",{"one":["the most upvoted dump of the week"],"other":["the ","#"," most upvoted dumps of the week"]}]],"email.digest.seeAll":[["n","plural",{"one":["See the notification"],"other":["See all ","#"," notifications"]}]],"email.digest.theChat":["the chat"],"email.digest.totals":[["dumps","plural",{"one":["#"," dump"],"other":["#"," dumps"]}],", ",["upvotes","plural",{"one":["#"," upvote"],"other":["#"," upvotes"]}]," and ",["comments","plural",{"one":["#"," comment"],"other":["#"," comments"]}],"."],"email.digest.upload":["upload"],"email.digest.weekly.reason":["You\'re getting the weekly digest because it\'s on in your settings."],"email.digest.weekly.subject":["Your week on ",["site"],", ",["range"]],"email.digest.weekly.title":["Your week, ",["username"]],"email.digest.yours":["yours"],"email.reset.body":["Someone asked to reset the password of your ",["site"]," account. The button below lets you choose a new one. It works once, for ",["hours","plural",{"one":["#"," hour"],"other":["#"," hours"]}],"."],"email.reset.button":["Reset password"],"email.reset.fallback":["If the button doesn\'t work, paste this link into your browser:"],"email.reset.hi":["Hi ",["username"],","],"email.reset.ignore":["If you didn\'t ask for this, ignore this email. Your password stays the same."],"email.reset.preheader":["The link works once, for ",["hours","plural",{"one":["#"," hour"],"other":["#"," hours"]}],"."],"email.reset.reason":["You\'re getting this email because a password reset was requested for this address."],"email.reset.subject":["Reset your ",["site"]," password"],"email.reset.title":["Reset your password"],"email.settings":["Settings"],"email.welcome.body":["# Welcome to ",["site"],"!\\n\\nHi **",["username"],"**,\\n\\nYour account has been created successfully. Welcome aboard!"],"email.welcome.button":["Open ",["site"]],"email.welcome.digests":["Once a week you\'ll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings."],"email.welcome.preheader":["Your account is ready."],"email.welcome.reason":["You\'re getting this email because an account was created on ",["site"]," with this address."],"email.welcome.subject":["Welcome to ",["site"]],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"f3cEDO":["Change image"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"fgLNSM":["Register"],"fn1ZQX":["Sign in to ",["name"],"?"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gIpLuy":["A link is required"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hLAe-z":["Back to the login page"],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jTGs8A":["This site doesn\'t send email, so no digest goes out."],"jZNg18":["Account on <0>",["host"],""],"jbernk":["Loading profile…"],"jnrIbz":["Upvoted (",["0"],")"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"m9Uf3I":["Users of a trusted instance sign in here with their account there. Those of a blocked one can\'t. Users of any other instance need an invite."],"mDJo8W":["Dumps (",["0"],")"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nucyoV":["<0>",["0"]," mentioned everyone in <1>",["where"],""],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"oW3yYY":["This month"],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pd3FCq":["That file is over ",["limit"],"."],"pvnfJD":["Dark"],"q018gJ":["Couldn\'t add that instance"],"qIMfNQ":["Delete playlist"],"qItrqH":["You won\'t get the monthly recap any more. You can turn it back on in your settings."],"qVdjD5":["Trusted"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qnUJBI":["The new order couldn\'t be saved. Try again."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"tOHgQw":["Continue as @",["username"]],"tXNIqW":["This sign-in was started elsewhere. Start again from the login page."],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"tyiiHe":["Nothing happened this month."],"u1lDX2":["Fetching preview…"],"u8pttd":["Change it on ",["home"]],"uACFmj":["Log in with ",["0"]],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"v6Lbin":["An instance\'s address, like music.example"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"w61W3L":["Remove ",["0"]],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in"],"wtxFp4":["File too large (max ",["max"],")."],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xGVfLh":["Continue"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y1G_DI":["Could not rename"],"y6sq5j":["Following"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yByRxz":["This week"],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yxlcv5":["Turn off the monthly recap?"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', +); diff --git a/locales/en.po b/locales/en.po index bd2aca54..9395ef32 100644 --- a/locales/en.po +++ b/locales/en.po @@ -391,7 +391,7 @@ msgstr "Auto" msgid "Back to login" msgstr "Back to login" -#: src/pages/FederationCallback.tsx:66 +#: src/pages/FederationCallback.tsx:71 msgid "Back to the login page" msgstr "Back to the login page" @@ -545,7 +545,7 @@ msgstr "Community" msgid "Confirm new password" msgstr "Confirm new password" -#: src/components/federation/FederatedSignIn.tsx:111 +#: src/components/federation/FederatedSignIn.tsx:113 msgid "Connecting…" msgstr "Connecting…" @@ -553,7 +553,7 @@ msgstr "Connecting…" msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" -#: src/components/federation/FederatedSignIn.tsx:113 +#: src/components/federation/FederatedSignIn.tsx:115 msgid "Continue" msgstr "Continue" @@ -602,7 +602,7 @@ msgstr "Couldn't load a preview for this link — check it, or post it as-is and msgid "Couldn't load the chat." msgstr "Couldn't load the chat." -#: src/components/federation/FederatedSignIn.tsx:102 +#: src/components/federation/FederatedSignIn.tsx:104 msgid "Couldn't reach that instance" msgstr "Couldn't reach that instance" @@ -1066,7 +1066,7 @@ msgstr "invited by you" msgid "Invitees" msgstr "Invitees" -#: src/components/federation/FederatedSignIn.tsx:105 +#: src/components/federation/FederatedSignIn.tsx:107 msgid "Its address, like music.example" msgstr "Its address, like music.example" @@ -1155,7 +1155,7 @@ msgid "Loading…" msgstr "Loading…" #: src/components/layout/AppHeader.tsx:208 -#: src/pages/FederationCallback.tsx:31 +#: src/pages/FederationCallback.tsx:32 #: src/pages/UserLogin.tsx:44 #: src/pages/UserLogin.tsx:71 #: src/pages/UserLogin.tsx:90 @@ -1171,7 +1171,7 @@ msgid "Log in to vote" msgstr "Log in to vote" #. placeholder {0}: peer.name ?? peer.host -#: src/components/federation/FederatedSignIn.tsx:97 +#: src/components/federation/FederatedSignIn.tsx:99 msgid "Log in with {0}" msgstr "Log in with {0}" @@ -1184,7 +1184,7 @@ msgstr "Log out" msgid "Logging in…" msgstr "Logging in…" -#: src/pages/FederationCallback.tsx:63 +#: src/pages/FederationCallback.tsx:68 #: src/pages/UserLogin.tsx:76 msgid "Login failed" msgstr "Login failed" @@ -1390,11 +1390,11 @@ msgstr "Open the chat" msgid "or <0>browse files" msgstr "or <0>browse files" -#: src/components/federation/FederatedSignIn.tsx:82 +#: src/components/federation/FederatedSignIn.tsx:84 msgid "Or join with your account on another gerbeur" msgstr "Or join with your account on another gerbeur" -#: src/components/federation/FederatedSignIn.tsx:83 +#: src/components/federation/FederatedSignIn.tsx:85 msgid "Or log in with your account on another gerbeur" msgstr "Or log in with your account on another gerbeur" @@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?" msgstr "Sign in to {name}?" #: src/pages/Authorize.tsx:154 -#: src/pages/FederationCallback.tsx:73 +#: src/pages/FederationCallback.tsx:78 msgid "Signing you in…" msgstr "Signing you in…" @@ -1838,10 +1838,14 @@ msgstr "This page does not exist." msgid "This reset link is missing or malformed." msgstr "This reset link is missing or malformed." -#: src/pages/FederationCallback.tsx:38 +#: src/pages/FederationCallback.tsx:39 msgid "This sign-in link is incomplete." msgstr "This sign-in link is incomplete." +#: src/pages/FederationCallback.tsx:47 +msgid "This sign-in was started elsewhere. Start again from the login page." +msgstr "This sign-in was started elsewhere. Start again from the login page." + #: src/pages/Settings.tsx:201 msgid "This site doesn't send email, so no digest goes out." msgstr "This site doesn't send email, so no digest goes out." diff --git a/locales/fr.mjs b/locales/fr.mjs index fd83ed23..f9f69ac0 100644 --- a/locales/fr.mjs +++ b/locales/fr.mjs @@ -1 +1,3 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Déconnecter\"],\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2rwzb1\":[\"Vous ne vous êtes encore connecté à aucun autre gerbeur avec ce compte.\"],\"2wxgft\":[\"Renommer\"],\"2ygf_L\":[\"← Retour\"],\"32TndD\":[\"Bloquée\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Modifier l'e-mail\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4OjqAQ\":[\"Continuer l'édition\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5NZ3tg\":[\"Désactivation…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6C4kKS\":[\"Autres instances\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6_dCYd\":[\"Aperçu\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Rien de neuf cette semaine.\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8rPWOT\":[\"Connexion impossible\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9eEYEq\":[\"Ce lien de connexion est incomplet.\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AEfu-D\":[\"Dans les collections\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"AS7WoE\":[\"Repartir de zéro\"],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Position\"],\"AnRu_j\":[\"Annuler la réponse\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Voté\"],\"CJkEfx\":[\"Bloquer\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Ne plus recevoir le résumé hebdomadaire ?\"],\"Cj24wt\":[\"Inscription…\"],\"CnqayG\":[\"La mise à jour a échoué. Réessayez.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"E417RG\":[\"Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres.\"],\"EBU6O6\":[\"Connexion perdue — ce message n'a peut-être pas été envoyé\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"EarrCe\":[\"Rechercher un emoji\"],\"Evcm2Z\":[\"Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé.\"],\"EwVrWF\":[\"Le dump n'a pas pu être retiré. Réessayez.\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FdTlEG\":[\"Piste \",[\"0\"]],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GCWCCo\":[\"Fermer le lecteur\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GY6uSc\":[\"Suivant →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"Gv5nA5\":[\"Format de réponse inattendu\"],\"H4MGkI\":[\"Agrandir le lecteur\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"H_PnT8\":[\"Insérer un emoji\"],\"I-x669\":[\"Invités\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"JrFTcr\":[\"Connexion…\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"KDw4GX\":[\"Réessayer\"],\"KKZxBU\":[\"Envoi \",[\"0\"],\" %\"],\"K_F6pa\":[\"Enregistrement…\"],\"KtYF-t\":[\"Sons\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"L7_rsx\":[\"Envoi de l'image…\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"MbkoRn\":[\"Ou rejoignez-nous avec votre compte d'un autre gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"MrIc3x\":[\"Ou connectez-vous avec votre compte d'un autre gerbeur\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"QctOgc\":[\"Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RLe7Vk\":[\"Vérification…\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"RvVJBH\":[\"Actualisation du flux…\"],\"S1xV5B\":[[\"maxLength\"],\" caractères au maximum.\"],\"SBTElJ\":[\"Recherche…\"],\"SLaP1b\":[\"Compte d'ailleurs\"],\"SMaFdc\":[\"Se désabonner\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T8ejtZ\":[\"Ce commentaire n'a pas pu être supprimé. Réessayez.\"],\"T91vKp\":[\"Lecture\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Votre compte est sur \",[\"home\"],\" : connectez-vous aux autres instances depuis là-bas.\"],\"TAtbho\":[\"Chargement des messages…\"],\"TN382O\":[\"Lien invalide\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") demande à vous connecter avec votre compte d'ici, <1>@\",[\"username\"],\". Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar.\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOV0wi\":[\"Connexion…\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VsMqsD\":[\"Son adresse, comme music.example\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W5A0Ly\":[\"Une erreur inattendue s'est produite.\"],\"W9FRBT\":[\"Aimer\"],\"WAcnnR\":[\"L'image n'a pas pu être envoyée.\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WMw63e\":[\"Impossible de joindre cette instance\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XHqhoS\":[\"Impossible de charger le chat.\"],\"XJVYo8\":[\"Pourquoi & où\"],\"XJy2oN\":[\"Connexion…\"],\"XWbTfu\":[\"utilisée \",[\"0\"]],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YWnSuq\":[\"Ce lien de désabonnement est absent ou mal formé.\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"Z5HWHd\":[\"Activés\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZWxjuD\":[\"Certaines images n'ont pas pu être envoyées.\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_0oJwI\":[\"Approuver\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_I1_HN\":[\"Minuscules, chiffres et tirets simples seulement\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Se connecter ailleurs\"],\"_karOe\":[\"Hors ligne — message non envoyé\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"az8lvo\":[\"Désactivés\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"b_1P1D\":[\"Renommage…\"],\"b_VouM\":[\"Votre compte est sur \",[\"home\"],\" : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas.\"],\"bzSI52\":[\"Abandonner\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cNu4my\":[[\"0\"],\" de plus en ligne : \",[\"restNames\"]],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"dau1Pl\":[[\"unseenCount\"],\" nouveaux messages\"],\"digest.community\":[\"Communauté\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"digest.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"digest.invitedByYou\":[\"sur votre invitation\"],\"digest.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"digest.newMembers\":[\"Nouveaux membres\"],\"digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"digest.topMonth\":[\"Les plus votées du mois\"],\"digest.topWeek\":[\"Les plus votées de la semaine\"],\"digest.weekly\":[\"Résumé hebdomadaire\"],\"digest.weekly.off\":[\"Ne plus recevoir le résumé hebdomadaire\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"email.digest.addedTo\":[\"ajoutée à \",[\"playlist\"]],\"email.digest.andMore\":[\"et \",[\"n\"],\" de plus\"],\"email.digest.by\":[\"par @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message cette semaine\"],\"other\":[\"#\",\" messages cette semaine\"]}]],\"email.digest.communityPage\":[\"Tout voir sur la page de la communauté\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"nouvel abonné\"],\"other\":[\"nouveaux abonnés\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"j'aime sur vos commentaires\"],\"other\":[\"j'aime sur vos commentaires\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"abonnement à vos collections\"],\"other\":[\"abonnements à vos collections\"]}]],\"email.digest.forYou\":[\"Pour vous\"],\"email.digest.hl.comment\":[[\"who\"],\" a commenté votre reco \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" a aimé votre commentaire sur \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" vous a mentionné dans un commentaire sur \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" a mentionné tout le monde dans \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" vous a mentionné dans \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" vous a mentionné dans la collection \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" a suivi votre collection \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" vous suit désormais\"],\"email.digest.inChat\":[\"Dans la tribune\"],\"email.digest.monthly.reason\":[\"Vous recevez ce récap mensuel car il est activé dans vos paramètres.\"],\"email.digest.monthly.subject\":[\"Le mois sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"Le mois sur \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" de plus dans votre fil Suivi\"],\"email.digest.network\":[\"De ceux que vous suivez\"],\"email.digest.openChat\":[\"Ouvrir la tribune\"],\"email.digest.playlistMeta\":[\"par @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouveau membre\"],\"other\":[\"#\",\" nouveaux membres\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle reco de ceux que vous suivez\"],\"other\":[\"#\",\" nouvelles recos de ceux que vous suivez\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle collection\"],\"other\":[\"#\",\" nouvelles collections\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus votée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus votées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus votée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus votées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" vote\"],\"other\":[\"#\",\" votes\"]}],\" et \",[\"comments\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}],\".\"],\"email.digest.upload\":[\"fichier\"],\"email.digest.weekly.reason\":[\"Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres.\"],\"email.digest.weekly.subject\":[\"Votre semaine sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Votre semaine, \",[\"username\"]],\"email.digest.yours\":[\"à vous\"],\"email.reset.body\":[\"Quelqu'un a demandé à réinitialiser le mot de passe de votre compte \",[\"site\"],\". Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.button\":[\"Choisir un nouveau mot de passe\"],\"email.reset.fallback\":[\"Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :\"],\"email.reset.hi\":[\"Bonjour \",[\"username\"],\",\"],\"email.reset.ignore\":[\"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.\"],\"email.reset.preheader\":[\"Le lien ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.reason\":[\"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.\"],\"email.reset.subject\":[\"Réinitialisez votre mot de passe \",[\"site\"]],\"email.reset.title\":[\"Réinitialiser votre mot de passe\"],\"email.settings\":[\"Paramètres\"],\"email.welcome.body\":[\"# Bienvenue sur \",[\"site\"],\" !\\n\\nBonjour **\",[\"username\"],\"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !\"],\"email.welcome.button\":[\"Ouvrir \",[\"site\"]],\"email.welcome.digests\":[\"Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus votées. Vous pouvez désactiver l'un ou l'autre dans vos paramètres.\"],\"email.welcome.preheader\":[\"Votre compte est prêt.\"],\"email.welcome.reason\":[\"Vous recevez cet e-mail car un compte \",[\"site\"],\" a été créé avec cette adresse.\"],\"email.welcome.subject\":[\"Bienvenue sur \",[\"site\"]],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"f3cEDO\":[\"Changer l'image\"],\"fC6mXb\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"fn1ZQX\":[\"Se connecter à \",[\"name\"],\" ?\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gIpLuy\":[\"Il faut un lien\"],\"gL9KKi\":[\"Qui peut la voir\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"goWEhR\":[\"Piste précédente\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Retour à la page de connexion\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jTGs8A\":[\"Ce site n'envoie pas d'e-mails, aucun résumé ne part donc.\"],\"jZNg18\":[\"Compte sur <0>\",[\"host\"],\"\"],\"jbernk\":[\"Chargement du profil…\"],\"jnrIbz\":[\"Voté (\",[\"0\"],\")\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"k4b5_X\":[\"modifié\"],\"kLttbL\":[\"Inscription échouée\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"m9Uf3I\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d'une invitation.\"],\"mDJo8W\":[\"Recos (\",[\"0\"],\")\"],\"mb_Wju\":[\"Déposez un fichier ici, ou collez-en un\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" a mentionné tout le monde dans <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"oW3yYY\":[\"Ce mois-ci\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pd3FCq\":[\"Ce fichier dépasse \",[\"limit\"],\".\"],\"pvnfJD\":[\"Sombre\"],\"q018gJ\":[\"Impossible d'ajouter cette instance\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"qVdjD5\":[\"Approuvée\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qnUJBI\":[\"Le nouvel ordre n'a pas pu être enregistré. Réessayez.\"],\"qsqt0w\":[\"Lien d'invitation\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rn6SBY\":[\"Rétablir le son\"],\"sEXJPi\":[\"Lien ou fichier\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"sgX7B-\":[\"Usage unique, valable 7 jours.\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"tOHgQw\":[\"Continuer en tant que @\",[\"username\"]],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"tyiiHe\":[\"Rien de neuf ce mois-ci.\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"u8pttd\":[\"Le changer sur \",[\"home\"]],\"uACFmj\":[\"Se connecter avec \",[\"0\"]],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uISnsY\":[\"Suppression impossible\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"v-Wp--\":[\"Ouvrir la source\"],\"v6Lbin\":[\"L'adresse d'une instance, comme music.example\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"w61W3L\":[\"Retirer \",[\"0\"]],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xGVfLh\":[\"Continuer\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y1G_DI\":[\"Renommage impossible\"],\"y6sq5j\":[\"Abonné\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yByRxz\":[\"Cette semaine\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yxlcv5\":[\"Ne plus recevoir le récap mensuel ?\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}"); \ No newline at end of file +/*eslint-disable*/ export const messages = JSON.parse( + '{"-K0AvT":["Déconnecter"],"-K9EZb":["Ajouter un e-mail…"],"-Ya-b9":["Enregistrement échoué"],"-iDCtG":["Vos paramètres"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2rwzb1":["Vous ne vous êtes encore connecté à aucun autre gerbeur avec ce compte."],"2wxgft":["Renommer"],"2ygf_L":["← Retour"],"32TndD":["Bloquée"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5NZ3tg":["Désactivation…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6C4kKS":["Autres instances"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6_dCYd":["Aperçu"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7noMWp":["Rien de neuf cette semaine."],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"8JplJJ":["Désabonné"],"8Ug9jB":["Connexe"],"8ZsakT":["Mot de passe"],"8rPWOT":["Connexion impossible"],"92o1-U":["Suppression échouée"],"9BruTc":["Pourquoi ?"],"9eEYEq":["Ce lien de connexion est incomplet."],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AEfu-D":["Dans les collections"],"AHZflp":["Tribune"],"AQbgNR":["Suivre ",["targetUsername"]],"AS7WoE":["Repartir de zéro"],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"AhNP40":["Position"],"AnRu_j":["Annuler la réponse"],"AwUsnG":["Instances"],"B4LeYP":["Échec de la suppression (",["0"],")"],"BjitBo":["Télécharger ",["0"]],"BlpJ2G":["Reprise de votre brouillon."],"CI50ct":["Voté"],"CJkEfx":["Bloquer"],"C_qQE4":["Déjà recommandé par ",["0"]," ",["1"]],"Cf_gxi":["Ne plus recevoir le résumé hebdomadaire ?"],"Cj24wt":["Inscription…"],"CnqayG":["La mise à jour a échoué. Réessayez."],"D8DRYV":["Supprimer ce message ?"],"DI3NBO":["Ouvrir sur ",["0"]],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"]," a voté pour <1>",["1"],""],"E417RG":["Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres."],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"Gv5nA5":["Format de réponse inattendu"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IF7RiR":["Couverture"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"IhK90M":["Lettres, chiffres et tirets bas seulement"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JLasL-":["Désabonnement impossible"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"]," a commenté sur <1>",["1"],""],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"JrFTcr":["Connexion…"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"MbkoRn":["Ou rejoignez-nous avec votre compte d\'un autre gerbeur"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"MrIc3x":["Ou connectez-vous avec votre compte d\'un autre gerbeur"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"Nu4oKW":["Description"],"O32mmL":["Modifier la description"],"O3oNi5":["E-mail"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"P5MuiB":["Ajouter une image"],"PJTy3N":[["totalUnread","plural",{"one":["#"," nouvelle"],"other":["#"," nouvelles"]}]],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"QctOgc":["Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RLe7Vk":["Vérification…"],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"SLaP1b":["Compte d\'ailleurs"],"SMaFdc":["Se désabonner"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TAkYGZ":["Votre compte est sur ",["home"]," : connectez-vous aux autres instances depuis là-bas."],"TAtbho":["Chargement des messages…"],"TN382O":["Lien invalide"],"ToADL_":["<0>",["name"]," (",["host"],") demande à vous connecter avec votre compte d\'ici, <1>@",["username"],". Il recevra votre nom d\'utilisateur, votre adresse e-mail et votre avatar."],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOV0wi":["Connexion…"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"V38P77":["Seuls son auteur et les modérateurs peuvent modifier ce dump."],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VsMqsD":["Son adresse, comme music.example"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W5A0Ly":["Une erreur inattendue s\'est produite."],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WMw63e":["Impossible de joindre cette instance"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"XWbTfu":["utilisée ",["0"]],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YWnSuq":["Ce lien de désabonnement est absent ou mal formé."],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"]," a aimé votre commentaire sur <1>",["1"],""],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_0oJwI":["Approuver"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_I1_HN":["Minuscules, chiffres et tirets simples seulement"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_eZzBf":["Se connecter ailleurs"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"b_1P1D":["Renommage…"],"b_VouM":["Votre compte est sur ",["home"]," : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d\'utilisateur se changent là-bas."],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"digest.community":["Communauté"],"digest.count.comments":[["n","plural",{"one":["commentaire"],"other":["commentaires"]}]],"digest.count.upvotes":[["n","plural",{"one":["vote"],"other":["votes"]}]],"digest.invitedBy":["sur invitation de @",["username"]],"digest.invitedByYou":["sur votre invitation"],"digest.monthly":["Récap mensuel"],"digest.monthly.off":["Ne plus recevoir le récap mensuel"],"digest.newMembers":["Nouveaux membres"],"digest.newPlaylists":["Nouvelles collections publiques"],"digest.stat.chat":[["n","plural",{"one":["message dans la tribune"],"other":["messages dans la tribune"]}]],"digest.stat.dumps":[["n","plural",{"one":["reco"],"other":["recos"]}]],"digest.topMonth":["Les plus votées du mois"],"digest.topWeek":["Les plus votées de la semaine"],"digest.weekly":["Résumé hebdomadaire"],"digest.weekly.off":["Ne plus recevoir le résumé hebdomadaire"],"dqwCfR":["Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l\'aperçu plus tard."],"dznB-Y":["Seules les images peuvent être déposées ici."],"eFSqvc":["Impossible de publier la réponse"],"eIEHHV":["Changer la couleur de peau"],"eOfXq3":["Un titre est requis."],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"eloqXQ":["Vous pouvez la publier quand même."],"email.digest.addedTo":["ajoutée à ",["playlist"]],"email.digest.andMore":["et ",["n"]," de plus"],"email.digest.by":["par @",["username"]],"email.digest.chatWeek":[["n","plural",{"one":["#"," message cette semaine"],"other":["#"," messages cette semaine"]}]],"email.digest.communityPage":["Tout voir sur la page de la communauté"],"email.digest.count.followers":[["n","plural",{"one":["nouvel abonné"],"other":["nouveaux abonnés"]}]],"email.digest.count.likes":[["n","plural",{"one":["j\'aime sur vos commentaires"],"other":["j\'aime sur vos commentaires"]}]],"email.digest.count.mentions":[["n","plural",{"one":["mention"],"other":["mentions"]}]],"email.digest.count.playlistFollows":[["n","plural",{"one":["abonnement à vos collections"],"other":["abonnements à vos collections"]}]],"email.digest.forYou":["Pour vous"],"email.digest.hl.comment":[["who"]," a commenté votre reco ",["target"]],"email.digest.hl.commentLike":[["who"]," a aimé votre commentaire sur ",["target"]],"email.digest.hl.mentionComment":[["who"]," vous a mentionné dans un commentaire sur ",["target"]],"email.digest.hl.mentionEveryone":[["who"]," a mentionné tout le monde dans ",["target"]],"email.digest.hl.mentionIn":[["who"]," vous a mentionné dans ",["target"]],"email.digest.hl.mentionPlaylist":[["who"]," vous a mentionné dans la collection ",["target"]],"email.digest.hl.playlistFollow":[["who"]," a suivi votre collection ",["target"]],"email.digest.hl.userFollow":[["who"]," vous suit désormais"],"email.digest.inChat":["Dans la tribune"],"email.digest.monthly.reason":["Vous recevez ce récap mensuel car il est activé dans vos paramètres."],"email.digest.monthly.subject":["Le mois sur ",["site"],", ",["range"]],"email.digest.monthly.title":["Le mois sur ",["site"]],"email.digest.more":[["n"]," de plus dans votre fil Suivi"],"email.digest.network":["De ceux que vous suivez"],"email.digest.openChat":["Ouvrir la tribune"],"email.digest.playlistMeta":["par @",["username"]," · ",["n","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"email.digest.pre.members":[["n","plural",{"one":["#"," nouveau membre"],"other":["#"," nouveaux membres"]}]],"email.digest.pre.network":[["n","plural",{"one":["#"," nouvelle reco de ceux que vous suivez"],"other":["#"," nouvelles recos de ceux que vous suivez"]}]],"email.digest.pre.playlists":[["n","plural",{"one":["#"," nouvelle collection"],"other":["#"," nouvelles collections"]}]],"email.digest.pre.topMonth":[["n","plural",{"one":["la reco la plus votée du mois"],"other":["les ","#"," recos les plus votées du mois"]}]],"email.digest.pre.topWeek":[["n","plural",{"one":["la reco la plus votée de la semaine"],"other":["les ","#"," recos les plus votées de la semaine"]}]],"email.digest.seeAll":[["n","plural",{"one":["Voir la notification"],"other":["Voir les ","#"," notifications"]}]],"email.digest.theChat":["la tribune"],"email.digest.totals":[["dumps","plural",{"one":["#"," reco"],"other":["#"," recos"]}],", ",["upvotes","plural",{"one":["#"," vote"],"other":["#"," votes"]}]," et ",["comments","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}],"."],"email.digest.upload":["fichier"],"email.digest.weekly.reason":["Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres."],"email.digest.weekly.subject":["Votre semaine sur ",["site"],", ",["range"]],"email.digest.weekly.title":["Votre semaine, ",["username"]],"email.digest.yours":["à vous"],"email.reset.body":["Quelqu\'un a demandé à réinitialiser le mot de passe de votre compte ",["site"],". Le bouton ci-dessous vous permet d\'en choisir un nouveau. Il ne fonctionne qu\'une fois, pendant ",["hours","plural",{"one":["#"," heure"],"other":["#"," heures"]}],"."],"email.reset.button":["Choisir un nouveau mot de passe"],"email.reset.fallback":["Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :"],"email.reset.hi":["Bonjour ",["username"],","],"email.reset.ignore":["Si vous n\'êtes pas à l\'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas."],"email.reset.preheader":["Le lien ne fonctionne qu\'une fois, pendant ",["hours","plural",{"one":["#"," heure"],"other":["#"," heures"]}],"."],"email.reset.reason":["Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse."],"email.reset.subject":["Réinitialisez votre mot de passe ",["site"]],"email.reset.title":["Réinitialiser votre mot de passe"],"email.settings":["Paramètres"],"email.welcome.body":["# Bienvenue sur ",["site"]," !\\n\\nBonjour **",["username"],"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !"],"email.welcome.button":["Ouvrir ",["site"]],"email.welcome.digests":["Chaque semaine, vous recevrez un résumé de ce qui s\'est passé ici, et chaque mois un récap des recos les plus votées. Vous pouvez désactiver l\'un ou l\'autre dans vos paramètres."],"email.welcome.preheader":["Votre compte est prêt."],"email.welcome.reason":["Vous recevez cet e-mail car un compte ",["site"]," a été créé avec cette adresse."],"email.welcome.subject":["Bienvenue sur ",["site"]],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"f3cEDO":["Changer l\'image"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"fgLNSM":["S\'inscrire"],"fn1ZQX":["Se connecter à ",["name"]," ?"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gIpLuy":["Il faut un lien"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hLAe-z":["Retour à la page de connexion"],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jTGs8A":["Ce site n\'envoie pas d\'e-mails, aucun résumé ne part donc."],"jZNg18":["Compte sur <0>",["host"],""],"jbernk":["Chargement du profil…"],"jnrIbz":["Voté (",["0"],")"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"m9Uf3I":["Les utilisateurs d\'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d\'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d\'une invitation."],"mDJo8W":["Recos (",["0"],")"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nucyoV":["<0>",["0"]," a mentionné tout le monde dans <1>",["where"],""],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"oW3yYY":["Ce mois-ci"],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pd3FCq":["Ce fichier dépasse ",["limit"],"."],"pvnfJD":["Sombre"],"q018gJ":["Impossible d\'ajouter cette instance"],"qIMfNQ":["Supprimer la collection"],"qItrqH":["Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres."],"qVdjD5":["Approuvée"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qnUJBI":["Le nouvel ordre n\'a pas pu être enregistré. Réessayez."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"tOHgQw":["Continuer en tant que @",["username"]],"tXNIqW":["Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion."],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"tyiiHe":["Rien de neuf ce mois-ci."],"u1lDX2":["Récupération de l\'aperçu…"],"u8pttd":["Le changer sur ",["home"]],"uACFmj":["Se connecter avec ",["0"]],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"v6Lbin":["L\'adresse d\'une instance, comme music.example"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"w61W3L":["Retirer ",["0"]],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"wtxFp4":["Fichier trop volumineux (max ",["max"],")."],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xGVfLh":["Continuer"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y1G_DI":["Renommage impossible"],"y6sq5j":["Abonné"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yByRxz":["Cette semaine"],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yxlcv5":["Ne plus recevoir le récap mensuel ?"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', +); diff --git a/locales/fr.po b/locales/fr.po index 287c9dfc..6ca5c910 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -391,7 +391,7 @@ msgstr "Auto" msgid "Back to login" msgstr "Retour à la connexion" -#: src/pages/FederationCallback.tsx:66 +#: src/pages/FederationCallback.tsx:71 msgid "Back to the login page" msgstr "Retour à la page de connexion" @@ -545,7 +545,7 @@ msgstr "Communauté" msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" -#: src/components/federation/FederatedSignIn.tsx:111 +#: src/components/federation/FederatedSignIn.tsx:113 msgid "Connecting…" msgstr "Connexion…" @@ -553,7 +553,7 @@ msgstr "Connexion…" msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" -#: src/components/federation/FederatedSignIn.tsx:113 +#: src/components/federation/FederatedSignIn.tsx:115 msgid "Continue" msgstr "Continuer" @@ -602,7 +602,7 @@ msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publi msgid "Couldn't load the chat." msgstr "Impossible de charger le chat." -#: src/components/federation/FederatedSignIn.tsx:102 +#: src/components/federation/FederatedSignIn.tsx:104 msgid "Couldn't reach that instance" msgstr "Impossible de joindre cette instance" @@ -1066,7 +1066,7 @@ msgstr "sur votre invitation" msgid "Invitees" msgstr "Invités" -#: src/components/federation/FederatedSignIn.tsx:105 +#: src/components/federation/FederatedSignIn.tsx:107 msgid "Its address, like music.example" msgstr "Son adresse, comme music.example" @@ -1155,7 +1155,7 @@ msgid "Loading…" msgstr "Chargement…" #: src/components/layout/AppHeader.tsx:208 -#: src/pages/FederationCallback.tsx:31 +#: src/pages/FederationCallback.tsx:32 #: src/pages/UserLogin.tsx:44 #: src/pages/UserLogin.tsx:71 #: src/pages/UserLogin.tsx:90 @@ -1171,7 +1171,7 @@ msgid "Log in to vote" msgstr "Se connecter pour voter" #. placeholder {0}: peer.name ?? peer.host -#: src/components/federation/FederatedSignIn.tsx:97 +#: src/components/federation/FederatedSignIn.tsx:99 msgid "Log in with {0}" msgstr "Se connecter avec {0}" @@ -1184,7 +1184,7 @@ msgstr "Se déconnecter" msgid "Logging in…" msgstr "Connexion…" -#: src/pages/FederationCallback.tsx:63 +#: src/pages/FederationCallback.tsx:68 #: src/pages/UserLogin.tsx:76 msgid "Login failed" msgstr "Connexion échouée" @@ -1390,11 +1390,11 @@ msgstr "Ouvrir la tribune" msgid "or <0>browse files" msgstr "ou <0>parcourir les fichiers" -#: src/components/federation/FederatedSignIn.tsx:82 +#: src/components/federation/FederatedSignIn.tsx:84 msgid "Or join with your account on another gerbeur" msgstr "Ou rejoignez-nous avec votre compte d'un autre gerbeur" -#: src/components/federation/FederatedSignIn.tsx:83 +#: src/components/federation/FederatedSignIn.tsx:85 msgid "Or log in with your account on another gerbeur" msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur" @@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?" msgstr "Se connecter à {name} ?" #: src/pages/Authorize.tsx:154 -#: src/pages/FederationCallback.tsx:73 +#: src/pages/FederationCallback.tsx:78 msgid "Signing you in…" msgstr "Connexion…" @@ -1838,10 +1838,14 @@ msgstr "Rien à voir, circulez." msgid "This reset link is missing or malformed." msgstr "Ce lien de réinitialisation est absent ou malformé." -#: src/pages/FederationCallback.tsx:38 +#: src/pages/FederationCallback.tsx:39 msgid "This sign-in link is incomplete." msgstr "Ce lien de connexion est incomplet." +#: src/pages/FederationCallback.tsx:47 +msgid "This sign-in was started elsewhere. Start again from the login page." +msgstr "Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion." + #: src/pages/Settings.tsx:201 msgid "This site doesn't send email, so no digest goes out." msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc." diff --git a/src/components/federation/FederatedSignIn.tsx b/src/components/federation/FederatedSignIn.tsx index 7e370e0e..922791fb 100644 --- a/src/components/federation/FederatedSignIn.tsx +++ b/src/components/federation/FederatedSignIn.tsx @@ -17,6 +17,7 @@ import type { FederationLoginResponse, FederationPeer, } from "../../../shared/federation.ts"; +import { rememberSignIn } from "../../utils/auth/federation.ts"; import { dataOr, expectOk } from "../../utils/shared/api.ts"; import { deserializeFederationPeer } from "../../utils/shared/deserialize.ts"; import { useApiForm } from "../../hooks/form/useApiForm.ts"; @@ -72,6 +73,7 @@ export function FederatedSignIn( ), }), ); + rememberSignIn(authorizeUrl); globalThis.location.assign(authorizeUrl); }); diff --git a/src/pages/FederationCallback.tsx b/src/pages/FederationCallback.tsx index f4fca82d..edd50908 100644 --- a/src/pages/FederationCallback.tsx +++ b/src/pages/FederationCallback.tsx @@ -15,6 +15,7 @@ import type { Wire } from "../../shared/api.ts"; import type { FederationSessionRequest } from "../../shared/federation.ts"; import type { AuthResponse } from "../../shared/user.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; +import { forgetSignIn, startedHere } from "../utils/auth/federation.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; @@ -23,9 +24,9 @@ import { deserializeAuthResponse } from "../utils/shared/deserialize.ts"; /** * Renders `/federation/callback?code=&state=`. It finishes the sign-in - * (`POST /api/federation/sessions`), once: a code works only once, and a - * second request would fail the sign-in. On success the user lands on `/`, - * signed in. + * (`POST /api/federation/sessions`) if this tab started it (`startedHere`), + * and once: a code works only once, and a second request would fail the + * sign-in. On success the user lands on `/`, signed in. */ export function FederationCallback(): React.JSX.Element { useDocumentTitle(t`Log in`); @@ -34,14 +35,19 @@ export function FederationCallback(): React.JSX.Element { const { login } = useAuth(); const code = params.get("code"); const state = params.get("state"); - const [error, setError] = useState( - code && state ? null : t`This sign-in link is incomplete.`, + const [error, setError] = useState(() => + !code || !state + ? t`This sign-in link is incomplete.` + : !startedHere(state) + ? t`This sign-in was started elsewhere. Start again from the login page.` + : null ); const sent = useRef(false); useEffect(() => { - if (sent.current || !code || !state) return; + if (sent.current || error || !code || !state) return; sent.current = true; + forgetSignIn(); fetch(`${API_URL}/api/federation/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -53,7 +59,7 @@ export function FederationCallback(): React.JSX.Element { navigate("/", { replace: true }); }) .catch((err) => setError(friendlyFetchError(err))); - }, [code, state, login, navigate]); + }, [code, state, error, login, navigate]); return ( diff --git a/src/test/pages/FederationCallback.test.tsx b/src/test/pages/FederationCallback.test.tsx index 39a714c6..22a1acd2 100644 --- a/src/test/pages/FederationCallback.test.tsx +++ b/src/test/pages/FederationCallback.test.tsx @@ -7,7 +7,9 @@ import { calls, fail, route } from "../fetch.ts"; import { makeSession, wire } from "../fixtures.ts"; import { renderApp } from "../render.tsx"; -function open(query: string) { +/** Opens the callback, in a tab that started the sign-in of state `s`. */ +function open(query: string, started = "s") { + sessionStorage.setItem("federationState", started); renderApp(, { route: `/federation/callback${query}`, path: "/federation/callback", @@ -42,6 +44,16 @@ describe("FederationCallback", () => { .toHaveAttribute("href", "/login"); }); + test("a sign-in this tab didn't start is refused", async () => { + open("?code=c&state=theirs"); + expect( + await screen.findByText( + "This sign-in was started elsewhere. Start again from the login page.", + ), + ).toBeInTheDocument(); + expect(calls("POST /api/federation/sessions")).toHaveLength(0); + }); + test("a link without its code says so", async () => { open("?state=s"); expect(await screen.findByText("This sign-in link is incomplete.")) diff --git a/src/test/pages/UserLogin.test.tsx b/src/test/pages/UserLogin.test.tsx index d96007f2..9fe76090 100644 --- a/src/test/pages/UserLogin.test.tsx +++ b/src/test/pages/UserLogin.test.tsx @@ -167,7 +167,7 @@ describe("UserLogin", () => { route("POST /api/federation/logins", (req) => ({ authorizeUrl: `https://${ (req.body as { instance: string }).instance.replace(/^https:\/\//, "") - }/authorize?x`, + }/authorize?state=s1`, })); const user = setup(); await user.click( @@ -175,9 +175,11 @@ describe("UserLogin", () => { ); await waitFor(() => expect(assign).toHaveBeenCalledWith( - "https://music.example/authorize?x", + "https://music.example/authorize?state=s1", ) ); + // The callback finishes only the sign-in this tab started. + expect(sessionStorage.getItem("federationState")).toBe("s1"); expect(calls("POST /api/federation/logins")[0].body).toEqual({ instance: "https://music.example", }); @@ -191,7 +193,7 @@ describe("UserLogin", () => { ); await waitFor(() => expect(assign).toHaveBeenLastCalledWith( - "https://films.example/authorize?x", + "https://films.example/authorize?state=s1", ) ); }); diff --git a/src/utils/auth/federation.ts b/src/utils/auth/federation.ts new file mode 100644 index 00000000..e870523c --- /dev/null +++ b/src/utils/auth/federation.ts @@ -0,0 +1,42 @@ +/** + * Ties a federated sign-in to the tab that started it, so that a link to the + * callback page made from someone else's sign-in can't sign this browser in + * to their account (login CSRF). `FederatedSignIn` keeps the sign-in's state + * before leaving for the other instance, in sessionStorage, which outlives + * the trip there and back in one tab, and `FederationCallback` finishes only + * a sign-in whose state it finds. + * + * @module + */ + +const STATE_KEY = "federationState"; + +/** Keeps the state of the sign-in that `authorizeUrl` starts. */ +export function rememberSignIn(authorizeUrl: string): void { + try { + sessionStorage.setItem( + STATE_KEY, + new URL(authorizeUrl).searchParams.get("state") ?? "", + ); + } catch { + // Without sessionStorage, the callback refuses the sign-in. + } +} + +/** Returns whether `state` is that of the sign-in this tab started. */ +export function startedHere(state: string): boolean { + try { + return sessionStorage.getItem(STATE_KEY) === state; + } catch { + return false; + } +} + +/** Forgets the sign-in this tab started, once it is being finished. */ +export function forgetSignIn(): void { + try { + sessionStorage.removeItem(STATE_KEY); + } catch { + // Nothing was kept. + } +}