From f435e622209f7773b36ba2f7bfce2850e82e4060 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 00:58:41 -0700 Subject: [PATCH] Show NSFW-node members unblurred media and brand maintenance screens per node Hop-State: A_06FP9J7X3NRW462MRQP41Z0 Hop-Proposal: R_06FP9J6Q5X4KY348B7CJS28 Hop-Task: T_06FP9GSP35DQFZ5RXET13S0 Hop-Attempt: AT_06FP9GSP348ZKDWV5HCQYPG --- README.md | 2 +- deploy/maintenance-server.mjs | 99 ++++++++++++++++++++++++++++-- deploy/update.sh | 6 ++ src/components/AvatarImage.tsx | 4 +- src/components/ProfileBanner.tsx | 4 +- src/lib/nsfw/profile-media.test.ts | 13 +++- src/lib/nsfw/profile-media.ts | 9 ++- 7 files changed, 126 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 87282ea..6ea5bd6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The installer creates: Every node checks `origin/main` about once per minute. When a new commit is available, Synapsis fast-forwards the checkout, replaces the single `backups/latest` database snapshot, installs dependencies, runs migrations, builds, and restarts automatically. The repository commit count is shown in the Network Info card; `/api/version` exposes both that number and the full deployed commit hash. -While an update is being installed, `synapsis-maintenance.service` temporarily serves a branded maintenance page on the node's configured `PORT`. Existing reverse proxies continue receiving an HTTP response instead of showing a gateway error, and browsers automatically reload when Synapsis is ready. +While an update is being installed, `synapsis-maintenance.service` temporarily serves a maintenance page on the node's configured `PORT`, using that node's logo and accent color. Existing reverse proxies continue receiving an HTTP response instead of showing a gateway error, and browsers automatically reload when Synapsis is ready. For a node installed before automatic updates existed, bootstrap the timer once with: diff --git a/deploy/maintenance-server.mjs b/deploy/maintenance-server.mjs index 264a535..298ab3e 100644 --- a/deploy/maintenance-server.mjs +++ b/deploy/maintenance-server.mjs @@ -1,7 +1,77 @@ import http from 'node:http'; +import { dirname, join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; const host = process.env.MAINTENANCE_HOST || '127.0.0.1'; const port = Number.parseInt(process.env.PORT || '43821', 10); +const dataDir = process.env.MAINTENANCE_DATA_DIR + || dirname(process.env.DATABASE_PATH || '/var/lib/synapsis/synapsis.db'); +const brandingPath = join(dataDir, 'maintenance-branding.json'); +const logoPath = join(dataDir, 'maintenance-logo'); + +function validAccent(value) { + return typeof value === 'string' && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value) + ? value + : '#9fdf5f'; +} + +async function captureBranding() { + const baseUrl = `http://127.0.0.1:${port}`; + await mkdir(dataDir, { recursive: true }); + const nodeResponse = await fetch(`${baseUrl}/api/node`, { cache: 'no-store' }); + if (!nodeResponse.ok) throw new Error(`Node branding returned HTTP ${nodeResponse.status}`); + + const node = await nodeResponse.json(); + const branding = { + accentColor: validAccent(node.accentColor), + externalLogoUrl: typeof node.logoUrl === 'string' && /^https?:\/\//i.test(node.logoUrl) + ? node.logoUrl + : null, + logoContentType: null, + }; + + const logoResponse = await fetch(`${baseUrl}/api/node/logo`, { cache: 'no-store' }); + if (logoResponse.ok) { + const contentType = logoResponse.headers.get('content-type')?.split(';')[0]; + if (contentType?.startsWith('image/')) { + const logoTempPath = `${logoPath}.tmp`; + await writeFile(logoTempPath, Buffer.from(await logoResponse.arrayBuffer())); + await rename(logoTempPath, logoPath); + branding.logoContentType = contentType; + } else { + await rm(logoPath, { force: true }); + } + } else { + await rm(logoPath, { force: true }); + } + + const brandingTempPath = `${brandingPath}.tmp`; + await writeFile(brandingTempPath, `${JSON.stringify(branding)}\n`, { mode: 0o640 }); + await rename(brandingTempPath, brandingPath); +} + +if (process.argv.includes('--capture-branding')) { + await captureBranding(); + process.exit(0); +} + +let branding = {}; +try { + branding = JSON.parse(readFileSync(brandingPath, 'utf8')); +} catch {} + +const accentColor = validAccent(branding.accentColor); +const logoContentType = typeof branding.logoContentType === 'string' && branding.logoContentType.startsWith('image/') + ? branding.logoContentType + : 'application/octet-stream'; +const externalLogoUrl = typeof branding.externalLogoUrl === 'string' && /^https?:\/\//i.test(branding.externalLogoUrl) + ? branding.externalLogoUrl + : null; +const hasStoredLogo = existsSync(logoPath); +const logoMarkup = hasStoredLogo || externalLogoUrl + ? '' + : '
Synapsis
'; const page = ` @@ -11,23 +81,24 @@ const page = ` Update in progress
- + ${logoMarkup}

Update in progress

We’re making Synapsis better. We’ll be right back!

