v4: a deployment chooses which ways federated sign-in goes — every instance took users of the others and let its own sign in to them, with no way to turn either off. GERBEUR_FEDERATION_INCOMING=0 keeps users of other instances out: the login and register pages offer no other instance, /manage no list of instances, their sign-ins are refused, and those already in are signed out until it is on again, their homes' events still applied meanwhile. GERBEUR_FEDERATION_OUTGOING=0 keeps the instance's users from signing in elsewhere: its /authorize page lets no instance sign them in, its metadata says so (gerbeur_outgoing), which other instances check before sending anyone there, the settings page lists no instances, and at startup every grant is withdrawn, which signs them out there. Both are on by default and reach the client through index.html. The dev instance gerbeur (:3000) takes no users of the others, while its own still sign in to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 16:18:21 +00:00
parent 66e402bd91
commit 83dc44050e
26 changed files with 420 additions and 121 deletions

View File

@@ -71,6 +71,12 @@ GERBEUR_DEFAULT_STYLE=smooth
# How often, in minutes, the /community page's figures are recomputed. # How often, in minutes, the /community page's figures are recomputed.
GERBEUR_COMMUNITY_REFRESH_MINUTES=15 GERBEUR_COMMUNITY_REFRESH_MINUTES=15
# Set to 0 to keep users of other gerbeur instances from signing in here, which
# also signs out those already in until it is set back, or to keep this
# instance's users from signing in to others, which signs them out there.
# GERBEUR_FEDERATION_INCOMING=1
# GERBEUR_FEDERATION_OUTGOING=1
# Set to 1 to let federated sign-in reach other instances on private addresses # Set to 1 to let federated sign-in reach other instances on private addresses
# (localhost included) and over plain http, so that two instances on one # (localhost included) and over plain http, so that two instances on one
# machine can trust each other in development. Never in production. # machine can trust each other in development. Never in production.

4
API.md
View File

@@ -211,7 +211,9 @@ there: those endpoints answer it 400.
Federated sign-in (`shared/federation.ts`): a user signs in here with their Federated sign-in (`shared/federation.ts`): a user signs in here with their
account on another gerbeur instance, their home, by OpenID Connect's account on another gerbeur instance, their home, by OpenID Connect's
authorization code flow with PKCE, with tokens signed by each instance's Ed25519 authorization code flow with PKCE, with tokens signed by each instance's Ed25519
key. key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and
`/sessions` answer 403; with `GERBEUR_FEDERATION_OUTGOING=0`,
`GET /api/oauth/clients` and `POST /api/oauth/codes` do.
- `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone. - `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone.
The instance's metadata and public keys, bare JSON: the one exception to the The instance's metadata and public keys, bare JSON: the one exception to the

View File

@@ -77,6 +77,8 @@ variables:
| `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` | | `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` |
| `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` | | `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` |
| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template | | `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template |
| `GERBEUR_FEDERATION_INCOMING` | `0` keeps users of other instances from signing in here, and signs out those already in | `1` |
| `GERBEUR_FEDERATION_OUTGOING` | `0` keeps this instance's users from signing in to other instances, and signs them out there | `1` |
| `GERBEUR_FEDERATION_ALLOW_PRIVATE` | `1` lets federated sign-in reach instances on private addresses and over http — for two instances on one machine, never in production | unset | | `GERBEUR_FEDERATION_ALLOW_PRIVATE` | `1` lets federated sign-in reach instances on private addresses and over http — for two instances on one machine, never in production | unset |
| `GERBEUR_COMMUNITY_REFRESH_MINUTES` | How often, in minutes, the `/community` page's figures are recomputed | `15` | | `GERBEUR_COMMUNITY_REFRESH_MINUTES` | How often, in minutes, the `/community` page's figures are recomputed | `15` |
| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` | | `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` |
@@ -150,7 +152,10 @@ home instance, and the others follow its changes.
Every instance is ready for it: nothing to configure, as long as Every instance is ready for it: nothing to configure, as long as
`GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances `GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances
can reach each other. An admin decides who comes in, on `/manage`: can reach each other. Either way can be turned off:
`GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances, and
`GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. An admin decides
who comes in, on `/manage`:
- users of a **trusted** instance sign in directly, from a button on the login - users of a **trusted** instance sign in directly, from a button on the login
page; page;
@@ -160,7 +165,8 @@ can reach each other. An admin decides who comes in, on `/manage`:
`deno task dev` runs several instances on one machine (see [Run](#run)), with `deno task dev` runs several instances on one machine (see [Run](#run)), with
`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over `GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over
plain http on localhost. Never set it in production. plain http on localhost. Never set it in production. The first, `gerbeur`, takes
no users of the others, whose users sign in to each other.
## Project structure ## Project structure

View File

@@ -232,3 +232,26 @@ export const ALLOWED_ORIGINS: string[] = Array.from(
*/ */
export const FEDERATION_ALLOW_PRIVATE: boolean = export const FEDERATION_ALLOW_PRIVATE: boolean =
Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1"; Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1";
/**
* Which ways federated sign-in goes, each on unless set to `0`.
*
* - `incoming` (`GERBEUR_FEDERATION_INCOMING`): users of other instances sign
* in here. Off, the login and register pages offer no other instance,
* `/manage` no list of instances to trust or block,
* `federation-service.ts` refuses their sign-ins, and the accounts that came
* from one are signed out (`getSessionState`) until it is on again. Their
* homes' events are still applied, so that none is lost meanwhile.
* - `outgoing` (`GERBEUR_FEDERATION_OUTGOING`): this instance's users sign in
* to others. Off, `identity-provider-service.ts` lets no instance sign them
* in, and its metadata says so, which the others check before sending
* anyone here to sign in. The settings page lists no instances, since at
* startup `main.ts` withdraws every grant they gave, which signs them out
* there.
*
* index.html tells the client both. An object, so that the tests can turn one
* off for a while.
*/
export const FEDERATION: { incoming: boolean; outgoing: boolean } = {
incoming: Deno.env.get("GERBEUR_FEDERATION_INCOMING")?.trim() !== "0",
outgoing: Deno.env.get("GERBEUR_FEDERATION_OUTGOING")?.trim() !== "0",
};

View File

@@ -10,6 +10,7 @@ import {
BANDCAMP_PLAYER, BANDCAMP_PLAYER,
DEFAULT_STYLE, DEFAULT_STYLE,
EMAIL_ENABLED, EMAIL_ENABLED,
FEDERATION,
OG_SITE_NAME, OG_SITE_NAME,
SITE_EMOJI, SITE_EMOJI,
} from "../config.ts"; } from "../config.ts";
@@ -28,7 +29,8 @@ const indexHtml = new Map<string, { mtime: number; html: string }>();
/** /**
* Returns `${root}/index.html` with the site name, emoji, icon version, * Returns `${root}/index.html` with the site name, emoji, icon version,
* Bandcamp player mode, default style and whether email is on filled in. * Bandcamp player mode, default style, and whether email and each way of
* federated sign-in are on filled in.
* *
* @throws {Error} When the file can't be read. * @throws {Error} When the file can't be read.
*/ */
@@ -43,7 +45,9 @@ export async function loadIndexHtml(root: string): Promise<string> {
.replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off"); .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off")
.replaceAll("__FEDERATION_INCOMING__", FEDERATION.incoming ? "on" : "off")
.replaceAll("__FEDERATION_OUTGOING__", FEDERATION.outgoing ? "on" : "off");
indexHtml.set(root, { mtime, html }); indexHtml.set(root, { mtime, html });
return html; return html;
} }

View File

