diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index ca08b0be..e7caed6b 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -12,12 +12,13 @@ without the prefix is server-only. All are read at **runtime** (via ## Backend instance -| Variable | Read by | Required | Description | -| ------------------------------ | --------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `PUBLIC_INSTANCE_URL` | browser, server | **yes in production** | The Coves backend as reachable from the browser (e.g. `https://coves.social`). The server refuses to boot in production without it, even if `PUBLIC_INTERNAL_INSTANCE` is set, because the browser can only ever see this value. In dev the OAuth cookie is scoped to this host, so `hooks.server.ts` redirects any other hostname to it (RFC 8252 requires `127.0.0.1`, not `localhost`). | -| `PUBLIC_INTERNAL_INSTANCE` | server | no | Server-only shortcut to the backend for `hooks.server.ts` (`/api/me` validation) and the `/api/proxy` upstream — e.g. `http://appview:8080` on a Docker network, or `http://127.0.0.1:8081` in dev to skip the Caddy loop. Falls back to `PUBLIC_INSTANCE_URL`. | -| `ALLOW_HTTP_INTERNAL_INSTANCE` | server | no | `"true"` to let the production proxy talk plaintext `http://` **only** to the origin of `PUBLIC_INTERNAL_INSTANCE` (which must then carry an explicit `http://` scheme). Any other `http://` target is still rejected with 400. | -| `PUBLIC_LOCK_TO_INSTANCE` | browser, server | no (default `true`) | When `true`, login is pinned to `PUBLIC_INSTANCE_URL`: the login UI hides the instance field and `POST /api/auth/login` rejects any other origin with 403. Set `false` to allow arbitrary instances. | +| Variable | Read by | Required | Description | +| ------------------------------ | --------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PUBLIC_INSTANCE_URL` | browser, server | **yes in production** | The Coves backend as reachable from the browser (e.g. `https://coves.social`). The server refuses to boot in production without it, even if `PUBLIC_INTERNAL_INSTANCE` is set, because the browser can only ever see this value. In dev the OAuth cookie is scoped to this host, so `hooks.server.ts` redirects any other hostname to it (RFC 8252 requires `127.0.0.1`, not `localhost`). | +| `PUBLIC_INTERNAL_INSTANCE` | server | no | Server-only shortcut to the backend for `hooks.server.ts` (`/api/me` validation) and the `/api/proxy` upstream — e.g. `http://appview:8080` on a Docker network, or `http://127.0.0.1:8081` in dev to skip the Caddy loop. Falls back to `PUBLIC_INSTANCE_URL`. | +| `ALLOW_HTTP_INTERNAL_INSTANCE` | server | no | `"true"` to let the production proxy talk plaintext `http://` **only** to the origin of `PUBLIC_INTERNAL_INSTANCE` (which must then carry an explicit `http://` scheme). Any other `http://` target is still rejected with 400. | +| `PUBLIC_INSTANCE_DOMAIN` | browser, server | no | The domain communities hosted by this instance are "local" to — the `origin` the AppView reports for them (`gaming@coves.social`). Community URLs drop the `@origin` suffix (`/c/gaming`) exactly when the origin equals this value; every other origin is linked as `/c/name@origin`. Defaults to the hostname of `PUBLIC_INSTANCE_URL`, which matches production (the AppView is served from the instance domain, its `did:web`). Set it when the two differ, e.g. in development where the AppView is reached at `127.0.0.1` but communities carry the configured instance domain. | +| `PUBLIC_LOCK_TO_INSTANCE` | browser, server | no (default `true`) | When `true`, login is pinned to `PUBLIC_INSTANCE_URL`: the login UI hides the instance field and `POST /api/auth/login` rejects any other origin with 403. Set `false` to allow arbitrary instances. | Resolution precedence: diff --git a/src/lib/api/coves/types.ts b/src/lib/api/coves/types.ts index 17b7eb2c..8800863d 100644 --- a/src/lib/api/coves/types.ts +++ b/src/lib/api/coves/types.ts @@ -103,8 +103,15 @@ export interface AuthorView { export interface CommunityRef { did: DID - handle: Handle + /** Absent on refs the appview could not resolve; treat like `CommunityView`. */ + handle?: Handle name: string + /** + * Home instance of the community (e.g. `coves.social`, `lemmy.world`), + * served alongside `name` so clients can render the `!name@origin` form + * without parsing the DNS handle. Optional until every appview ships it. + */ + origin?: string avatar?: string } @@ -255,6 +262,8 @@ export interface CommunityViewerState { export interface CommunityView { did: DID name: string + /** Home instance of the community; see {@link CommunityRef.origin}. */ + origin?: string subscriberCount: number memberCount: number postCount: number diff --git a/src/lib/app/state/instance/domain.ts b/src/lib/app/state/instance/domain.ts new file mode 100644 index 00000000..cc70964a --- /dev/null +++ b/src/lib/app/state/instance/domain.ts @@ -0,0 +1,16 @@ +/** + * The local community domain, resolved once from public env — a leaf module. + * + * Kept apart from `./env` on purpose: that module fails fast in production + * when the instance URL is missing, which is the right behaviour for the API + * client but far too heavy a dependency for a link helper. Everything that + * builds a community URL (`$lib/app/util/links`) reads this constant, so the + * "is this community local?" decision has exactly one answer per deployment. + * + * See `localInstanceDomain` for the derivation and docs/ENVIRONMENT.md for + * the operator-facing description of `PUBLIC_INSTANCE_DOMAIN`. + */ +import { env } from '$env/dynamic/public' +import { localInstanceDomain } from './resolve' + +export const LOCAL_INSTANCE_DOMAIN: string | null = localInstanceDomain(env) diff --git a/src/lib/app/state/instance/resolve.test.ts b/src/lib/app/state/instance/resolve.test.ts index cfafe7c2..aae2d462 100644 --- a/src/lib/app/state/instance/resolve.test.ts +++ b/src/lib/app/state/instance/resolve.test.ts @@ -6,6 +6,7 @@ import { instanceOrigin, isLockedToInstance, isUpstreamSchemeAllowed, + localInstanceDomain, lockedInstanceOrigin, normalizeInstanceUrl, resolveInstanceUrl, @@ -193,3 +194,52 @@ describe('lockedInstanceOrigin', () => { expect(lockedInstanceOrigin({ PUBLIC_INSTANCE_URL: '::' })).toBeNull() }) }) + +describe('localInstanceDomain', () => { + it('derives the hostname of PUBLIC_INSTANCE_URL', () => { + expect(localInstanceDomain(BOTH)).toBe('coves.social') + }) + + it('reduces an explicit domain written as a URL to its hostname', () => { + expect( + localInstanceDomain({ + ...BOTH, + PUBLIC_INSTANCE_DOMAIN: 'https://Coves.Social:8443/', + }), + ).toBe('coves.social') + }) + + it('drops scheme, port and path', () => { + expect( + localInstanceDomain({ + PUBLIC_INSTANCE_URL: 'http://Coves.Social:8080/x', + }), + ).toBe('coves.social') + }) + + it('accepts a bare host', () => { + expect(localInstanceDomain({ PUBLIC_INSTANCE_URL: 'coves.social' })).toBe( + 'coves.social', + ) + }) + + it('prefers an explicit PUBLIC_INSTANCE_DOMAIN, lower-cased', () => { + expect( + localInstanceDomain({ + PUBLIC_INSTANCE_URL: 'http://127.0.0.1:8080', + PUBLIC_INSTANCE_DOMAIN: ' Coves.Local ', + }), + ).toBe('coves.local') + }) + + it('ignores a blank override', () => { + expect(localInstanceDomain({ ...BOTH, PUBLIC_INSTANCE_DOMAIN: ' ' })).toBe( + 'coves.social', + ) + }) + + it('returns null when nothing is configured or the URL is invalid', () => { + expect(localInstanceDomain({})).toBeNull() + expect(localInstanceDomain({ PUBLIC_INSTANCE_URL: '::' })).toBeNull() + }) +}) diff --git a/src/lib/app/state/instance/resolve.ts b/src/lib/app/state/instance/resolve.ts index 695db6c9..8a6b0343 100644 --- a/src/lib/app/state/instance/resolve.ts +++ b/src/lib/app/state/instance/resolve.ts @@ -26,6 +26,7 @@ export interface InstanceEnv { readonly PUBLIC_INSTANCE_URL?: string readonly PUBLIC_INTERNAL_INSTANCE?: string readonly PUBLIC_LOCK_TO_INSTANCE?: string + readonly PUBLIC_INSTANCE_DOMAIN?: string } export type InstanceSide = 'browser' | 'server' @@ -118,6 +119,39 @@ export function canonicalPublicHost(env: InstanceEnv): string | null { } } +/** + * The domain this deployment's communities are "local" to — the `origin` the + * AppView reports for communities it hosts (`gaming@coves.social`). Community + * URLs drop the `@origin` suffix exactly when the origin equals this value. + * + * Read from `PUBLIC_INSTANCE_DOMAIN` when set; otherwise derived from the + * hostname of `PUBLIC_INSTANCE_URL`, since in production the AppView is + * served from the instance domain itself (its `did:web` is the same claim). + * The override exists for deployments where the two differ — notably local + * development, where the AppView is reached at `127.0.0.1` but communities + * still carry the configured instance domain. Lower-cased; `null` when + * nothing is configured, in which case no community is treated as local. + * + * Both sources are reduced to a hostname: an operator who writes + * `PUBLIC_INSTANCE_DOMAIN=https://coves.social/` (or adds a port) still gets + * `coves.social`, rather than a value no AppView `origin` can ever equal — + * which would silently turn every local community into a remote one. + */ +export function localInstanceDomain(env: InstanceEnv): string | null { + return ( + hostnameOf(env.PUBLIC_INSTANCE_DOMAIN) ?? + hostnameOf(env.PUBLIC_INSTANCE_URL) + ) +} + +/** Lower-cased hostname of a bare domain or URL, or null when unparseable. */ +function hostnameOf(raw: string | undefined): string | null { + const normalized = normalizeInstanceUrl(raw) + if (normalized === null) return null + const { hostname } = new URL(normalized) + return hostname ? hostname.toLowerCase() : null +} + export interface PlaintextPolicyEnv extends InstanceEnv { readonly ALLOW_HTTP_INTERNAL_INSTANCE?: string } diff --git a/src/lib/app/util/community.test.ts b/src/lib/app/util/community.test.ts new file mode 100644 index 00000000..faeb8a3a --- /dev/null +++ b/src/lib/app/util/community.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalCommunityParam, + communityAddress, + communityMention, + encodeCommunityParam, +} from './community' + +describe('communityMention', () => { + it('uses the structured origin field when present', () => { + expect( + communityMention({ + name: 'comicstrips', + handle: 'comicstrips.lemmy-world.tdpl.io', + origin: 'lemmy.world', + }), + ).toBe('!comicstrips@lemmy.world') + }) + + it('ignores a blank origin and derives from the handle instead', () => { + expect( + communityMention({ + name: 'nba', + handle: 'c-nba.coves.social', + origin: ' ', + }), + ).toBe('!nba@coves.social') + }) + + it('splits a c- handle on its first label', () => { + expect( + communityMention({ name: 'nba', handle: 'c-nba.coves.social' }), + ).toBe('!nba@coves.social') + }) + + it('splits a four-label tdpl.io bridge handle', () => { + expect( + communityMention({ name: 'linux', handle: 'linux.lemmy-ml.tdpl.io' }), + ).toBe('!linux@lemmy-ml.tdpl.io') + }) + + it('does not split a tdpl.io handle with a different label count', () => { + expect( + communityMention({ name: 'linux', handle: 'a.b.linux.lemmy-ml.tdpl.io' }), + ).toBe('!a.b.linux.lemmy-ml.tdpl.io') + }) + + it('shows only !name for an unresolved or missing handle', () => { + expect(communityMention({ name: 'nba', handle: 'handle.invalid' })).toBe( + '!nba', + ) + expect(communityMention({ name: 'nba' })).toBe('!nba') + }) +}) + +describe('communityAddress', () => { + it('is the mention without the ! sigil', () => { + expect( + communityAddress({ + name: 'comicstrips', + handle: 'comicstrips.lemmy-world.tdpl.io', + origin: 'lemmy.world', + }), + ).toBe('comicstrips@lemmy.world') + expect( + communityAddress({ name: 'nba', handle: 'c-nba.coves.social' }), + ).toBe('nba@coves.social') + }) + + it('shows only the name for an unresolved or missing handle', () => { + expect(communityAddress({ name: 'nba', handle: 'handle.invalid' })).toBe( + 'nba', + ) + expect(communityAddress({ name: 'nba' })).toBe('nba') + }) +}) + +describe('canonicalCommunityParam', () => { + const local = 'coves.social' + + it('returns the bare name when the origin is the local instance', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'gaming', origin: 'coves.social' }, + local, + ), + ).toBe('gaming') + }) + + it('compares origins case-insensitively and ignores padding', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'gaming', origin: ' Coves.Social ' }, + local, + ), + ).toBe('gaming') + }) + + it('lower-cases the name so there is a single canonical spelling', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'Gaming', origin: 'coves.social' }, + local, + ), + ).toBe('gaming') + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'ComicStrips', origin: 'Lemmy.World' }, + local, + ), + ).toBe('comicstrips@lemmy.world') + }) + + it('returns name@origin for a bridged community', () => { + expect( + canonicalCommunityParam( + { + did: 'did:plc:x', + name: 'comicstrips', + handle: 'comicstrips.lemmy-world.tdpl.io', + origin: 'lemmy.world', + }, + local, + ), + ).toBe('comicstrips@lemmy.world') + }) + + it('returns name@origin for another Coves instance', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'gaming', origin: 'other.coves.net' }, + local, + ), + ).toBe('gaming@other.coves.net') + }) + + it('treats every origin as remote when no local domain is configured', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'gaming', origin: 'coves.social' }, + null, + ), + ).toBe('gaming@coves.social') + }) + + it('returns undefined when origin is absent or blank', () => { + expect( + canonicalCommunityParam({ did: 'did:plc:x', name: 'gaming' }, local), + ).toBeUndefined() + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'gaming', origin: ' ' }, + local, + ), + ).toBeUndefined() + }) + + it('returns undefined when the name would not survive the route matcher', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'has space', origin: 'coves.social' }, + local, + ), + ).toBeUndefined() + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: '', origin: 'coves.social' }, + local, + ), + ).toBeUndefined() + }) + + it('returns undefined when a remote origin is not a hostname', () => { + expect( + canonicalCommunityParam( + { did: 'did:plc:x', name: 'gaming', origin: 'not a host' }, + local, + ), + ).toBeUndefined() + }) +}) + +describe('encodeCommunityParam', () => { + it('keeps the @ of an address literal', () => { + expect(encodeCommunityParam('gaming@coves.social')).toBe( + 'gaming@coves.social', + ) + }) + + it('still percent-encodes a DID', () => { + expect(encodeCommunityParam('did:plc:abc')).toBe('did%3Aplc%3Aabc') + }) +}) diff --git a/src/lib/app/util/community.ts b/src/lib/app/util/community.ts new file mode 100644 index 00000000..9ebbaefd --- /dev/null +++ b/src/lib/app/util/community.ts @@ -0,0 +1,111 @@ +import { + isValidCommunityName, + isValidHandle, + usableHandle, +} from '$lib/types/atproto' +import { communitySlug } from './links' + +/** The minimal community shape needed to render its `!name@origin` form. */ +export interface CommunityMentionSource { + readonly name: string + readonly handle?: string + readonly origin?: string +} + +/** + * Splits a resolvable community handle into its `name@origin` parts. + * + * Coves handles namespace the community with a `c-` prefix + * (`c-nba.coves.social` → `nba@coves.social`); bridged communities use a + * four-label `..tdpl.io` handle + * (`linux.lemmy-ml.tdpl.io` → `linux@lemmy-ml.tdpl.io`). Any other shape is + * not something we can split confidently, so the caller falls back to the + * canonical slug. + */ +function splitHandle(handle: string): string | undefined { + const labels = handle.split('.') + if (handle.startsWith('c-') && labels.length >= 2) { + const [name, ...rest] = labels + return `${communitySlug(name)}@${rest.join('.')}` + } + if (labels.length === 4 && handle.endsWith('.tdpl.io')) { + const [name, ...rest] = labels + return `${name}@${rest.join('.')}` + } + return undefined +} + +/** + * Returns the display form of a community: `!name@origin` + * (`!nba@coves.social`, `!comicstrips@lemmy.world`). + * + * This is the single place the `!` prefix and the `@origin` join are produced; + * every piece of display copy should go through it. Prefers the structured + * `origin` field; when it is absent the origin is derived from the handle, + * and when the handle is missing or unresolved (`handle.invalid`) only the + * bare `!name` is shown. Never degrades to a DID — for URLs and route params + * use `communityLink`/`communityRouteParam` in `./links`, which pick the + * canonical `name`/`name@origin` param and fall back to the DNS handle or DID. + */ +export function communityMention(community: CommunityMentionSource): string { + return `!${communityAddress(community)}` +} + +/** + * Returns the sigil-less address of a community: `name@origin` + * (`nba@coves.social`). Same derivation as {@link communityMention} without + * the leading `!`, for compact secondary lines (list detail rows, pickers) + * where the sigil would be visual noise. + */ +export function communityAddress(community: CommunityMentionSource): string { + const origin = community.origin?.trim() + if (origin) return `${community.name}@${origin}` + const handle = usableHandle(community.handle) + if (!handle) return community.name + return splitHandle(handle) ?? communitySlug(handle) +} + +/** The minimal community shape needed to pick its canonical route param. */ +export interface CommunityRouteSource { + readonly did: string + readonly handle?: string + readonly name?: string + readonly origin?: string +} + +/** + * Returns the canonical `/c/` route param for a community, following + * Lemmy's convention: the bare `name` when the community's origin is the + * instance this site serves, `name@origin` for every remote origin (a bridged + * Lemmy community or another Coves instance). Both halves are lower-cased — + * DNS names are case-insensitive and the AppView folds names the same way + * when resolving — so there is exactly one canonical spelling to redirect to. + * + * Returns `undefined` when the community carries no `origin` (older AppView) + * or when the pair would not survive the `[handle=handle]` route matcher — + * callers then fall back to the legacy DNS-handle/DID param, which still + * resolves and is redirected to the canonical form once loaded. + * + * Pure: `localDomain` is passed in (see `$lib/app/util/links` for the + * deployment-bound wrapper) so the rule is unit-testable without env. + */ +export function canonicalCommunityParam( + community: CommunityRouteSource, + localDomain: string | null, +): string | undefined { + const origin = community.origin?.trim().toLowerCase() + const name = community.name?.trim().toLowerCase() + if (!origin || !name || !isValidCommunityName(name)) return undefined + if (origin === localDomain) return name + if (!isValidHandle(origin)) return undefined + return `${name}@${origin}` +} + +/** + * Percent-encodes a community route param for use in a path segment while + * keeping the `@` of `name@origin` literal — `@` is a legal path character + * (RFC 3986 `pchar`) and `%40` would make the canonical URL unreadable. + */ +export function encodeCommunityParam(param: string): string { + return encodeURIComponent(param).replaceAll('%40', '@') +} diff --git a/src/lib/app/util/links.test.ts b/src/lib/app/util/links.test.ts index 3031940a..f4d0c543 100644 --- a/src/lib/app/util/links.test.ts +++ b/src/lib/app/util/links.test.ts @@ -1,7 +1,18 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import type { AuthorView, CommunityRef } from '$lib/api/coves/types' import type { DID, Handle } from '$lib/types/atproto' -import { communityLink, communitySlug, userLink } from './links' +import { + communityLink, + communityRouteParam, + communitySlug, + userLink, +} from './links' + +// Pins the local instance so the "is this community local?" branch of +// communityLink is deterministic regardless of the developer's shell env. +vi.mock('$env/dynamic/public', () => ({ + env: { PUBLIC_INSTANCE_URL: 'https://coves.social' }, +})) // --------------------------------------------------------------------------- // communityLink() @@ -44,6 +55,40 @@ describe('communityLink', () => { expect(communityLink(cPrefixCommunity)).toBe('/c/gaming.coves.social') }) + it('uses the bare name when origin is the local instance', () => { + const local: CommunityRef = { + did: 'did:plc:abc123' as DID, + handle: 'c-gaming.coves.social' as Handle, + name: 'gaming', + origin: 'coves.social', + } + expect(communityLink(local)).toBe('/c/gaming') + expect(communityLink(local, '/app')).toBe('/app/c/gaming') + }) + + it('uses name@origin for a remote origin, with a literal @', () => { + const bridged: CommunityRef = { + did: 'did:plc:abc123' as DID, + handle: 'comicstrips.lemmy-world.tdpl.io' as Handle, + name: 'comicstrips', + origin: 'lemmy.world', + } + expect(communityLink(bridged)).toBe('/c/comicstrips@lemmy.world') + }) + + it('falls back to the handle slug when origin is absent', () => { + expect(communityLink(community)).toBe('/c/tech.coves.social') + }) + + it('falls back to the DID for the unresolved-handle sentinel', () => { + const unresolved: CommunityRef = { + did: 'did:plc:abc123' as DID, + handle: 'handle.invalid' as Handle, + name: 'tech', + } + expect(communityLink(unresolved)).toBe('/c/did%3Aplc%3Aabc123') + }) + it('strips c- prefix from handle when prefix is provided', () => { const cPrefixCommunity: CommunityRef = { did: 'did:plc:abc123' as DID, @@ -56,6 +101,42 @@ describe('communityLink', () => { }) }) +// --------------------------------------------------------------------------- +// communityRouteParam() +// --------------------------------------------------------------------------- + +describe('communityRouteParam', () => { + const gaming = { + did: 'did:plc:abc123', + handle: 'c-gaming.coves.social', + name: 'gaming', + origin: 'coves.social', + } + + it('returns the unencoded canonical param', () => { + expect(communityRouteParam(gaming)).toBe('gaming') + expect(communityRouteParam({ ...gaming, origin: 'lemmy.world' })).toBe( + 'gaming@lemmy.world', + ) + }) + + it('honours an explicit local domain', () => { + expect(communityRouteParam(gaming, 'other.example')).toBe( + 'gaming@coves.social', + ) + expect(communityRouteParam(gaming, null)).toBe('gaming@coves.social') + }) + + it('returns the handle slug, then the DID, when origin is absent', () => { + expect(communityRouteParam({ ...gaming, origin: undefined })).toBe( + 'gaming.coves.social', + ) + expect(communityRouteParam({ did: 'did:plc:abc123', name: 'gaming' })).toBe( + 'did:plc:abc123', + ) + }) +}) + // --------------------------------------------------------------------------- // communitySlug() // --------------------------------------------------------------------------- diff --git a/src/lib/app/util/links.ts b/src/lib/app/util/links.ts index 824a17ac..f7948805 100644 --- a/src/lib/app/util/links.ts +++ b/src/lib/app/util/links.ts @@ -1,7 +1,10 @@ -import type { - CommunityRef, - CommunityView as CovesCommunityView, -} from '$lib/api/coves/types' +import { LOCAL_INSTANCE_DOMAIN } from '$lib/app/state/instance/domain' +import { usableHandle } from '$lib/types/atproto' +import { + canonicalCommunityParam, + encodeCommunityParam, + type CommunityRouteSource, +} from './community' /** * Strips the "c-" prefix from a community handle to produce its canonical form. @@ -24,21 +27,37 @@ export function communitySlug(handle: string): string { } /** - * Generate a link path for a community. - * Accepts a Coves CommunityRef or CommunityView. + * Returns the route param a community should be addressed by: the canonical + * `name` / `name@origin` form when the AppView served an `origin` (see + * {@link canonicalCommunityParam}), otherwise the legacy DNS-handle slug, and + * the DID when the handle is missing or unresolved (`handle.invalid`). Every + * form is accepted by the `[handle=handle]` matcher and resolved by the + * AppView; only the canonical one survives the community page's redirect. * - * Falls back to the community DID when the handle is missing — the - * `[handle=handle]` route matcher accepts handles and DIDs but not bare - * community names, so a `name`-based URL would 404 at routing. + * Unencoded — pass through {@link encodeCommunityParam} when building a path. + */ +export function communityRouteParam( + community: CommunityRouteSource, + localDomain: string | null = LOCAL_INSTANCE_DOMAIN, +): string { + const canonical = canonicalCommunityParam(community, localDomain) + if (canonical) return canonical + const handle = usableHandle(community.handle) + return handle ? communitySlug(handle) : community.did +} + +/** + * Generate a link path for a community: `/c/gaming` for a local community, + * `/c/comicstrips@lemmy.world` for a remote one, and the legacy + * `/c/` / `/c/` when the response carried no `origin`. + * Accepts a Coves CommunityRef or CommunityView (or any `did` + optional + * `handle`/`name`/`origin` shape). */ export function communityLink( - community: CommunityRef | CovesCommunityView, + community: CommunityRouteSource, prefix: string = '', ): string { - if ('handle' in community && community.handle) { - return `${prefix}/c/${encodeURIComponent(communitySlug(community.handle))}` - } - return `${prefix}/c/${encodeURIComponent(community.did)}` + return `${prefix}/c/${encodeCommunityParam(communityRouteParam(community))}` } /** diff --git a/src/lib/feature/community/CommunityCard.svelte b/src/lib/feature/community/CommunityCard.svelte index f31b96f9..ab25f012 100644 --- a/src/lib/feature/community/CommunityCard.svelte +++ b/src/lib/feature/community/CommunityCard.svelte @@ -44,7 +44,7 @@ import SubscribeButton from './SubscribeButton.svelte' import { communityDisplayName, - communityHandleOrName, + communityMention, communityIdentifier, } from './helpers' @@ -98,7 +98,7 @@ avatarCircle={false} > {#snippet nameDetail()} - !{communityHandleOrName(community)} + {communityMention(community)} {/snippet} diff --git a/src/lib/feature/community/CommunityForm.svelte b/src/lib/feature/community/CommunityForm.svelte index 31aa376c..9f11e82b 100644 --- a/src/lib/feature/community/CommunityForm.svelte +++ b/src/lib/feature/community/CommunityForm.svelte @@ -5,7 +5,7 @@ import { profile } from '$lib/app/state/auth.svelte' import { errorMessage } from '$lib/app/util/error' import { t } from '$lib/app/state/i18n' - import { communitySlug } from '$lib/app/util/links' + import { communityLink } from '$lib/app/util/links' import MarkdownEditor from '$lib/feature/markdown/MarkdownEditor.svelte' import { Header } from '$lib/ui/layout' import { Button, Option, Select, TextInput, toast } from '$lib/ui/kit' @@ -46,7 +46,9 @@ type: 'success', }) - goto(`/c/${encodeURIComponent(communitySlug(res.handle))}`) + // The create response carries no `origin`, so this lands on the legacy + // handle URL; the community page redirects to the canonical one. + goto(communityLink({ ...res, name: formData.name })) } catch (err) { toast({ content: errorMessage(err), diff --git a/src/lib/feature/community/CommunityHeader.svelte b/src/lib/feature/community/CommunityHeader.svelte index 6bb9c6fe..2bed9e4f 100644 --- a/src/lib/feature/community/CommunityHeader.svelte +++ b/src/lib/feature/community/CommunityHeader.svelte @@ -15,7 +15,7 @@ import SubscribeButton from './SubscribeButton.svelte' import { communityDisplayName, - communityHandleOrName, + communityMention, communityIdentifier, } from './helpers' @@ -70,14 +70,12 @@ {#snippet nameDetail()} {/snippet}

- {communityHandleOrName(community)} + {communityAddress(community)}

- !{communityHandleOrName(community)} + {communityMention(community)} {/if} diff --git a/src/lib/feature/community/CommunityTitle.svelte b/src/lib/feature/community/CommunityTitle.svelte index 4957f9b1..e2e8c5a1 100644 --- a/src/lib/feature/community/CommunityTitle.svelte +++ b/src/lib/feature/community/CommunityTitle.svelte @@ -3,7 +3,7 @@ CommunityView, CommunityViewDetailed, } from '$lib/api/coves/types' - import { communitySlug } from '$lib/app/util/links' + import { communityMention } from './helpers' import Avatar from '$lib/ui/generic/Avatar.svelte' interface Props { @@ -17,10 +17,8 @@

{community.displayName ?? community.name}

- {#if community.handle} - - !{communitySlug(community.handle)} - - {/if} + + {communityMention(community)} +
diff --git a/src/lib/feature/community/helpers.test.ts b/src/lib/feature/community/helpers.test.ts index 8e9cc0e1..a5a48931 100644 --- a/src/lib/feature/community/helpers.test.ts +++ b/src/lib/feature/community/helpers.test.ts @@ -1,11 +1,15 @@ import type { CommunityRef, CommunityView } from '$lib/api/coves/types' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { communityDisplayName, - communityHandleOrName, + communityMention, communityIdentifier, } from './helpers' +vi.mock('$env/dynamic/public', () => ({ + env: { PUBLIC_INSTANCE_URL: 'https://coves.social' }, +})) + type CommunityOverrides = Partial> & { handle?: string } @@ -20,54 +24,88 @@ function makeCommunity(overrides: CommunityOverrides = {}): CommunityView { } describe('communityIdentifier', () => { - it('strips the c- prefix from the handle', () => { + it('uses the bare name for a community on the local instance', () => { + expect(communityIdentifier(makeCommunity({ origin: 'coves.social' }))).toBe( + 'science', + ) + }) + + it('uses name@origin for a remote community', () => { + expect( + communityIdentifier( + makeCommunity({ + name: 'linux', + handle: 'linux.lemmy-ml.tdpl.io', + origin: 'lemmy.ml', + }), + ), + ).toBe('linux@lemmy.ml') + }) + + it('strips the c- prefix from the handle when origin is absent', () => { expect(communityIdentifier(makeCommunity())).toBe('science.coves.social') }) - it('falls back to the did when the handle is missing', () => { + it('falls back to the encoded did when the handle is missing', () => { expect(communityIdentifier(makeCommunity({ handle: undefined }))).toBe( - 'did:plc:community123', + 'did%3Aplc%3Acommunity123', ) }) - it('falls back to the did for the unresolved-handle sentinel', () => { + it('falls back to the encoded did for the unresolved-handle sentinel', () => { expect( communityIdentifier(makeCommunity({ handle: 'handle.invalid' })), - ).toBe('did:plc:community123') + ).toBe('did%3Aplc%3Acommunity123') }) }) -describe('communityHandleOrName', () => { - it('strips the c- prefix so display copy shows the canonical handle', () => { - expect(communityHandleOrName(makeCommunity())).toBe('science.coves.social') +describe('communityMention', () => { + it('renders !name@origin when the appview serves origin', () => { + expect(communityMention(makeCommunity({ origin: 'coves.social' }))).toBe( + '!science@coves.social', + ) }) - it('passes through a handle without the c- prefix unchanged', () => { + it('prefers origin over the handle for bridged communities', () => { expect( - communityHandleOrName( - makeCommunity({ handle: 'linux.lemmy-ml.tdpl.io' }), + communityMention( + makeCommunity({ + name: 'linux', + handle: 'linux.lemmy-ml.tdpl.io', + origin: 'lemmy.ml', + }), ), - ).toBe('linux.lemmy-ml.tdpl.io') + ).toBe('!linux@lemmy.ml') + }) + + it('derives name@origin from a c- handle when origin is absent', () => { + expect(communityMention(makeCommunity())).toBe('!science@coves.social') }) - it('only strips a c- at the very beginning', () => { + it('derives name@origin from a tdpl.io bridge handle when origin is absent', () => { expect( - communityHandleOrName( - makeCommunity({ handle: 'myc-thing.coves.social' }), + communityMention( + makeCommunity({ name: 'linux', handle: 'linux.lemmy-ml.tdpl.io' }), ), - ).toBe('myc-thing.coves.social') + ).toBe('!linux@lemmy-ml.tdpl.io') }) - it('falls back to the name when the handle is missing', () => { - expect(communityHandleOrName(makeCommunity({ handle: undefined }))).toBe( - 'science', + it('falls back to the canonical slug for a handle it cannot split', () => { + expect( + communityMention(makeCommunity({ handle: 'myc-thing.coves.social' })), + ).toBe('!myc-thing.coves.social') + }) + + it('falls back to !name when the handle is missing', () => { + expect(communityMention(makeCommunity({ handle: undefined }))).toBe( + '!science', ) }) - it('falls back to the name for the unresolved-handle sentinel', () => { - expect( - communityHandleOrName(makeCommunity({ handle: 'handle.invalid' })), - ).toBe('science') + it('falls back to !name for the unresolved-handle sentinel', () => { + expect(communityMention(makeCommunity({ handle: 'handle.invalid' }))).toBe( + '!science', + ) }) it('never returns a did', () => { @@ -75,7 +113,7 @@ describe('communityHandleOrName', () => { did: 'did:plc:community123', name: 'science', } as CommunityRef - expect(communityHandleOrName(ref)).toBe('science') + expect(communityMention(ref)).toBe('!science') }) }) diff --git a/src/lib/feature/community/helpers.ts b/src/lib/feature/community/helpers.ts index f4791d77..fd331dd1 100644 --- a/src/lib/feature/community/helpers.ts +++ b/src/lib/feature/community/helpers.ts @@ -1,42 +1,25 @@ import type { CommunityRef, CommunityView } from '$lib/api/coves/types' -import { communitySlug } from '$lib/app/util/links' -import { usableHandle } from '$lib/types/atproto' - -function communityHandle( - community: CommunityView | CommunityRef, -): string | undefined { - return usableHandle(community.handle) -} +import { encodeCommunityParam } from '$lib/app/util/community' +import { communityRouteParam } from '$lib/app/util/links' /** - * Returns the identifier string for a community (for URLs, route params, etc.). - * Prefers the canonical slug form of `handle` (no `c-` prefix, matching - * {@link postLink} permalinks), falling back to `did` — both are accepted by - * the `[handle=handle]` route matcher, whereas a bare `name` (e.g. "general") - * would build a URL the router refuses. For human-readable text use - * {@link communityHandleOrName} or {@link communityDisplayName} instead. + * Display forms of a community (`!name@origin` and sigil-less `name@origin`). + * Live in `app/util` so the `ui/` layer can share them; re-exported here as + * the feature-level entry point. */ -export function communityIdentifier( - community: CommunityView | CommunityRef, -): string { - const handle = communityHandle(community) - return handle ? communitySlug(handle) : community.did -} +export { communityAddress, communityMention } from '$lib/app/util/community' /** - * Returns a human-readable identifier for display copy (e.g. `!handle` text, - * list detail lines). Prefers `handle` over `name` and never degrades to a - * DID — for URLs use {@link communityIdentifier} instead. - * - * The `c-` prefix is an internal namespacing convention, so the handle is - * shown in its canonical form (`science.coves.social`, not - * `c-science.coves.social`). + * Returns the identifier string for a community (for URLs, route params, etc.): + * the canonical `name` / `name@origin` when the AppView served an `origin`, + * else the slug form of `handle` (no `c-` prefix), else `did`. All are + * accepted by the `[handle=handle]` route matcher. For human-readable text + * use {@link communityMention} or {@link communityDisplayName} instead. */ -export function communityHandleOrName( +export function communityIdentifier( community: CommunityView | CommunityRef, ): string { - const handle = communityHandle(community) - return handle ? communitySlug(handle) : community.name + return encodeCommunityParam(communityRouteParam(community)) } /** diff --git a/src/lib/feature/feeds/feed.svelte.ts b/src/lib/feature/feeds/feed.svelte.ts index dac1f1ea..83f633a6 100644 --- a/src/lib/feature/feeds/feed.svelte.ts +++ b/src/lib/feature/feeds/feed.svelte.ts @@ -108,7 +108,7 @@ export interface FeedTypes { params: FeedPaginationParams & { community: string; cursor?: string } }, ] - '/profile/[handle=handle]': [ + '/profile/[handle=actor]': [ { actor: string; limit?: number; cursor?: string }, { profile: ProfileViewDetailed diff --git a/src/lib/feature/post/PostMeta.svelte b/src/lib/feature/post/PostMeta.svelte index 5a1a97f5..9a9bc5dd 100644 --- a/src/lib/feature/post/PostMeta.svelte +++ b/src/lib/feature/post/PostMeta.svelte @@ -3,7 +3,7 @@ import { locale, t } from '$lib/app/state/i18n' import Markdown from '$lib/feature/markdown/Markdown.svelte' import { type View, settings } from '$lib/app/state/settings.svelte' - import { communitySlug } from '$lib/app/util/links' + import { communityMention } from '$lib/app/util/community' import { parseWebUrl } from '$lib/app/util/url' import Avatar from '$lib/ui/generic/Avatar.svelte' import { publishedToDate } from '$lib/ui/util/date' @@ -144,11 +144,9 @@ />
{community.name} - {#if community.handle} - - !{communitySlug(community.handle)} - - {/if} + + {communityMention(community)} +
diff --git a/src/lib/feature/post/form/PostForm.svelte b/src/lib/feature/post/form/PostForm.svelte index 5c426e4b..f82dedc5 100644 --- a/src/lib/feature/post/form/PostForm.svelte +++ b/src/lib/feature/post/form/PostForm.svelte @@ -2,7 +2,7 @@ import { errorMessage } from '$lib/app/util/error' import { t } from '$lib/app/state/i18n' import MarkdownEditor from '$lib/feature/markdown/MarkdownEditor.svelte' - import { communitySlug } from '$lib/app/util/links' + import { communityAddress } from '$lib/app/util/community' import { placeholders } from '$lib/app/util/placeholders' import { isWebUrl } from '$lib/app/util/url' import FreeTextInput from '$lib/ui/form/FreeTextInput.svelte' @@ -124,9 +124,7 @@
{form.community.name} - {form.community.handle - ? communitySlug(form.community.handle) - : form.community.did} + {communityAddress(form.community)}
diff --git a/src/lib/feature/post/helpers.test.ts b/src/lib/feature/post/helpers.test.ts index 825ad6a5..cca1b8c5 100644 --- a/src/lib/feature/post/helpers.test.ts +++ b/src/lib/feature/post/helpers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import type { CommunityRef, ExternalEmbed, @@ -28,6 +28,10 @@ import { postTextFallback, } from './helpers' +vi.mock('$env/dynamic/public', () => ({ + env: { PUBLIC_INSTANCE_URL: 'https://coves.social' }, +})) + // --------------------------------------------------------------------------- // Test fixtures // --------------------------------------------------------------------------- @@ -527,11 +531,13 @@ describe('postLink', () => { uri: string communityHandle?: string communityName?: string + communityOrigin?: string }): PostView { const community: CommunityRef = { did: 'did:plc:community1' as DID, handle: (overrides.communityHandle ?? '') as Handle, name: overrides.communityName ?? 'fallback', + origin: overrides.communityOrigin, } return { uri: overrides.uri as AtUri, @@ -547,6 +553,26 @@ describe('postLink', () => { } } + it('uses the bare community name when the origin is local', () => { + const post = makePostView({ + uri: 'at://did:plc:abc123/social.coves.community.post/rkey1', + communityHandle: 'c-gaming.coves.social', + communityName: 'gaming', + communityOrigin: 'coves.social', + }) + expect(postLink(post)).toBe('/c/gaming/post/rkey1') + }) + + it('uses name@origin for a remote community', () => { + const post = makePostView({ + uri: 'at://did:plc:abc123/social.coves.community.post/rkey2', + communityHandle: 'comicstrips.lemmy-world.tdpl.io', + communityName: 'comicstrips', + communityOrigin: 'lemmy.world', + }) + expect(postLink(post)).toBe('/c/comicstrips@lemmy.world/post/rkey2') + }) + it('strips c- prefix from community handle for the URL slug', () => { const post = makePostView({ uri: 'at://did:plc:abc123/social.coves.community.post/rkey123', diff --git a/src/lib/feature/post/helpers.ts b/src/lib/feature/post/helpers.ts index a02a8735..ea8ed1e2 100644 --- a/src/lib/feature/post/helpers.ts +++ b/src/lib/feature/post/helpers.ts @@ -1,7 +1,7 @@ import type { AtUri, PostEmbed } from '$lib/api/coves/types' import { parseAtUri } from '$lib/api/coves/types' import { isImage, isVideo, isWebUrl } from '$lib/app/util/url' -import { communitySlug } from '$lib/app/util/links' +import { communityLink } from '$lib/app/util/links' import { type ImagePreset, type ImageVariant, @@ -99,6 +99,7 @@ export interface PostLinkRef { did: string handle?: string name: string + origin?: string } } @@ -110,10 +111,10 @@ export interface PostLinkRef { * lives in one place. Accepts any object carrying the post's AT-URI and a * community ref (see {@link PostLinkRef}). * - * The slug prefers the community's handle; when the handle is missing it - * falls back to the community DID, which the `[handle=handle]` route matcher - * accepts and the community loaders resolve — a bare `name` would 404 at - * routing. + * The community segment comes from {@link communityLink}: the canonical + * `name` / `name@origin` when the ref carries an `origin`, else the handle + * slug, else the DID — every form the `[handle=handle]` route matcher accepts + * and the community loaders resolve. * * @param includeUri - When true, appends `?uri=` to the path. * The post page reads this param to load the post immediately, without a @@ -122,10 +123,7 @@ export interface PostLinkRef { */ export function postLink(post: PostLinkRef, includeUri = false): string { const { rkey } = parseAtUri(post.uri as AtUri) - const slug = post.community.handle - ? communitySlug(post.community.handle) - : post.community.did - const path = `/c/${encodeURIComponent(slug)}/post/${encodeURIComponent(rkey)}` + const path = `${communityLink(post.community)}/post/${encodeURIComponent(rkey)}` if (!includeUri) return path return `${path}?${new URLSearchParams({ uri: post.uri })}` } diff --git a/src/lib/types/atproto.ts b/src/lib/types/atproto.ts index 696bcc93..480d7d88 100644 --- a/src/lib/types/atproto.ts +++ b/src/lib/types/atproto.ts @@ -70,6 +70,30 @@ export function isValidHandle(value: string): value is Handle { ) } +/** + * A community *name* as it appears in the `!name@origin` address: one DNS + * label (RFC 1035), alphanumeric with interior hyphens, at most 63 characters. + * No dots — a dotted value is a handle, not a name — and no underscores: the + * AppView resolves names with the same DNS-label rule (`isValidDNSLabel`), so + * admitting `_` here would only build URLs the resolver rejects with 400. + */ +export function isValidCommunityName(value: string): boolean { + return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(value) +} + +/** + * Validates the `name@origin` community address (`gaming@coves.social`, + * `comicstrips@lemmy.world`) without the leading `!` sigil — the form used in + * URLs. The origin half must be a DNS hostname (`isValidHandle`). + */ +export function isValidCommunityAddress(value: string): boolean { + const at = value.indexOf('@') + if (at <= 0) return false + const name = value.slice(0, at) + const origin = value.slice(at + 1) + return isValidCommunityName(name) && isValidHandle(origin) +} + /** * Type guard to validate Instance URL format and narrow type. * Must be a valid URL with http: or https: protocol. diff --git a/src/lib/ui/form/ObjectAutocomplete.svelte b/src/lib/ui/form/ObjectAutocomplete.svelte index 5a93014a..509df83f 100644 --- a/src/lib/ui/form/ObjectAutocomplete.svelte +++ b/src/lib/ui/form/ObjectAutocomplete.svelte @@ -1,7 +1,7 @@ diff --git a/src/routes/profile/[handle=handle]/+page.svelte b/src/routes/profile/[handle=actor]/+page.svelte similarity index 100% rename from src/routes/profile/[handle=handle]/+page.svelte rename to src/routes/profile/[handle=actor]/+page.svelte diff --git a/src/routes/profile/[handle=handle]/+page.ts b/src/routes/profile/[handle=actor]/+page.ts similarity index 100% rename from src/routes/profile/[handle=handle]/+page.ts rename to src/routes/profile/[handle=actor]/+page.ts diff --git a/src/routes/profile/[handle=handle]/UserActions.svelte b/src/routes/profile/[handle=actor]/UserActions.svelte similarity index 100% rename from src/routes/profile/[handle=handle]/UserActions.svelte rename to src/routes/profile/[handle=actor]/UserActions.svelte diff --git a/src/routes/profile/[handle=handle]/page.test.ts b/src/routes/profile/[handle=actor]/page.test.ts similarity index 99% rename from src/routes/profile/[handle=handle]/page.test.ts rename to src/routes/profile/[handle=actor]/page.test.ts index 4ce7e01f..10db91ca 100644 --- a/src/routes/profile/[handle=handle]/page.test.ts +++ b/src/routes/profile/[handle=actor]/page.test.ts @@ -43,7 +43,7 @@ function makeArgs(handle: string, query = ''): Parameters[0] { // A distinct spy, not globalThis.fetch: the pass-through assertion below // must be able to tell SvelteKit's per-request fetch from the global one. fetch: vi.fn(), - route: { id: '/profile/[handle=handle]' }, + route: { id: '/profile/[handle=actor]' }, } as unknown as Parameters[0] } diff --git a/src/routes/u/[handle=handle]/+page.ts b/src/routes/u/[handle=actor]/+page.ts similarity index 100% rename from src/routes/u/[handle=handle]/+page.ts rename to src/routes/u/[handle=actor]/+page.ts diff --git a/src/routes/u/[handle=handle]/page.test.ts b/src/routes/u/[handle=actor]/page.test.ts similarity index 94% rename from src/routes/u/[handle=handle]/page.test.ts rename to src/routes/u/[handle=actor]/page.test.ts index 85d49b5a..d549d072 100644 --- a/src/routes/u/[handle=handle]/page.test.ts +++ b/src/routes/u/[handle=actor]/page.test.ts @@ -6,8 +6,8 @@ interface RedirectError { location: string } -describe('/u/[handle=handle] redirect', () => { - // All fixture params must be matcher-valid — the [handle=handle] matcher +describe('/u/[handle=actor] redirect', () => { + // All fixture params must be matcher-valid — the [handle=actor] matcher // only routes handles (dotted domains) and DIDs, so a bare name like // "alice" would never reach this load function. it('redirects to /profile/{handle} with 301 status', () => {