Checking for the node…
@@ -44,6 +115,24 @@ const page = ` `; const server = http.createServer((request, response) => { + if (request.url?.split('?')[0] === '/maintenance-logo') { + if (hasStoredLogo) { + const logo = readFileSync(logoPath); + response.writeHead(200, { + 'Content-Type': logoContentType, + 'Content-Length': logo.byteLength, + 'Cache-Control': 'no-store, max-age=0', + }); + response.end(logo); + return; + } + if (externalLogoUrl) { + response.writeHead(302, { Location: externalLogoUrl, 'Cache-Control': 'no-store, max-age=0' }); + response.end(); + return; + } + } + response.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(page), diff --git a/deploy/update.sh b/deploy/update.sh index f2ed94b..2b0a269 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -67,6 +67,12 @@ if [[ "${SYNAPSIS_APPLY_UPDATE:-0}" != "1" ]]; then fi install_update_units +if ! runuser -u synapsis -- env \ + PORT="${PORT:-43821}" \ + MAINTENANCE_DATA_DIR="$DATA_DIR" \ + node "$APP_DIR/deploy/maintenance-server.mjs" --capture-branding; then + echo "Warning: could not refresh maintenance-page branding; using the last captured branding." >&2 +fi systemctl stop synapsis systemctl start synapsis-maintenance diff --git a/src/components/AvatarImage.tsx b/src/components/AvatarImage.tsx index e5f8273..136a8a5 100644 --- a/src/components/AvatarImage.tsx +++ b/src/components/AvatarImage.tsx @@ -22,9 +22,11 @@ export function AvatarImage({ avatarUrl, seed, isNsfw = false, nodeIsNsfw, alt = const { config } = useRuntimeConfig(); const customAvatar = avatarUrl?.trim(); const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed); + const localNodeIsNsfw = config?.isNsfw ?? false; const blurred = shouldBlurProfileMedia({ accountIsNsfw: isNsfw, - nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false, + nodeIsNsfw: nodeIsNsfw ?? localNodeIsNsfw, + localNodeIsNsfw, viewer: user, }); diff --git a/src/components/ProfileBanner.tsx b/src/components/ProfileBanner.tsx index d4f7f4d..248aeff 100644 --- a/src/components/ProfileBanner.tsx +++ b/src/components/ProfileBanner.tsx @@ -21,9 +21,11 @@ export function ProfileBanner({ }) { const { user } = useAuth(); const { config } = useRuntimeConfig(); + const localNodeIsNsfw = config?.isNsfw ?? false; const blurred = shouldBlurProfileMedia({ accountIsNsfw: isNsfw, - nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false, + nodeIsNsfw: nodeIsNsfw ?? localNodeIsNsfw, + localNodeIsNsfw, viewer: user, }); diff --git a/src/lib/nsfw/profile-media.test.ts b/src/lib/nsfw/profile-media.test.ts index 2a4cef7..0eaf044 100644 --- a/src/lib/nsfw/profile-media.test.ts +++ b/src/lib/nsfw/profile-media.test.ts @@ -6,8 +6,17 @@ describe('shouldBlurProfileMedia', () => { expect(shouldBlurProfileMedia({ accountIsNsfw: true, viewer: null })).toBe(true); }); - it('blurs every account from an NSFW node when the viewer has NSFW disabled', () => { - expect(shouldBlurProfileMedia({ nodeIsNsfw: true, viewer: { nsfwEnabled: false } })).toBe(true); + it('blurs media from an NSFW node for a viewer on a non-NSFW node with NSFW disabled', () => { + expect(shouldBlurProfileMedia({ nodeIsNsfw: true, localNodeIsNsfw: false, viewer: { nsfwEnabled: false } })).toBe(true); + }); + + it('shows sensitive media to an authenticated member of the local NSFW node', () => { + expect(shouldBlurProfileMedia({ + accountIsNsfw: true, + nodeIsNsfw: true, + localNodeIsNsfw: true, + viewer: { nsfwEnabled: false }, + })).toBe(false); }); it('shows sensitive profile media when the viewer has enabled NSFW', () => { diff --git a/src/lib/nsfw/profile-media.ts b/src/lib/nsfw/profile-media.ts index fbf7bfe..698323d 100644 --- a/src/lib/nsfw/profile-media.ts +++ b/src/lib/nsfw/profile-media.ts @@ -5,11 +5,18 @@ export interface ProfileMediaViewer { export function shouldBlurProfileMedia({ accountIsNsfw = false, nodeIsNsfw = false, + localNodeIsNsfw = false, viewer, }: { accountIsNsfw?: boolean; nodeIsNsfw?: boolean; + localNodeIsNsfw?: boolean; viewer: ProfileMediaViewer | null; }): boolean { - return (accountIsNsfw || nodeIsNsfw) && viewer?.nsfwEnabled !== true; + if (!accountIsNsfw && !nodeIsNsfw) return false; + if (!viewer) return true; + + // Signing in to an NSFW node is itself consent to view that node's media. + // The explicit preference still controls sensitive media on non-NSFW nodes. + return viewer.nsfwEnabled !== true && !localNodeIsNsfw; }