@@ -1,6 +1,8 @@
/** /**
* Builds the Oak application and, when run directly, starts the recurring * Builds the Oak application and, when run directly, starts the recurring
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes * jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
* the users out of other instances if they may no longer sign in there
* (`FEDERATION.outgoing`). A request goes
* through the error middleware, the OG tags and CORS, then the routers, one * through the error middleware, the OG tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend, * per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did. * which answers every path no router did.
@@ -37,7 +39,13 @@ import usersRouter from "./routes/users.ts";
import wellKnownRouter from "./routes/well-known.ts"; import wellKnownRouter from "./routes/well-known.ts";
import wsRouter from "./routes/ws.ts"; import wsRouter from "./routes/ws.ts";
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import {
ALLOWED_ORIGINS,
FEDERATION,
LISTEN_HOST,
PORT,
PUBLIC_URL,
} from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts"; import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts"; import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts"; import { routeStaticFilesFrom } from "./lib/static.ts";
@@ -46,7 +54,10 @@ import { rateLimitSweepJob } from "./lib/rate-limit.ts";
import { digestJob } from "./services/digest-service.ts"; import { digestJob } from "./services/digest-service.ts";
import { communityJob } from "./services/community-service.ts"; import { communityJob } from "./services/community-service.ts";
import { pingJob, typingExpiryJob } from "./services/ws-service.ts"; import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
import { outboxJob } from "./services/identity-provider-service.ts"; import {
outboxJob,
withdrawAllGrants,
} from "./services/identity-provider-service.ts";
/** /**
* The whole server, exported so the tests' helpers can send it requests * The whole server, exported so the tests' helpers can send it requests
@@ -115,6 +126,12 @@ app.addEventListener(
); );
if (import.meta.main) { if (import.meta.main) {
if (!FEDERATION.outgoing) {
const withdrawn = await withdrawAllGrants();
if (withdrawn > 0) {
console.log(`Signed users out of other instances (${withdrawn} grants)`);
}
}
startJobs([ startJobs([
digestJob, digestJob,
communityJob, communityJob,

View File

@@ -16,7 +16,8 @@
* Whether a user of another instance may join is the admin's call * Whether a user of another instance may join is the admin's call
* (`federation-peer-service.ts`): from a trusted instance they may, from a * (`federation-peer-service.ts`): from a trusted instance they may, from a
* blocked one they may not, and from any other only with an invite, which the * blocked one they may not, and from any other only with an invite, which the
* register page carries. Once here, an account works like any other, but its * register page carries. None may while the deployment turns sign-ins from
* other instances off (`FEDERATION.incoming`). Once here, an account works like any other, but its
* password, email, avatar and name are its home's (`assertLocalUser`). * password, email, avatar and name are its home's (`assertLocalUser`).
* *
* @module * @module
@@ -25,6 +26,7 @@
import { decodeJwt, type JSONWebKeySet } from "@panva/jose"; import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import { FEDERATION } from "../config.ts";
import { db, transaction } from "../db/connection.ts"; import { db, transaction } from "../db/connection.ts";
import { verifyInstanceToken } from "../lib/jwt.ts"; import { verifyInstanceToken } from "../lib/jwt.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
@@ -33,6 +35,7 @@ import {
type FederationSessionRequest, type FederationSessionRequest,
type IdentityClaims, type IdentityClaims,
instanceHost, instanceHost,
type InstanceMetadata,
isFederationEvent, isFederationEvent,
isIdentityClaims, isIdentityClaims,
parseInstance, parseInstance,
@@ -73,6 +76,22 @@ const LOGIN_TTL_MS = 10 * 60_000;
*/ */
const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 }; const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 };
/**
* Refuses a sign-in from another instance while the deployment turns them off
* (`FEDERATION.incoming`), as {@link startLogin} and {@link completeLogin}
* must first.
*
* @throws {APIException} 403 when they are off.
*/
function assertIncoming(): void {
if (!FEDERATION.incoming) {
throw new APIException(
403,
"This instance doesn't take accounts from other instances",
);
}
}
/** /**
* Returns the origin of the instance `instance` names, checked as a home: not * Returns the origin of the instance `instance` names, checked as a home: not
* this instance, nor a blocked one. * this instance, nor a blocked one.
@@ -98,13 +117,15 @@ function homeOf(instance: string): string {
* claimed only once the account is made; {@link completeLogin} makes no * claimed only once the account is made; {@link completeLogin} makes no
* account without one. * account without one.
* *
* @throws {APIException} 400 when the instance isn't one; 403 when it is * @throws {APIException} 400 when the instance isn't one; 403 when sign-ins
* blocked, or neither trusted nor invited; 404 or 409 when the invite doesn't * from other instances are off, or it is blocked, neither trusted nor
* work; 502 when it doesn't answer as a gerbeur. * invited, or keeps its users from signing in elsewhere; 404 or 409 when the
* invite doesn't work; 502 when it doesn't answer as a gerbeur.
*/ */
export async function startLogin( export async function startLogin(
request: FederationLoginRequest, request: FederationLoginRequest,
): Promise<string> { ): Promise<string> {
assertIncoming();
const issuer = homeOf(request.instance); const issuer = homeOf(request.instance);
if (request.inviteToken) { if (request.inviteToken) {
validateInvite(request.inviteToken); validateInvite(request.inviteToken);
@@ -121,14 +142,19 @@ export async function startLogin(
); );
} }
let authorizationEndpoint: string; let metadata: InstanceMetadata;
try { try {
authorizationEndpoint = (await fetchMetadata(issuer)) metadata = await fetchMetadata(issuer);
.authorization_endpoint;
} catch (err) { } catch (err) {
console.error(`[federation] ${issuer} unreachable:`, err); console.error(`[federation] ${issuer} unreachable:`, err);
throw new APIException(502, "That instance doesn't answer as a gerbeur"); throw new APIException(502, "That instance doesn't answer as a gerbeur");
} }
if (metadata.gerbeur_outgoing === false) {
throw new APIException(
403,
"Accounts on that instance don't sign in to other instances",
);
}
const state = randomBytes(24).toString("base64url"); const state = randomBytes(24).toString("base64url");
const verifier = randomBytes(48).toString("base64url"); const verifier = randomBytes(48).toString("base64url");
@@ -151,7 +177,7 @@ export async function startLogin(
); );
}); });
const url = new URL(authorizationEndpoint); const url = new URL(metadata.authorization_endpoint);
url.search = new URLSearchParams({ url.search = new URLSearchParams({
response_type: "code", response_type: "code",
scope: "openid profile email", scope: "openid profile email",
@@ -244,12 +270,14 @@ function checkClaims(claims: unknown, issuer: string): IdentityClaims | null {
* login was started with, claimed now. * login was started with, claimed now.
* *
* @throws {APIException} 400 when the login is unknown or expired; 401 when * @throws {APIException} 400 when the login is unknown or expired; 401 when
* the identity token doesn't verify; 403 when the account may not be created; * the identity token doesn't verify; 403 when sign-ins from other instances
* 409 when its handle is someone's; 502 when the home fails. * are off, or the account may not be created; 409 when its handle is
* someone's; 502 when the home fails.
*/ */
export async function completeLogin( export async function completeLogin(
request: FederationSessionRequest, request: FederationSessionRequest,
): Promise<User> { ): Promise<User> {
assertIncoming();
const login = db.prepare( const login = db.prepare(
`DELETE FROM federation_logins WHERE state = ? `DELETE FROM federation_logins WHERE state = ?
RETURNING issuer, code_verifier, nonce, invite_token, created_at;`, RETURNING issuer, code_verifier, nonce, invite_token, created_at;`,

View File

@@ -28,6 +28,7 @@ import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts"; import { db, transaction } from "../db/connection.ts";
import { import {
AVATARS_DIR, AVATARS_DIR,
FEDERATION,
FRONTEND_URL, FRONTEND_URL,
OG_SITE_NAME, OG_SITE_NAME,
PUBLIC_URL, PUBLIC_URL,
@@ -102,6 +103,7 @@ export function getInstanceMetadata(): InstanceMetadata {
gerbeur_site_name: OG_SITE_NAME, gerbeur_site_name: OG_SITE_NAME,
gerbeur_redirect_uri: REDIRECT_URI, gerbeur_redirect_uri: REDIRECT_URI,
gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`, gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`,
gerbeur_outgoing: FEDERATION.outgoing,
}; };
} }
@@ -143,16 +145,24 @@ export function identityClaims(user: User): IdentityClaims {
/** /**
* Returns the client `clientId` names, checked: an instance other than this * Returns the client `clientId` names, checked: an instance other than this
* one, not blocked here, answering as a gerbeur. `user` must have a local * one, not blocked here, answering as a gerbeur. `user` must have a local
* account, which alone may sign in elsewhere. * account, which alone may sign in elsewhere, and this instance must let its
* users sign in elsewhere (`FEDERATION.outgoing`).
* *
* @throws {APIException} 400 when `clientId` isn't an instance's address, is * @throws {APIException} 400 when `clientId` isn't an instance's address, is
* this instance, or `user` comes from another instance; 403 when the client * this instance, or `user` comes from another instance; 403 when this
* is blocked here; 502 when it doesn't answer as a gerbeur. * instance's users don't sign in elsewhere, or the client is blocked here;
* 502 when it doesn't answer as a gerbeur.
*/ */
async function checkClient( async function checkClient(
user: User, user: User,
clientId: string, clientId: string,
): Promise<{ clientId: string; metadata: InstanceMetadata }> { ): Promise<{ clientId: string; metadata: InstanceMetadata }> {
if (!FEDERATION.outgoing) {
throw new APIException(
403,
"Accounts on this instance don't sign in to other instances",
);
}
if (isRemoteUser(user)) { if (isRemoteUser(user)) {
throw new APIException( throw new APIException(
400, 400,
@@ -362,6 +372,24 @@ export async function revokeGrant(userId: string, host: string): Promise<void> {
).run(userId, grant.clientId); ).run(userId, grant.clientId);
} }
/**
* Withdraws every grant, as each user would theirs, and drops the codes not
* yet traded, so that no account here stays signed in elsewhere: `main.ts`
* calls it at startup when `FEDERATION.outgoing` is off. Returns how many
* grants it withdrew.
*/
export async function withdrawAllGrants(): Promise<number> {
db.prepare(`DELETE FROM oauth_codes;`).run();
const grants = db.prepare(
`SELECT user_id, client_id FROM federation_grants;`,
).all() as { user_id: string; client_id: string }[];
for (const { user_id, client_id } of grants) {
await notifyClients(user_id, { type: "revoke" }, [client_id]);
}
db.prepare(`DELETE FROM federation_grants;`).run();
return grants.length;
}
/** /**
* Tells each instance user `userId` signed in to of `event`, or only those of * Tells each instance user `userId` signed in to of `event`, or only those of
* `clientIds`: `update` when their name, email or avatar changed, `revoke` * `clientIds`: `update` when their name, email or avatar changed, `revoke`

View File

@@ -31,7 +31,7 @@ import {
updateClientAvatar, updateClientAvatar,
} from "./ws-service.ts"; } from "./ws-service.ts";
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts"; import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
import { AVATARS_DIR } from "../config.ts"; import { AVATARS_DIR, FEDERATION } from "../config.ts";
import { hashPassword } from "../lib/password.ts"; import { hashPassword } from "../lib/password.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
@@ -187,14 +187,15 @@ export function getUserById(userId: string): User {
/** /**
* Returns the user's live role and their last password change, before which * Returns the user's live role and their last password change, before which
* their tokens don't count, or undefined when the user is gone, or comes from * their tokens don't count, or undefined when the user is gone, or comes from
* an instance an admin blocked. `lib/auth.ts` checks every session and media * another instance while an admin blocks it or the deployment turns sign-ins
* token against it. * from other instances off (`FEDERATION.incoming`). `lib/auth.ts` checks
* every session and media token against it.
*/ */
export function getSessionState( export function getSessionState(
userId: string, userId: string,
): { role: Role; tokensValidAfter: string | null } | undefined { ): { role: Role; tokensValidAfter: string | null } | undefined {
const row = db.prepare( const row = db.prepare(
`SELECT u.role, u.tokens_valid_after, p.status AS peer_status `SELECT u.role, u.tokens_valid_after, ui.issuer, p.status AS peer_status
FROM users u FROM users u
LEFT JOIN user_identities ui ON ui.user_id = u.id LEFT JOIN user_identities ui ON ui.user_id = u.id
LEFT JOIN federation_peers p ON p.issuer = ui.issuer LEFT JOIN federation_peers p ON p.issuer = ui.issuer
@@ -203,10 +204,12 @@ export function getSessionState(
| { | {
role: Role; role: Role;
tokens_valid_after: string | null; tokens_valid_after: string | null;
issuer: string | null;
peer_status: string | null; peer_status: string | null;
} }
| undefined; | undefined;
if (!row || row.peer_status === "blocked") return undefined; if (!row || row.peer_status === "blocked") return undefined;
if (row.issuer && !FEDERATION.incoming) return undefined;
return { return {
role: row.role, role: row.role,
tokensValidAfter: row.tokens_valid_after, tokensValidAfter: row.tokens_valid_after,

View File

@@ -19,6 +19,7 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { clearRichContentCache } from "../services/rich-content-service.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../remote/image-proxy.ts"; import { clearImageCache } from "../remote/image-proxy.ts";
import { clearInstanceCaches } from "../remote/gerbeur-instance.ts"; import { clearInstanceCaches } from "../remote/gerbeur-instance.ts";
import { FEDERATION } from "../config.ts";
import { import {
type OutgoingEmail, type OutgoingEmail,
setEmailTransport, setEmailTransport,
@@ -464,8 +465,14 @@ export interface FakeInstance {
handle(url: URL, init?: RequestInit): Promise<Response> | null; handle(url: URL, init?: RequestInit): Promise<Response> | null;
} }
/** Makes a {@link FakeInstance} at `origin` with a fresh key. */ /**
export async function fakeInstance(origin: string): Promise<FakeInstance> { * Makes a {@link FakeInstance} at `origin` with a fresh key, its metadata
* with `metadata` over the defaults.
*/
export async function fakeInstance(
origin: string,
metadata: Record<string, unknown> = {},
): Promise<FakeInstance> {
const { kid, privateJwk, publicJwk } = await generateInstanceKey(); const { kid, privateJwk, publicJwk } = await generateInstanceKey();
const key = await importInstanceKey(kid, privateJwk); const key = await importInstanceKey(kid, privateJwk);
const codes = new Map< const codes = new Map<
@@ -496,6 +503,7 @@ export async function fakeInstance(origin: string): Promise<FakeInstance> {
gerbeur_site_name: `Fake ${new URL(origin).host}`, gerbeur_site_name: `Fake ${new URL(origin).host}`,
gerbeur_redirect_uri: `${origin}/federation/callback`, gerbeur_redirect_uri: `${origin}/federation/callback`,
gerbeur_events_endpoint: `${origin}/api/federation/events`, gerbeur_events_endpoint: `${origin}/api/federation/events`,
...metadata,
}); });
case "/.well-known/jwks.json": case "/.well-known/jwks.json":
return json({ keys: [publicJwk] }); return json({ keys: [publicJwk] });
@@ -564,6 +572,20 @@ export async function fakeInstance(origin: string): Promise<FakeInstance> {
}; };
} }
/**
* Turns one way of federated sign-in off for the life of the returned handle
* (`using _off = federationOff("incoming")`), as `GERBEUR_FEDERATION_*=0`
* would for the server's life.
*/
export function federationOff(way: keyof typeof FEDERATION): Disposable {
FEDERATION[way] = false;
return {
[Symbol.dispose]() {
FEDERATION[way] = true;
},
};
}
// ── Email ──────────────────────────────────────────────────────────────────── // ── Email ────────────────────────────────────────────────────────────────────
/** /**

View File

@@ -8,11 +8,11 @@ Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", a
const path = `${root}/index.html`; const path = `${root}/index.html`;
await Deno.writeTextFile( await Deno.writeTextFile(
path, path,
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__</body>", "<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__ __FEDERATION_INCOMING__ __FEDERATION_OUTGOING__</body>",
); );
assertEquals( assertEquals(
await loadIndexHtml(root), await loadIndexHtml(root),
"<title>gerbeur</title><body>embed smooth off</body>", "<title>gerbeur</title><body>embed smooth off on on</body>",
); );
await Deno.writeTextFile(path, "<title>rebuilt __SITE_NAME__</title>"); await Deno.writeTextFile(path, "<title>rebuilt __SITE_NAME__</title>");

View File

@@ -1,9 +1,11 @@
import { import {
count,
createUser, createUser,
db, db,
del, del,
type FakeInstance, type FakeInstance,
fakeInstance, fakeInstance,
federationOff,
formWithFile, formWithFile,
get, get,
patch, patch,
@@ -193,6 +195,48 @@ test("federation: blocking an instance refuses its users and ends their sessions
assertEquals((await get("/api/me", { auth })).status, 200); assertEquals((await get("/api/me", { auth })).status, 200);
}); });
test("federation: with sign-ins from other instances off, none starts or finishes, and those in are out until it is on", async () => {
const home = await fakeInstance(HOME);
using _net = network(home);
await trust(HOME);
const { token } = (await signIn(home, ALICE)).data as AuthResponse;
const auth = { Authorization: `Bearer ${token}` };
const started = await post<FederationLoginResponse>(
"/api/federation/logins",
{ json: { instance: "home.test" } },
);
const state = new URL(started.data.authorizeUrl).searchParams.get("state");
const code = home.issue(started.data.authorizeUrl, ALICE);
{
using _off = federationOff("incoming");
assertEquals((await get("/api/me", { auth })).status, 401);
assertEquals(
(await post("/api/federation/logins", {
json: { instance: "home.test" },
})).status,
403,
);
assertEquals(
(await post("/api/federation/sessions", { json: { code, state } }))
.status,
403,
);
}
assertEquals((await get("/api/me", { auth })).status, 200);
});
test("federation: a home whose users don't sign in elsewhere is refused before the user leaves", async () => {
const home = await fakeInstance(HOME, { gerbeur_outgoing: false });
using _net = network(home);
await trust(HOME);
const start = await post("/api/federation/logins", {
json: { instance: "home.test" },
});
assertEquals(start.status, 403);
assertEquals(count(`SELECT COUNT(*) AS n FROM federation_logins;`), 0);
});
test("federation: only admins edit the peers, which must answer as gerbeurs", async () => { test("federation: only admins edit the peers, which must answer as gerbeurs", async () => {
const home = await fakeInstance(HOME); const home = await fakeInstance(HOME);
using _net = network(home); using _net = network(home);

View File

@@ -6,6 +6,7 @@ import {
del, del,
type FakeInstance, type FakeInstance,
fakeInstance, fakeInstance,
federationOff,
formWithFile, formWithFile,
get, get,
patch, patch,
@@ -25,6 +26,7 @@ import {
import { import {
deliverOutbox, deliverOutbox,
identityClaims, identityClaims,
withdrawAllGrants,
} from "../../services/identity-provider-service.ts"; } from "../../services/identity-provider-service.ts";
import { getUserById } from "../../services/user-service.ts"; import { getUserById } from "../../services/user-service.ts";
import type { import type {
@@ -105,6 +107,7 @@ test("identity provider: metadata and keys are served at the well-known paths",
assertEquals(body.authorization_endpoint, `${BASE_URL}/authorize`); assertEquals(body.authorization_endpoint, `${BASE_URL}/authorize`);
assertEquals(body.gerbeur_redirect_uri, `${BASE_URL}/federation/callback`); assertEquals(body.gerbeur_redirect_uri, `${BASE_URL}/federation/callback`);
assertEquals(body.gerbeur_federation, 1); assertEquals(body.gerbeur_federation, 1);
assertEquals(body.gerbeur_outgoing, true);
const jwks = await get("/.well-known/jwks.json"); const jwks = await get("/.well-known/jwks.json");
const keys = JSON.parse(new TextDecoder().decode(jwks.bytes)).keys; const keys = JSON.parse(new TextDecoder().decode(jwks.bytes)).keys;
assertEquals(keys[0].crv, "Ed25519"); assertEquals(keys[0].crv, "Ed25519");
@@ -260,6 +263,40 @@ test("identity provider: the instances a user signed in to hear of their changes
); );
}); });
test("identity provider: with sign-ins elsewhere off, no instance signs the users in, and their grants are withdrawn", async () => {
const fake = await fakeInstance(CLIENT);
using _net = network(fake);
const user = await createUser();
const { verifier, challenge } = pkce();
const pending = new URL(
(await authorize(user.auth, challenge)).data.redirectTo,
).searchParams.get("code")!;
using _off = federationOff("outgoing");
const meta = await get("/.well-known/openid-configuration");
assertEquals(
(JSON.parse(new TextDecoder().decode(meta.bytes)) as InstanceMetadata)
.gerbeur_outgoing,
false,
);
assertEquals(
(await get(`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`, {
auth: user.auth,
})).status,
403,
);
assertEquals((await authorize(user.auth, challenge)).status, 403);
assertEquals(await withdrawAllGrants(), 1);
await until(() => fake.received.length === 1);
assertEquals(decodeJwt(fake.received[0]).type, "revoke");
assertEquals((await trade(pending, verifier)).status, 400);
assertEquals(
(await get<FederationGrant[]>("/api/me/grants", { auth: user.auth })).data,
[],
);
});
test("identity provider: an event an instance fails to take waits in the outbox", async () => { test("identity provider: an event an instance fails to take waits in the outbox", async () => {
const fake = await fakeInstance(CLIENT); const fake = await fakeInstance(CLIENT);
using _net = network(fake, () => new Response("", { status: 503 })); using _net = network(fake, () => new Response("", { status: 503 }));

View File

@@ -34,17 +34,25 @@ export interface DevInstance {
webPort: number; webPort: number;
/** /**
* `GERBEUR_*` settings of its own, over the base `.env`: a site name, an * `GERBEUR_*` settings of its own, over the base `.env`: a site name, an
* emoji or a style, to tell the instances apart. * emoji or a style, to tell the instances apart, or which ways federated
* sign-in goes.
*/ */
env?: Record<string, string>; env?: Record<string, string>;
} }
/** /**
* The instances `deno task dev` starts, in this order. The first keeps the * The instances `deno task dev` starts, in this order. The first keeps the
* ports the single dev server always had. * ports the single dev server always had, and takes no users of the others,
* while its own sign in to them, so that both switches of federated sign-in
* can be tried.
*/ */
export const INSTANCES: readonly DevInstance[] = [ export const INSTANCES: readonly DevInstance[] = [
{ name: "gerbeur", apiPort: 8000, webPort: 3000 }, {
name: "gerbeur",
apiPort: 8000,
webPort: 3000,
env: { GERBEUR_FEDERATION_INCOMING: "0" },
},
{ {
name: "musique", name: "musique",
apiPort: 8001, apiPort: 8001,

View File

@@ -16,6 +16,8 @@
<meta name="bandcamp-player" content="__BANDCAMP_PLAYER__" /> <meta name="bandcamp-player" content="__BANDCAMP_PLAYER__" />
<meta name="default-style" content="__DEFAULT_STYLE__" /> <meta name="default-style" content="__DEFAULT_STYLE__" />
<meta name="email-enabled" content="__EMAIL_ENABLED__" /> <meta name="email-enabled" content="__EMAIL_ENABLED__" />
<meta name="federation-incoming" content="__FEDERATION_INCOMING__" />
<meta name="federation-outgoing" content="__FEDERATION_OUTGOING__" />
<link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" /> <link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
<title>__SITE_NAME__</title> <title>__SITE_NAME__</title>

View File

@@ -51,7 +51,7 @@ msgstr "{0} more online: {restNames}"
#. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX #. placeholder {1}: VALIDATION.USERNAME_MAX
#: src/pages/UserRegister.tsx:147 #: src/pages/UserRegister.tsx:149
msgid "{0}–{1} characters: letters, numbers, or underscores" msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores"
@@ -350,7 +350,7 @@ msgstr "All"
msgid "Already dumped by {0} {1}" msgid "Already dumped by {0} {1}"
msgstr "Already dumped by {0} {1}" msgstr "Already dumped by {0} {1}"
#: src/pages/UserRegister.tsx:179 #: src/pages/UserRegister.tsx:181
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>" msgstr "Already have an account? <0>Log in</0>"
@@ -374,7 +374,7 @@ msgstr "Appearance"
#. placeholder {0}: VALIDATION.PASSWORD_MIN #. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/profile/ChangePasswordModal.tsx:101 #: src/components/profile/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:124 #: src/pages/ResetPassword.tsx:124
#: src/pages/UserRegister.tsx:166 #: src/pages/UserRegister.tsx:168
msgid "At least {0} characters" msgid "At least {0} characters"
msgstr "At least {0} characters" msgstr "At least {0} characters"
@@ -391,7 +391,7 @@ msgstr "Auto"
msgid "Back to login" msgid "Back to login"
msgstr "Back to login" msgstr "Back to login"
#: src/pages/FederationCallback.tsx:71 #: src/pages/FederationCallback.tsx:72
msgid "Back to the login page" msgid "Back to the login page"
msgstr "Back to the login page" msgstr "Back to the login page"
@@ -453,7 +453,7 @@ msgid "Cancel search"
msgstr "Cancel search" msgstr "Cancel search"
#: src/components/category/CategorySelect.tsx:33 #: src/components/category/CategorySelect.tsx:33
#: src/pages/Manage.tsx:36 #: src/pages/Manage.tsx:38
msgid "Categories" msgid "Categories"
msgstr "Categories" msgstr "Categories"
@@ -498,7 +498,7 @@ msgstr "Chat"
msgid "Chat ({unreadChatCount} unread)" msgid "Chat ({unreadChatCount} unread)"
msgstr "Chat ({unreadChatCount} unread)" msgstr "Chat ({unreadChatCount} unread)"
#: src/pages/UserRegister.tsx:108 #: src/pages/UserRegister.tsx:110
msgid "Checking invite…" msgid "Checking invite…"
msgstr "Checking invite…" msgstr "Checking invite…"
@@ -838,7 +838,7 @@ msgstr "Editing {0}"
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
#: src/pages/UserRegister.tsx:154 #: src/pages/UserRegister.tsx:156
msgid "Email address" msgid "Email address"
msgstr "Email address" msgstr "Email address"
@@ -958,7 +958,7 @@ msgstr "Following"
msgid "email.digest.forYou" msgid "email.digest.forYou"
msgstr "For you" msgstr "For you"
#: src/pages/UserLogin.tsx:101 #: src/pages/UserLogin.tsx:103
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Forgot password?" msgstr "Forgot password?"
@@ -997,7 +997,7 @@ msgstr "Hi {username},"
msgid "Hot" msgid "Hot"
msgstr "Hot" msgstr "Hot"
#: src/pages/UserLogin.tsx:142 #: src/pages/UserLogin.tsx:144
msgid "If that address is registered you'll receive a reset link shortly." msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly."
@@ -1029,11 +1029,11 @@ msgstr "In the chat"
msgid "Insert emoji" msgid "Insert emoji"
msgstr "Insert emoji" msgstr "Insert emoji"
#: src/pages/Manage.tsx:43 #: src/pages/Manage.tsx:45
msgid "Instances" msgid "Instances"
msgstr "Instances" msgstr "Instances"
#: src/pages/UserRegister.tsx:119 #: src/pages/UserRegister.tsx:121
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invalid invite" msgstr "Invalid invite"
@@ -1156,9 +1156,9 @@ msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:208 #: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32 #: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:44 #: src/pages/UserLogin.tsx:46
#: src/pages/UserLogin.tsx:71 #: src/pages/UserLogin.tsx:73
#: src/pages/UserLogin.tsx:90 #: src/pages/UserLogin.tsx:92
msgid "Log in" msgid "Log in"
msgstr "Log in" msgstr "Log in"
@@ -1180,12 +1180,12 @@ msgstr "Log in with {0}"
msgid "Log out" msgid "Log out"
msgstr "Log out" msgstr "Log out"
#: src/pages/UserLogin.tsx:89 #: src/pages/UserLogin.tsx:91
msgid "Logging in…" msgid "Logging in…"
msgstr "Logging in…" msgstr "Logging in…"
#: src/pages/FederationCallback.tsx:68 #: src/pages/FederationCallback.tsx:69
#: src/pages/UserLogin.tsx:76 #: src/pages/UserLogin.tsx:78
msgid "Login failed" msgid "Login failed"
msgstr "Login failed" msgstr "Login failed"
@@ -1194,8 +1194,8 @@ msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only" msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:114 #: src/components/layout/UserMenu.tsx:114
#: src/pages/Manage.tsx:27 #: src/pages/Manage.tsx:29
#: src/pages/Manage.tsx:34 #: src/pages/Manage.tsx:36
#: src/pages/UserPublicProfile.tsx:164 #: src/pages/UserPublicProfile.tsx:164
msgid "Manage" msgid "Manage"
msgstr "Manage" msgstr "Manage"
@@ -1412,12 +1412,12 @@ msgstr "Page not found"
#: src/pages/Settings.tsx:96 #: src/pages/Settings.tsx:96
#: src/pages/Settings.tsx:106 #: src/pages/Settings.tsx:106
#: src/pages/UserLogin.tsx:86 #: src/pages/UserLogin.tsx:88
msgid "Password" msgid "Password"
msgstr "Password" msgstr "Password"
#. placeholder {0}: VALIDATION.PASSWORD_MIN #. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/pages/UserRegister.tsx:160 #: src/pages/UserRegister.tsx:162
msgid "Password (min. {0} characters)" msgid "Password (min. {0} characters)"
msgstr "Password (min. {0} characters)" msgstr "Password (min. {0} characters)"
@@ -1519,17 +1519,17 @@ msgstr "Refreshing stream…"
msgid "Refreshing…" msgid "Refreshing…"
msgstr "Refreshing…" msgstr "Refreshing…"
#: src/pages/UserRegister.tsx:47 #: src/pages/UserRegister.tsx:49
#: src/pages/UserRegister.tsx:131 #: src/pages/UserRegister.tsx:133
#: src/pages/UserRegister.tsx:171 #: src/pages/UserRegister.tsx:173
msgid "Register" msgid "Register"
msgstr "Register" msgstr "Register"
#: src/pages/UserRegister.tsx:170 #: src/pages/UserRegister.tsx:172
msgid "Registering…" msgid "Registering…"
msgstr "Registering…" msgstr "Registering…"
#: src/pages/UserRegister.tsx:136 #: src/pages/UserRegister.tsx:138
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
@@ -1581,7 +1581,7 @@ msgstr "Reply"
msgid "Replying to" msgid "Replying to"
msgstr "Replying to" msgstr "Replying to"
#: src/pages/UserLogin.tsx:154 #: src/pages/UserLogin.tsx:156
msgid "Request failed" msgid "Request failed"
msgstr "Request failed" msgstr "Request failed"
@@ -1688,12 +1688,12 @@ msgstr "Seek"
msgid "Send" msgid "Send"
msgstr "Send" msgstr "Send"
#: src/pages/UserLogin.tsx:163 #: src/pages/UserLogin.tsx:165
msgid "Send reset link" msgid "Send reset link"
msgstr "Send reset link" msgstr "Send reset link"
#: src/components/chat/PendingItem.tsx:104 #: src/components/chat/PendingItem.tsx:104
#: src/pages/UserLogin.tsx:162 #: src/pages/UserLogin.tsx:164
msgid "Sending…" msgid "Sending…"
msgstr "Sending…" msgstr "Sending…"
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
msgstr "Sign in to {name}?" msgstr "Sign in to {name}?"
#: src/pages/Authorize.tsx:154 #: src/pages/Authorize.tsx:154
#: src/pages/FederationCallback.tsx:78 #: src/pages/FederationCallback.tsx:79
msgid "Signing you in…" msgid "Signing you in…"
msgstr "Signing you in…" msgstr "Signing you in…"
@@ -1818,11 +1818,11 @@ msgstr "The new order couldn't be saved. Try again."
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again."
#: src/pages/UserRegister.tsx:120 #: src/pages/UserRegister.tsx:122
msgid "This invite link is missing, expired, or already used." msgid "This invite link is missing, expired, or already used."
msgstr "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used."
#: src/pages/UserLogin.tsx:110 #: src/pages/UserLogin.tsx:112
msgid "This is a mirage." msgid "This is a mirage."
msgstr "This is a mirage." msgstr "This is a mirage."
@@ -1838,11 +1838,11 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed." msgstr "This reset link is missing or malformed."
#: src/pages/FederationCallback.tsx:39 #: src/pages/FederationCallback.tsx:40
msgid "This sign-in link is incomplete." msgid "This sign-in link is incomplete."
msgstr "This sign-in link is incomplete." msgstr "This sign-in link is incomplete."
#: src/pages/FederationCallback.tsx:47 #: src/pages/FederationCallback.tsx:42
msgid "This sign-in was started elsewhere. Start again from the login page." 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." msgstr "This sign-in was started elsewhere. Start again from the login page."
@@ -2022,8 +2022,8 @@ msgstr "User menu"
#: src/components/profile/ProfileManageTab.tsx:61 #: src/components/profile/ProfileManageTab.tsx:61
#: src/components/profile/UsernameField.tsx:51 #: src/components/profile/UsernameField.tsx:51
#: src/pages/Settings.tsx:91 #: src/pages/Settings.tsx:91
#: src/pages/UserLogin.tsx:79 #: src/pages/UserLogin.tsx:81
#: src/pages/UserRegister.tsx:139 #: src/pages/UserRegister.tsx:141
msgid "Username" msgid "Username"
msgstr "Username" msgstr "Username"
@@ -2151,7 +2151,7 @@ msgstr "Your account lives on {home}: sign in to other instances from there."
msgid "Your account lives on {home}: your password, email address, avatar and username change there." msgid "Your account lives on {home}: your password, email address, avatar and username change there."
msgstr "Your account lives on {home}: your password, email address, avatar and username change there." msgstr "Your account lives on {home}: your password, email address, avatar and username change there."
#: src/pages/UserLogin.tsx:158 #: src/pages/UserLogin.tsx:160
msgid "Your email address" msgid "Your email address"
msgstr "Your email address" msgstr "Your email address"

View File

@@ -51,7 +51,7 @@ msgstr "{0} de plus en ligne : {restNames}"
#. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX #. placeholder {1}: VALIDATION.USERNAME_MAX
#: src/pages/UserRegister.tsx:147 #: src/pages/UserRegister.tsx:149
msgid "{0}–{1} characters: letters, numbers, or underscores" msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
@@ -350,7 +350,7 @@ msgstr "Tout"
msgid "Already dumped by {0} {1}" msgid "Already dumped by {0} {1}"
msgstr "Déjà recommandé par {0} {1}" msgstr "Déjà recommandé par {0} {1}"
#: src/pages/UserRegister.tsx:179 #: src/pages/UserRegister.tsx:181
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>" msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
@@ -374,7 +374,7 @@ msgstr "Apparence"
#. placeholder {0}: VALIDATION.PASSWORD_MIN #. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/profile/ChangePasswordModal.tsx:101 #: src/components/profile/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:124 #: src/pages/ResetPassword.tsx:124
#: src/pages/UserRegister.tsx:166 #: src/pages/UserRegister.tsx:168
msgid "At least {0} characters" msgid "At least {0} characters"
msgstr "Au moins {0} caractères" msgstr "Au moins {0} caractères"
@@ -391,7 +391,7 @@ msgstr "Auto"
msgid "Back to login" msgid "Back to login"
msgstr "Retour à la connexion" msgstr "Retour à la connexion"
#: src/pages/FederationCallback.tsx:71 #: src/pages/FederationCallback.tsx:72
msgid "Back to the login page" msgid "Back to the login page"
msgstr "Retour à la page de connexion" msgstr "Retour à la page de connexion"
@@ -453,7 +453,7 @@ msgid "Cancel search"
msgstr "Annuler la recherche" msgstr "Annuler la recherche"
#: src/components/category/CategorySelect.tsx:33 #: src/components/category/CategorySelect.tsx:33
#: src/pages/Manage.tsx:36 #: src/pages/Manage.tsx:38
msgid "Categories" msgid "Categories"
msgstr "Catégories" msgstr "Catégories"
@@ -498,7 +498,7 @@ msgstr "Tribune"
msgid "Chat ({unreadChatCount} unread)" msgid "Chat ({unreadChatCount} unread)"
msgstr "{unreadChatCount} messages non lus" msgstr "{unreadChatCount} messages non lus"
#: src/pages/UserRegister.tsx:108 #: src/pages/UserRegister.tsx:110
msgid "Checking invite…" msgid "Checking invite…"
msgstr "Vérification de l'invitation…" msgstr "Vérification de l'invitation…"
@@ -838,7 +838,7 @@ msgstr "Édition de {0}"
msgid "Email" msgid "Email"
msgstr "E-mail" msgstr "E-mail"
#: src/pages/UserRegister.tsx:154 #: src/pages/UserRegister.tsx:156
msgid "Email address" msgid "Email address"
msgstr "Adresse e-mail" msgstr "Adresse e-mail"
@@ -958,7 +958,7 @@ msgstr "Abonné"
msgid "email.digest.forYou" msgid "email.digest.forYou"
msgstr "Pour vous" msgstr "Pour vous"
#: src/pages/UserLogin.tsx:101 #: src/pages/UserLogin.tsx:103
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Mot de passe oublié ?" msgstr "Mot de passe oublié ?"
@@ -997,7 +997,7 @@ msgstr "Bonjour {username},"
msgid "Hot" msgid "Hot"
msgstr "Tendances" msgstr "Tendances"
#: src/pages/UserLogin.tsx:142 #: src/pages/UserLogin.tsx:144
msgid "If that address is registered you'll receive a reset link shortly." msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
@@ -1029,11 +1029,11 @@ msgstr "Dans la tribune"
msgid "Insert emoji" msgid "Insert emoji"
msgstr "Insérer un emoji" msgstr "Insérer un emoji"
#: src/pages/Manage.tsx:43 #: src/pages/Manage.tsx:45
msgid "Instances" msgid "Instances"
msgstr "Instances" msgstr "Instances"
#: src/pages/UserRegister.tsx:119 #: src/pages/UserRegister.tsx:121
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invitation invalide" msgstr "Invitation invalide"
@@ -1156,9 +1156,9 @@ msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:208 #: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32 #: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:44 #: src/pages/UserLogin.tsx:46
#: src/pages/UserLogin.tsx:71 #: src/pages/UserLogin.tsx:73
#: src/pages/UserLogin.tsx:90 #: src/pages/UserLogin.tsx:92
msgid "Log in" msgid "Log in"
msgstr "Se connecter" msgstr "Se connecter"
@@ -1180,12 +1180,12 @@ msgstr "Se connecter avec {0}"
msgid "Log out" msgid "Log out"
msgstr "Se déconnecter" msgstr "Se déconnecter"
#: src/pages/UserLogin.tsx:89 #: src/pages/UserLogin.tsx:91
msgid "Logging in…" msgid "Logging in…"
msgstr "Connexion…" msgstr "Connexion…"
#: src/pages/FederationCallback.tsx:68 #: src/pages/FederationCallback.tsx:69
#: src/pages/UserLogin.tsx:76 #: src/pages/UserLogin.tsx:78
msgid "Login failed" msgid "Login failed"
msgstr "Connexion échouée" msgstr "Connexion échouée"
@@ -1194,8 +1194,8 @@ msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement" msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:114 #: src/components/layout/UserMenu.tsx:114
#: src/pages/Manage.tsx:27 #: src/pages/Manage.tsx:29
#: src/pages/Manage.tsx:34 #: src/pages/Manage.tsx:36
#: src/pages/UserPublicProfile.tsx:164 #: src/pages/UserPublicProfile.tsx:164
msgid "Manage" msgid "Manage"
msgstr "Administration" msgstr "Administration"
@@ -1412,12 +1412,12 @@ msgstr "Page introuvable"
#: src/pages/Settings.tsx:96 #: src/pages/Settings.tsx:96
#: src/pages/Settings.tsx:106 #: src/pages/Settings.tsx:106
#: src/pages/UserLogin.tsx:86 #: src/pages/UserLogin.tsx:88
msgid "Password" msgid "Password"
msgstr "Mot de passe" msgstr "Mot de passe"
#. placeholder {0}: VALIDATION.PASSWORD_MIN #. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/pages/UserRegister.tsx:160 #: src/pages/UserRegister.tsx:162
msgid "Password (min. {0} characters)" msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)" msgstr "Mot de passe (min. {0} caractères)"
@@ -1519,17 +1519,17 @@ msgstr "Actualisation du flux…"
msgid "Refreshing…" msgid "Refreshing…"
msgstr "Actualisation…" msgstr "Actualisation…"
#: src/pages/UserRegister.tsx:47 #: src/pages/UserRegister.tsx:49
#: src/pages/UserRegister.tsx:131 #: src/pages/UserRegister.tsx:133
#: src/pages/UserRegister.tsx:171 #: src/pages/UserRegister.tsx:173
msgid "Register" msgid "Register"
msgstr "S'inscrire" msgstr "S'inscrire"
#: src/pages/UserRegister.tsx:170 #: src/pages/UserRegister.tsx:172
msgid "Registering…" msgid "Registering…"
msgstr "Inscription…" msgstr "Inscription…"
#: src/pages/UserRegister.tsx:136 #: src/pages/UserRegister.tsx:138
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
@@ -1581,7 +1581,7 @@ msgstr "Répondre"
msgid "Replying to" msgid "Replying to"
msgstr "En réponse à" msgstr "En réponse à"
#: src/pages/UserLogin.tsx:154 #: src/pages/UserLogin.tsx:156
msgid "Request failed" msgid "Request failed"
msgstr "Échec de la demande" msgstr "Échec de la demande"
@@ -1688,12 +1688,12 @@ msgstr "Position"
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
#: src/pages/UserLogin.tsx:163 #: src/pages/UserLogin.tsx:165
msgid "Send reset link" msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation" msgstr "Envoyer le lien de réinitialisation"
#: src/components/chat/PendingItem.tsx:104 #: src/components/chat/PendingItem.tsx:104
#: src/pages/UserLogin.tsx:162 #: src/pages/UserLogin.tsx:164
msgid "Sending…" msgid "Sending…"
msgstr "Envoi…" msgstr "Envoi…"
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
msgstr "Se connecter à {name} ?" msgstr "Se connecter à {name} ?"
#: src/pages/Authorize.tsx:154 #: src/pages/Authorize.tsx:154
#: src/pages/FederationCallback.tsx:78 #: src/pages/FederationCallback.tsx:79
msgid "Signing you in…" msgid "Signing you in…"
msgstr "Connexion…" msgstr "Connexion…"
@@ -1818,11 +1818,11 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/UserRegister.tsx:120 #: src/pages/UserRegister.tsx:122
msgid "This invite link is missing, expired, or already used." msgid "This invite link is missing, expired, or already used."
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
#: src/pages/UserLogin.tsx:110 #: src/pages/UserLogin.tsx:112
msgid "This is a mirage." msgid "This is a mirage."
msgstr "C'est un mirage." msgstr "C'est un mirage."
@@ -1838,11 +1838,11 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé." msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/FederationCallback.tsx:39 #: src/pages/FederationCallback.tsx:40
msgid "This sign-in link is incomplete." msgid "This sign-in link is incomplete."
msgstr "Ce lien de connexion est incomplet." msgstr "Ce lien de connexion est incomplet."
#: src/pages/FederationCallback.tsx:47 #: src/pages/FederationCallback.tsx:42
msgid "This sign-in was started elsewhere. Start again from the login page." 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." msgstr "Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion."
@@ -2022,8 +2022,8 @@ msgstr "Menu utilisateur"
#: src/components/profile/ProfileManageTab.tsx:61 #: src/components/profile/ProfileManageTab.tsx:61
#: src/components/profile/UsernameField.tsx:51 #: src/components/profile/UsernameField.tsx:51
#: src/pages/Settings.tsx:91 #: src/pages/Settings.tsx:91
#: src/pages/UserLogin.tsx:79 #: src/pages/UserLogin.tsx:81
#: src/pages/UserRegister.tsx:139 #: src/pages/UserRegister.tsx:141
msgid "Username" msgid "Username"
msgstr "Nom d'utilisateur" msgstr "Nom d'utilisateur"
@@ -2151,7 +2151,7 @@ msgstr "Votre compte est sur {home} : connectez-vous aux autres instances depuis
msgid "Your account lives on {home}: your password, email address, avatar and username change there." msgid "Your account lives on {home}: your password, email address, avatar and username change there."
msgstr "Votre compte est sur {home} : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas." msgstr "Votre compte est sur {home} : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas."
#: src/pages/UserLogin.tsx:158 #: src/pages/UserLogin.tsx:160
msgid "Your email address" msgid "Your email address"
msgstr "Votre adresse e-mail" msgstr "Votre adresse e-mail"

View File

@@ -58,6 +58,12 @@ export interface InstanceMetadata {
gerbeur_redirect_uri: string; gerbeur_redirect_uri: string;
/** `POST /api/federation/events`, where it receives a home's events. */ /** `POST /api/federation/events`, where it receives a home's events. */
gerbeur_events_endpoint: string; gerbeur_events_endpoint: string;
/**
* Whether its users may sign in to other instances, so that a client
* refuses at once to send them to its `/authorize` page, which would. An
* instance that doesn't say lets them.
*/
gerbeur_outgoing?: boolean;
} }
/** /**
@@ -74,7 +80,9 @@ export function isInstanceMetadata(value: unknown): value is InstanceMetadata {
v.gerbeur_federation === FEDERATION_VERSION && v.gerbeur_federation === FEDERATION_VERSION &&
typeof v.gerbeur_site_name === "string" && typeof v.gerbeur_site_name === "string" &&
typeof v.gerbeur_redirect_uri === "string" && typeof v.gerbeur_redirect_uri === "string" &&
typeof v.gerbeur_events_endpoint === "string"; typeof v.gerbeur_events_endpoint === "string" &&
(v.gerbeur_outgoing === undefined ||
typeof v.gerbeur_outgoing === "boolean");
} }
/** /**

View File

@@ -1,11 +1,13 @@
/** /**
* Lets a deployment set what the app can't know from the browser: the style * Lets a deployment set what the app can't know from the browser: the style
* a visitor sees before choosing one, and whether the server sends email, * a visitor sees before choosing one, whether the server sends email, without
* without which the digest settings do nothing. * which the digest settings do nothing, and which ways federated sign-in goes.
* *
* The server writes `GERBEUR_DEFAULT_STYLE` into index.html's `default-style` * The server writes `GERBEUR_DEFAULT_STYLE` into index.html's `default-style`
* `<meta>` tag, and whether `GERBEUR_SMTPS_URL` is set into `email-enabled` * `<meta>` tag, whether `GERBEUR_SMTPS_URL` is set into `email-enabled`, and
* (`api/lib/index-html.ts`, and the Vite config in development). index.html's * `FEDERATION` (`api/config.ts`) into `federation-incoming` and
* `federation-outgoing` (`api/lib/index-html.ts`, and the Vite config in
* development). index.html's
* inline theme script reads the first one too. * inline theme script reads the first one too.
* *
* @module * @module
@@ -33,3 +35,18 @@ export const DEFAULT_STYLE: StyleName = (() => {
* digest switches disabled, with a note why. * digest switches disabled, with a note why.
*/ */
export const EMAIL_ENABLED: boolean = readMeta("email-enabled") === "on"; export const EMAIL_ENABLED: boolean = readMeta("email-enabled") === "on";
/**
* Whether users of other instances sign in here. Without it the login and
* register pages offer no other instance.
*/
export const FEDERATION_INCOMING: boolean =
readMeta("federation-incoming") !== "off";
/**
* Whether this instance's users sign in to other instances. Without it the
* settings page doesn't list the instances they signed in to, since none
* remain.
*/
export const FEDERATION_OUTGOING: boolean =
readMeta("federation-outgoing") !== "off";

View File

@@ -16,18 +16,20 @@ import { PageHeader } from "../components/layout/PageHeader.tsx";
import { PageGroup } from "../components/layout/PageGroup.tsx"; import { PageGroup } from "../components/layout/PageGroup.tsx";
import { CategoryManager } from "../components/category/CategoryManager.tsx"; import { CategoryManager } from "../components/category/CategoryManager.tsx";
import { PeerManager } from "../components/federation/PeerManager.tsx"; import { PeerManager } from "../components/federation/PeerManager.tsx";
import { FEDERATION_INCOMING } from "../config/instance.ts";
import { NotFound } from "./NotFound.tsx"; import { NotFound } from "./NotFound.tsx";
/** /**
* Renders the category manager and the instances the site trusts or blocks, * Renders the category manager and the instances the site trusts or blocks,
* each to those who hold its permission, which the API checks again; without * each to those who hold its permission, which the API checks again, the
* instances only while users of other instances may sign in here; without
* either, the page isn't found. * either, the page isn't found.
*/ */
export function Manage(): React.JSX.Element { export function Manage(): React.JSX.Element {
useDocumentTitle(t`Manage`); useDocumentTitle(t`Manage`);
const { user } = useRequiredAuth(); const { user } = useRequiredAuth();
const categories = can(user, "category:manage"); const categories = can(user, "category:manage");
const instances = can(user, "federation:manage"); const instances = FEDERATION_INCOMING && can(user, "federation:manage");
if (!categories && !instances) return <NotFound />; if (!categories && !instances) return <NotFound />;
return ( return (
<PageShell narrow> <PageShell narrow>

View File

@@ -20,7 +20,7 @@ import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts";
import { useSettings } from "../hooks/auth/useSettings.ts"; import { useSettings } from "../hooks/auth/useSettings.ts";
import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; import { useRequiredAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { EMAIL_ENABLED } from "../config/instance.ts"; import { EMAIL_ENABLED, FEDERATION_OUTGOING } from "../config/instance.ts";
import { previewChime } from "../utils/shared/sound.ts"; import { previewChime } from "../utils/shared/sound.ts";
import { PageShell } from "../components/layout/PageShell.tsx"; import { PageShell } from "../components/layout/PageShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx"; import { PageHeader } from "../components/layout/PageHeader.tsx";
@@ -66,7 +66,7 @@ const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
* Renders `/settings`, signed in only: the username, which only a moderator * Renders `/settings`, signed in only: the username, which only a moderator
* changes (`ProfileManageTab`), the password change, which opens * changes (`ProfileManageTab`), the password change, which opens
* {@link ChangePasswordModal}, the other instances the account signs in to * {@link ChangePasswordModal}, the other instances the account signs in to
* ({@link GrantList}), and the settings, each saved as soon as it is * ({@link GrantList}) unless it may sign in to none, and the settings, each saved as soon as it is
* clicked. An account from another instance changes its password there. The style, color scheme and digests are saved to the account; the * clicked. An account from another instance changes its password there. The style, color scheme and digests are saved to the account; the
* default feed tab and sounds, for now, to this browser. * default feed tab and sounds, for now, to this browser.
*/ */
@@ -124,7 +124,7 @@ export function Settings(): React.JSX.Element {
</p> </p>
)} )}
</PageGroup> </PageGroup>
{!home && ( {!home && FEDERATION_OUTGOING && (
<PageGroup label={<Trans>Other instances</Trans>}> <PageGroup label={<Trans>Other instances</Trans>}>
<div className="page-card"> <div className="page-card">
<GrantList /> <GrantList />

View File

@@ -24,6 +24,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx"; import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts"; import { useApiForm } from "../hooks/form/useApiForm.ts";
import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx"; import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx";
import { FEDERATION_INCOMING } from "../config/instance.ts";
import { nextPath } from "../utils/auth/next.ts"; import { nextPath } from "../utils/auth/next.ts";
interface LoginValues { interface LoginValues {
@@ -38,7 +39,8 @@ interface ResetValues {
/** /**
* Renders `/login`, for the signed out. A sign-in lands on the page `?next=` * Renders `/login`, for the signed out. A sign-in lands on the page `?next=`
* names, or `/`, and "Forgot password?" unfolds the reset request form below. * names, or `/`, and "Forgot password?" unfolds the reset request form below.
* Users of another instance sign in through {@link FederatedSignIn}. * Users of another instance sign in through {@link FederatedSignIn}, unless
* the instance takes none.
*/ */
export function UserLogin(): React.JSX.Element { export function UserLogin(): React.JSX.Element {
useDocumentTitle(t`Log in`); useDocumentTitle(t`Log in`);
@@ -104,7 +106,7 @@ export function UserLogin(): React.JSX.Element {
{showReset && <ResetRequestPanel />} {showReset && <ResetRequestPanel />}
<FederatedSignIn /> {FEDERATION_INCOMING && <FederatedSignIn />}
<p className="auth-card-footer"> <p className="auth-card-footer">
<Trans>This is a mirage.</Trans> <Trans>This is a mirage.</Trans>

View File

@@ -26,6 +26,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx"; import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts"; import { useApiForm } from "../hooks/form/useApiForm.ts";
import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx"; import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx";
import { FEDERATION_INCOMING } from "../config/instance.ts";
type TokenState = type TokenState =
| { status: "checking" } | { status: "checking" }
@@ -41,7 +42,8 @@ interface Values {
/** /**
* Renders `/register?token=`, for the signed out. It checks the invite before * Renders `/register?token=`, for the signed out. It checks the invite before
* showing the form, and signs the new user in on success. A user of another * showing the form, and signs the new user in on success. A user of another
* instance joins with that account instead, through {@link FederatedSignIn}. * instance joins with that account instead, through {@link FederatedSignIn},
* unless the instance takes none.
*/ */
export function UserRegister(): React.JSX.Element { export function UserRegister(): React.JSX.Element {
useDocumentTitle(t`Register`); useDocumentTitle(t`Register`);
@@ -173,7 +175,7 @@ export function UserRegister(): React.JSX.Element {
</form> </form>
</FormProvider> </FormProvider>
<FederatedSignIn inviteToken={token} /> {FEDERATION_INCOMING && <FederatedSignIn inviteToken={token} />}
<p className="auth-card-footer"> <p className="auth-card-footer">
<Trans> <Trans>

View File

@@ -8,12 +8,18 @@ import { makeSession, makeToken } from "../fixtures.ts";
import { renderApp } from "../render.tsx"; import { renderApp } from "../render.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
const instance = vi.hoisted(() => ({ emailEnabled: false })); const instance = vi.hoisted(() => ({
emailEnabled: false,
federationOutgoing: true,
}));
vi.mock("../../config/instance.ts", () => ({ vi.mock("../../config/instance.ts", () => ({
DEFAULT_STYLE: "smooth", DEFAULT_STYLE: "smooth",
get EMAIL_ENABLED() { get EMAIL_ENABLED() {
return instance.emailEnabled; return instance.emailEnabled;
}, },
get FEDERATION_OUTGOING() {
return instance.federationOutgoing;
},
})); }));
const annSession = makeSession({ const annSession = makeSession({
@@ -35,6 +41,7 @@ const row = (label: string) =>
describe("Settings", () => { describe("Settings", () => {
afterEach(() => { afterEach(() => {
instance.emailEnabled = false; instance.emailEnabled = false;
instance.federationOutgoing = true;
document.documentElement.removeAttribute("data-style"); document.documentElement.removeAttribute("data-style");
document.documentElement.removeAttribute("data-color-scheme"); document.documentElement.removeAttribute("data-color-scheme");
}); });
@@ -197,6 +204,14 @@ describe("Settings", () => {
); );
}); });
test("no instances are listed where accounts don't sign in elsewhere", async () => {
instance.federationOutgoing = false;
open();
expect(await screen.findByText("ann")).toBeInTheDocument();
expect(screen.queryByText("Other instances")).not.toBeInTheDocument();
expect(calls("GET /api/me/grants")).toHaveLength(0);
});
test("an account from another instance changes its password there", async () => { test("an account from another instance changes its password there", async () => {
open(makeSession({ id: "al", username: "alice@home.example" })); open(makeSession({ id: "al", username: "alice@home.example" }));
const link = await screen.findByRole("link", { const link = await screen.findByRole("link", {

View File

@@ -8,6 +8,14 @@ import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts"; import { makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx"; import { currentPath, renderWithContexts } from "../render.tsx";
const instance = vi.hoisted(() => ({ federationIncoming: true }));
vi.mock("../../config/instance.ts", async (importOriginal) => ({
...await importOriginal<typeof import("../../config/instance.ts")>(),
get FEDERATION_INCOMING() {
return instance.federationIncoming;
},
}));
const home = <Route path="/" element={<p>home page</p>} />; const home = <Route path="/" element={<p>home page</p>} />;
describe("UserLogin", () => { describe("UserLogin", () => {
@@ -153,6 +161,7 @@ describe("UserLogin", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
assign.mockReset(); assign.mockReset();
instance.federationIncoming = true;
}); });
test("a trusted instance's button, or any address, starts the sign-in there", async () => { test("a trusted instance's button, or any address, starts the sign-in there", async () => {
@@ -198,6 +207,14 @@ describe("UserLogin", () => {
); );
}); });
test("isn't offered where accounts of other instances don't sign in", () => {
instance.federationIncoming = false;
setup();
expect(screen.getByPlaceholderText("Username")).toBeInTheDocument();
expect(screen.queryByText(/another gerbeur/)).not.toBeInTheDocument();
expect(calls("GET /api/federation/peers")).toHaveLength(0);
});
test("a refusal is shown under the field", async () => { test("a refusal is shown under the field", async () => {
route("GET /api/federation/peers", () => []); route("GET /api/federation/peers", () => []);
route( route(

View File

@@ -16,6 +16,10 @@ const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim())
? process.env.GERBEUR_DEFAULT_STYLE!.trim() ? process.env.GERBEUR_DEFAULT_STYLE!.trim()
: BASE_STYLE; : BASE_STYLE;
const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off"; const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off";
const FEDERATION_INCOMING =
process.env.GERBEUR_FEDERATION_INCOMING?.trim() === "0" ? "off" : "on";
const FEDERATION_OUTGOING =
process.env.GERBEUR_FEDERATION_OUTGOING?.trim() === "0" ? "off" : "on";
// Cache-busting token for the favicon URLs (see index.html). Changes when the // Cache-busting token for the favicon URLs (see index.html). Changes when the
// emoji changes, so browsers don't keep serving a stale cached icon. Mirrors // emoji changes, so browsers don't keep serving a stale cached icon. Mirrors
@@ -148,6 +152,8 @@ export default defineConfig({
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED) .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED)
.replaceAll("__FEDERATION_INCOMING__", FEDERATION_INCOMING)
.replaceAll("__FEDERATION_OUTGOING__", FEDERATION_OUTGOING)
: html, : html,
}, },
}, },