diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -162,6 +162,7 @@ await handle({ event, resolve }) expect(mockFetch).toHaveBeenCalledWith('http://localhost:4000/api/me', { headers: { Cookie: 'coves_session=sealed-token-value' }, + signal: expect.any(AbortSignal), }) expect(event.locals.auth.authenticated).toBe(true) if (event.locals.auth.authenticated) { @@ -308,6 +309,33 @@ warnSpy.mockRestore() }) + it('classifies TimeoutError and AbortError as network errors by name', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + for (const name of ['TimeoutError', 'AbortError']) { + warnSpy.mockClear() + mockFetch.mockRejectedValue(new DOMException('operation failed', name)) + + const cookies = createMockCookies({ + coves_session: 'sealed-token-value', + }) + const event = createMockEvent({ cookies }) + const resolve = createMockResolve() + + await handle({ event, resolve }) + + expect(event.locals.auth.authenticated).toBe(false) + expect(event.locals.authError).toBe('network_error') + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Network error calling /api/me'), + expect.any(DOMException), + ) + expect(cookies.delete).not.toHaveBeenCalled() + } + + warnSpy.mockRestore() + }) + it('does not delete the coves_session cookie on network error', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -515,6 +543,7 @@ expect(mockFetch).toHaveBeenCalledWith( 'https://coves.example.com/api/me', { headers: { Cookie: 'coves_session=sealed-token-value' }, + signal: expect.any(AbortSignal), }, ) expect(event.locals.auth.authenticated).toBe(true) @@ -676,5 +705,104 @@ message: 'Forbidden', }) expect(result).toEqual({ message: 'An unexpected error occurred' }) + }) + + describe('log sanitization', () => { + const sealedToken = 'sealed-session-token-v1.super-secret-value' + + /** + * Creates an event carrying the sealed session token in both places it + * lives on a real authenticated request: the Cookie header and locals.auth. + */ + function createEventWithToken(): RequestEvent { + const cookies = createMockCookies({ coves_session: sealedToken }) + const event = createMockEvent({ + cookies, + locals: { + auth: { + authenticated: true, + account: { + did: 'did:plc:test123', + handle: 'test.example.com', + pdsUrl: 'https://pds.example.com', + sealedToken, + }, + authToken: sealedToken, + }, + } as unknown as App.Locals, + }) + // Replace the bare request with one that includes the session cookie + // header, as the real server would receive it. + Object.assign(event, { + request: new Request(event.url, { + headers: { Cookie: `coves_session=${sealedToken}` }, + }), + }) + return event + } + + /** Best-effort string form of a logged argument for content assertions. */ + function stringifyLoggedArg(arg: unknown): string { + if (typeof arg === 'string') return arg + try { + return JSON.stringify(arg) ?? String(arg) + } catch { + return String(arg) + } + } + + it('never logs the event object or the session token for non-404 errors', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const event = createEventWithToken() + await handleError({ + error: new Error('Internal database connection failed'), + event, + status: 500, + message: 'Internal Server Error', + }) + + expect(errorSpy).toHaveBeenCalled() + for (const call of errorSpy.mock.calls) { + for (const arg of call) { + expect(arg).not.toBe(event) + expect(stringifyLoggedArg(arg)).not.toContain(sealedToken) + } + } + + errorSpy.mockRestore() + }) + + it('does not call console.error for 404 errors', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await handleError({ + error: new Error('Page not found'), + event: createEventWithToken(), + status: 404, + message: 'Not Found', + }) + + expect(errorSpy).not.toHaveBeenCalled() + + errorSpy.mockRestore() + }) + + it('logs the error stack for diagnostics', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const error = new Error('boom') + await handleError({ + error, + event: createEventWithToken(), + status: 500, + message: 'Internal Server Error', + }) + + expect(error.stack).toBeDefined() + expect(errorSpy).toHaveBeenCalledWith(error.stack) + + errorSpy.mockRestore() + }) }) }) diff --git a/src/hooks.server.ts b/src/hooks.server.ts --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -38,6 +38,13 @@ * error type alone, to avoid misclassifying programming bugs as transient network errors. */ function isNetworkError(error: unknown): boolean { if (error instanceof Error) { + // AbortSignal.timeout() rejects with a DOMException named 'TimeoutError'; + // an aborted fetch rejects with 'AbortError'. Match the structured name + // rather than message substrings so programming bugs that merely mention + // "timeout"/"abort" in their message are not misclassified. + if (error.name === 'TimeoutError' || error.name === 'AbortError') { + return true + } const msg = error.message.toLowerCase() return ( msg.includes('fetch failed') || @@ -97,6 +104,9 @@ const response = await fetch(`${instance}/api/me`, { headers: { Cookie: `coves_session=${covesSession}`, }, + // A hung backend must not pile up requests until the Node process + // exhausts sockets — this fetch runs on every authenticated request. + signal: AbortSignal.timeout(10_000), }) if (!response.ok) { @@ -170,11 +180,12 @@ if (status == 404) { return { message: 'Not found' } } - console.error(`An error was captured:`) - console.error(error) - console.error(`Event:`, event) - console.error(`Status:`, status) - console.error(`Message:`, message) + // Log only safe request context — never the full event, which contains the + // session cookie and sealed auth token (locals.auth.authToken). + console.error( + `[hooks] Error captured: ${event.request.method} ${event.url.pathname} (status ${status}): ${message}`, + ) + console.error(error instanceof Error ? (error.stack ?? error.message) : error) return { message: 'An unexpected error occurred' } } diff --git a/src/lib/app/i18n/en.json b/src/lib/app/i18n/en.json --- a/src/lib/app/i18n/en.json +++ b/src/lib/app/i18n/en.json @@ -821,7 +821,8 @@ "settingsImport": "Successfully imported settings", "settingsImportWarning": "The imported settings don't seem valid. Are you sure you want to import this?", "userLoading": "Still loading your user data...", "lemmyDonate": "Your account's server runs Lemmy, and the developers are requesting donations. They are able to develop Lemmy as an open source platform, free of tracking and ads, thanks to the generosity of its users.\n\nAnnually, they ask you to consider donating to support their work, and allow them to continue maintaining and improving Lemmy.\n\n[Donate](https://join-lemmy.org/donate)\n\n*Note: this is a donation to Lemmy, not Kelp.*", - "sessionExpired": "Your session has expired. Please log in again." + "sessionExpired": "Your session has expired. Please log in again.", + "serverUnreachable": "Can't reach the server right now. You may appear logged out, but your session is preserved." }, "settings": { "title": "Settings", diff --git a/src/lib/app/instance.svelte.ts b/src/lib/app/instance.svelte.ts --- a/src/lib/app/instance.svelte.ts +++ b/src/lib/app/instance.svelte.ts @@ -1,4 +1,4 @@ -import { browser } from '$app/environment' +import { browser, dev } from '$app/environment' import { env } from '$env/dynamic/public' import { profile } from './auth.svelte' @@ -18,13 +18,23 @@ ? env.PUBLIC_INSTANCE_URL : undefined const getDefaultInstance = (): string => { - if (browser) { - return env.PUBLIC_INSTANCE_URL || 'lemdro.id' - } else { - return ( - env.PUBLIC_INTERNAL_INSTANCE || env.PUBLIC_INSTANCE_URL || 'lemdro.id' + // The instance URL must never default to a third-party host. In production + // the server fails fast when PUBLIC_INSTANCE_URL is missing — even if + // PUBLIC_INTERNAL_INSTANCE is set, because the browser can only ever see + // PUBLIC_INSTANCE_URL, so an internal-only config would leave every client + // without an instance. In dev and in the browser we return '' instead of + // throwing; server-side consumers (e.g. the API proxy) treat empty as a + // hard config error. + if (!browser && !dev && !env.PUBLIC_INSTANCE_URL) { + throw new Error( + '[instance] PUBLIC_INSTANCE_URL is required in production. Set PUBLIC_INSTANCE_URL (PUBLIC_INTERNAL_INSTANCE is optional on top of it).', ) } + + const configured = browser + ? env.PUBLIC_INSTANCE_URL + : env.PUBLIC_INTERNAL_INSTANCE || env.PUBLIC_INSTANCE_URL + return configured || '' } export const DEFAULT_INSTANCE_URL = getDefaultInstance() diff --git a/src/lib/app/markdown/renderers/MdLink.svelte b/src/lib/app/markdown/renderers/MdLink.svelte --- a/src/lib/app/markdown/renderers/MdLink.svelte +++ b/src/lib/app/markdown/renderers/MdLink.svelte @@ -1,6 +1,5 @@ - +{#if safe} + + {@render children?.()} + +{:else} {@render children?.()} - +{/if} diff --git a/src/lib/app/markdown/renderers/plugins.js b/src/lib/app/markdown/renderers/plugins.js --- a/src/lib/app/markdown/renderers/plugins.js +++ b/src/lib/app/markdown/renderers/plugins.js @@ -123,6 +123,27 @@ // "implicit user mention" rewrite turned every real email link (e.g. // support@coves.social on /legal) into a dead /profile/ link. } +// Markdown link targets are untrusted user content. Enforce a protocol +// allowlist here rather than relying on upstream regex stripping: the URL +// parser normalizes tricks like embedded tabs in "java\tscript:" that +// pattern-based blocklists miss. Relative links resolve against the base +// and come out as https:, so they pass. +export const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']) + +/** + * Whether a markdown link href is safe to render as an anchor. + * @param {string} href + * @returns {boolean} + */ +export const isSafeHref = (href) => { + if (!href) return false + try { + return SAFE_PROTOCOLS.has(new URL(href, 'https://base.invalid').protocol) + } catch { + return false + } +} + export function subSupscriptExtension(tokensExtractor) { return { name: 'subscriptSuperscript', diff --git a/src/lib/app/markdown/renderers/plugins.test.ts b/src/lib/app/markdown/renderers/plugins.test.ts --- a/src/lib/app/markdown/renderers/plugins.test.ts +++ b/src/lib/app/markdown/renderers/plugins.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { localizeLink, CONTENT_REGEXES } from './plugins' +import { localizeLink, isSafeHref, CONTENT_REGEXES } from './plugins' // --------------------------------------------------------------------------- // localizeLink() - user links @@ -90,6 +90,72 @@ it('returns undefined for an external comment link (legacy route removed)', () => { const result = localizeLink('https://lemmy.world/comment/6789') expect(result).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// isSafeHref() - protocol allowlist for untrusted markdown link targets +// --------------------------------------------------------------------------- + +describe('isSafeHref', () => { + it('rejects javascript: URLs', () => { + expect(isSafeHref('javascript:alert(1)')).toBe(false) + }) + + it('rejects javascript: with an embedded tab (parser strips it)', () => { + // The exact bypass this defends against: the URL parser normalizes + // "java\tscript:" back to "javascript:", which regex blocklists miss. + expect(isSafeHref('java\tscript:alert(1)')).toBe(false) + }) + + it('rejects javascript: with an embedded newline (parser strips it)', () => { + expect(isSafeHref('java\nscript:alert(1)')).toBe(false) + }) + + it('rejects mixed-case javascript: URLs', () => { + expect(isSafeHref('JaVaScRiPt:alert(1)')).toBe(false) + }) + + it('rejects data: URLs', () => { + expect(isSafeHref('data:text/html,x')).toBe(false) + }) + + it('rejects vbscript: URLs', () => { + expect(isSafeHref('vbscript:x')).toBe(false) + }) + + it('accepts https: URLs', () => { + expect(isSafeHref('https://example.com')).toBe(true) + }) + + it('accepts http: URLs', () => { + expect(isSafeHref('http://example.com')).toBe(true) + }) + + it('accepts mailto: URLs', () => { + expect(isSafeHref('mailto:a@b.c')).toBe(true) + }) + + it('accepts relative paths (resolved against the base)', () => { + expect(isSafeHref('/c/technology')).toBe(true) + }) + + it('accepts fragment-only links', () => { + expect(isSafeHref('#section')).toBe(true) + }) + + it('accepts query-only links', () => { + expect(isSafeHref('?query=1')).toBe(true) + }) + + it('accepts protocol-relative URLs (intentional: resolves to https:)', () => { + // "//evil.example" resolves against the https: base, so its protocol is + // https: — an ordinary external link, safe to render as an anchor. + expect(isSafeHref('//evil.example')).toBe(true) + }) + + it('rejects the empty string', () => { + expect(isSafeHref('')).toBe(false) }) }) diff --git a/src/lib/feature/community/CommunityFlair.svelte b/src/lib/feature/community/CommunityFlair.svelte deleted file mode 100644 --- a/src/lib/feature/community/CommunityFlair.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
{ - e.preventDefault() - submit() - }} - class="contents" -> - - - diff --git a/src/lib/feature/moderation/BanModal.svelte b/src/lib/feature/moderation/BanModal.svelte deleted file mode 100644 --- a/src/lib/feature/moderation/BanModal.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - - - {#if item} -
{ - e.preventDefault() - submit() - }} - > -
- - {item.name} -
- {#if community} - - {/if} - - {#if !banned} - - {$t('moderation.ban.deleteData')} - {#snippet description()} - {$t('moderation.ban.warning')} - {/snippet} - - - {/if} - - - {/if} -
diff --git a/src/lib/feature/moderation/ModerationModals.svelte b/src/lib/feature/moderation/ModerationModals.svelte --- a/src/lib/feature/moderation/ModerationModals.svelte +++ b/src/lib/feature/moderation/ModerationModals.svelte @@ -1,14 +1,15 @@ - - - {#if item} -
- {#if isCommentView(item)} - - {:else if isPostView(item)} - - {/if} - - - - {#if !removed} - - {$t('moderation.removeSubmission.withReason')} - - - {#if commentReason} - - - {#snippet customLabel()} -
- {$t('comment.reply')} - -
- {/snippet} -
- {/if} - {/if} - - - - {/if} -
diff --git a/src/lib/feature/moderation/moderation.svelte.ts b/src/lib/feature/moderation/moderation.svelte.ts --- a/src/lib/feature/moderation/moderation.svelte.ts +++ b/src/lib/feature/moderation/moderation.svelte.ts @@ -1,11 +1,4 @@ -import type { - AuthorView, - CommentView, - CommunityRef, - PostView, -} from '$lib/api/coves/types' -import { toast } from 'mono-svelte' -import type { SubmissionView } from '../legacy/contentview' +import type { CommentView, PostView } from '$lib/api/coves/types' /** * Moderation modal state. Invariant: when `open` is true, the associated @@ -21,17 +14,6 @@ reporting: { open: boolean item: PostView | CommentView | undefined } - removing: { - open: boolean - item: SubmissionView | undefined - purge: boolean - } - banning: { - open: boolean - banned: boolean - user: AuthorView | undefined - community: CommunityRef | undefined - } } export const modals: Modals = $state({ @@ -39,49 +21,10 @@ reporting: { open: false, item: undefined, }, - removing: { - open: false, - item: undefined, - purge: false, - }, - banning: { - open: false, - banned: false, - user: undefined, - community: undefined, - }, }) export function report(item: PostView | CommentView) { modals.reporting = { open: true, item } -} - -export function remove(item: SubmissionView, purge: boolean = false) { - modals.removing = { open: true, item, purge } -} - -export function ban( - banned: boolean, - item: AuthorView, - community?: CommunityRef, -) { - modals.banning = { open: true, user: item, banned, community } -} - -/** - * @deprecated No Coves API for distinguishing comments - */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -export function feature( - _featured: boolean, - _item: CommentView, - _jwt: string, -): void { - /* eslint-enable @typescript-eslint/no-unused-vars */ - toast({ - content: 'Comment distinguishing is not yet available', - type: 'warning', - }) } export const removalTemplate = ( diff --git a/src/lib/feature/user/UserNote.svelte b/src/lib/feature/user/UserNote.svelte deleted file mode 100644 --- a/src/lib/feature/user/UserNote.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - -
{ - e.preventDefault() - submit(note) - }} - class="contents" -> - -
- - -
- diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -28,5 +28,6 @@ return { lang: preferredLanguage, session, sessionExpired: locals.sessionExpired ?? false, + authError: locals.authError ?? null, } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -113,6 +113,23 @@ $effect(() => { profile.syncFromServer(page.data.session ?? undefined) }) + // Surface auth infrastructure failures from hooks.server.ts (mirrors the + // sessionExpired flash handling above): the backend couldn't be reached to + // validate the session, so the user may appear logged out even though their + // session cookie is preserved. Warn once per outage rather than on every + // navigation while the backend stays unreachable. + let notifiedAuthNetworkError = false + $effect(() => { + if (page.data.authError === 'network_error') { + if (!notifiedAuthNetworkError) { + notifiedAuthNetworkError = true + toast({ content: $t('toast.serverUnreachable'), type: 'warning' }) + } + } else { + notifiedAuthNetworkError = false + } + }) + let nprogressTimeout = -1 $effect(() => { if (navigating.to) { diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -47,6 +47,8 @@ method: 'POST', headers: { Cookie: `coves_session=${authToken}`, }, + // Remote revocation is best-effort — don't let a hung backend stall logout. + signal: AbortSignal.timeout(10_000), }, ) if (!logoutResponse.ok) { diff --git a/src/routes/api/proxy/[...path]/+server.ts b/src/routes/api/proxy/[...path]/+server.ts --- a/src/routes/api/proxy/[...path]/+server.ts +++ b/src/routes/api/proxy/[...path]/+server.ts @@ -1,4 +1,6 @@ import type { RequestHandler } from './$types' +import { env } from '$env/dynamic/private' +import { env as publicEnv } from '$env/dynamic/public' import { DEFAULT_INSTANCE_URL } from '$lib/app/instance.svelte' import { validateProxyPath } from '../validate' @@ -45,6 +47,25 @@ * ============================================================================= */ /** + * Resolves an instance value (which may lack a scheme) to a URL origin, + * applying the same https:// protocol-defaulting used when deriving the + * proxy target from the session instance. Returns null when the value is + * empty or unparseable. + */ +function toOrigin(instance: string | undefined): string | null { + if (!instance) return null + const withProtocol = + instance.startsWith('http://') || instance.startsWith('https://') + ? instance + : `https://${instance}` + try { + return new URL(withProtocol).origin + } catch { + return null + } +} + +/** * Handles proxying requests to the upstream Coves server. * Injects the Authorization header from the session if available. */ @@ -78,6 +99,18 @@ // Instance may already include protocol (e.g., "https://coves.social") or be just the hostname const instance = locals.auth.authenticated ? locals.auth.account.instance : DEFAULT_INSTANCE_URL + if (!instance) { + return new Response( + JSON.stringify({ + error: 'Internal Server Error', + message: 'No instance URL configured', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } let baseUrl: string if (instance.startsWith('http://') || instance.startsWith('https://')) { // Instance already has protocol, use as-is @@ -87,18 +120,36 @@ // Instance is just hostname, add https:// baseUrl = `https://${instance}` } - // In production, only allow HTTPS URLs to prevent MITM attacks + // In production, only allow HTTPS URLs to prevent MITM attacks. + // ALLOW_HTTP_INTERNAL_INSTANCE=true is an explicit operator opt-in for + // deployments that reach the backend over a private network (e.g. the + // Docker service `http://appview:8080`), where plaintext is the norm. + // The exemption is scoped: plaintext is permitted ONLY when the target + // origin equals the operator-configured PUBLIC_INTERNAL_INSTANCE (which + // must carry an explicit http:// scheme to match) — a session-derived + // instance can never downgrade the proxy to http://. if (import.meta.env.PROD && baseUrl.startsWith('http://')) { - return new Response( - JSON.stringify({ - error: 'Bad Request', - message: 'HTTP URLs are not allowed in production', - }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }, - ) + const allowedHttpOrigin = + env.ALLOW_HTTP_INTERNAL_INSTANCE === 'true' + ? toOrigin(publicEnv.PUBLIC_INTERNAL_INSTANCE) + : null + const targetOrigin = toOrigin(baseUrl) + if ( + allowedHttpOrigin === null || + targetOrigin === null || + targetOrigin !== allowedHttpOrigin + ) { + return new Response( + JSON.stringify({ + error: 'Bad Request', + message: 'HTTP URLs are not allowed in production', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } } // Remove trailing slash from baseUrl if present to avoid double slashes // Preserve query parameters from the original request @@ -138,6 +189,15 @@ if (request.method !== 'GET' && request.method !== 'HEAD') { fetchOptions.body = await request.blob() } + // Bound upstream latency: a hung backend must fail this request rather + // than accumulate pending connections until the process is starved. + // The signal is created only after the client body has been fully read, + // so a slow client upload doesn't eat into the upstream's 30s budget. + // Known limitation (accepted risk): the signal continues to govern the + // response body stream after headers return, so an upstream stream that + // takes >30s in total is truncated mid-stream rather than mapped to 504. + fetchOptions.signal = AbortSignal.timeout(30_000) + const response = await fetchFn(targetUrl, fetchOptions) // Return response, stripping headers that SvelteKit should handle @@ -158,14 +218,21 @@ console.error( `Proxy error [${request.method} /${path}] [requestId: ${requestId}]:`, error, ) + // Name-based check rather than `instanceof DOMException`: under other + // runtimes (e.g. the Bun adapter) the abort error may not be a + // DOMException, but timeout aborts are always named 'TimeoutError'. + // DOMException subclasses Error in modern runtimes, so this narrows safely. + const timedOut = error instanceof Error && error.name === 'TimeoutError' return new Response( JSON.stringify({ - error: 'Bad Gateway', - message: 'Failed to connect to upstream server', + error: timedOut ? 'Gateway Timeout' : 'Bad Gateway', + message: timedOut + ? 'Upstream server timed out' + : 'Failed to connect to upstream server', requestId, }), { - status: 502, + status: timedOut ? 504 : 502, headers: { 'Content-Type': 'application/json' }, }, ) diff --git a/src/routes/communities/Subscribe.svelte b/src/routes/communities/Subscribe.svelte deleted file mode 100644 --- a/src/routes/communities/Subscribe.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -{@render children?.({ subscribe, subscribing })} diff --git a/src/routes/profile/(local_user)/blocks/+layout.svelte b/src/routes/profile/(local_user)/blocks/+layout.svelte --- a/src/routes/profile/(local_user)/blocks/+layout.svelte +++ b/src/routes/profile/(local_user)/blocks/+layout.svelte @@ -1,33 +1,16 @@ +
{$t('routes.profile.blocks.title')} - - {#snippet extended()} - - {/snippet}
{@render children?.()} diff --git a/src/routes/profile/(local_user)/blocks/communities/+page.ts b/src/routes/profile/(local_user)/blocks/communities/+page.ts new file mode 100644 --- /dev/null +++ b/src/routes/profile/(local_user)/blocks/communities/+page.ts @@ -0,0 +1,11 @@ +import { error } from '@sveltejs/kit' + +// TODO(coves-migration): The blocked-communities page is unmigrated Lemmy code +// — its `data.community_blocks` list is never populated by any load (it came +// from Lemmy's `my_user` payload) and unblocking called the legacy /api/v3 +// endpoint. The Coves API has +// blockCommunity/unblockCommunity writes but no blocked-communities list query +// yet. Remove this gate and migrate the page once that query exists. +export function load(): never { + error(404, 'Blocked communities are not available yet') +} diff --git a/src/routes/profile/(local_user)/blocks/instances/+page.svelte b/src/routes/profile/(local_user)/blocks/instances/+page.svelte --- a/src/routes/profile/(local_user)/blocks/instances/+page.svelte +++ b/src/routes/profile/(local_user)/blocks/instances/+page.svelte @@ -1,4 +1,7 @@