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
This commit is contained in:
@@ -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.
|
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:
|
For a node installed before automatic updates existed, bootstrap the timer once with:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,77 @@
|
|||||||
import http from 'node:http';
|
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 host = process.env.MAINTENANCE_HOST || '127.0.0.1';
|
||||||
const port = Number.parseInt(process.env.PORT || '43821', 10);
|
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
|
||||||
|
? '<img class="logo" src="/maintenance-logo" alt="Node logo">'
|
||||||
|
: '<div class="brand-fallback">Synapsis</div>';
|
||||||
|
|
||||||
const page = `<!doctype html>
|
const page = `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -11,23 +81,24 @@ const page = `<!doctype html>
|
|||||||
<meta name="theme-color" content="#090909">
|
<meta name="theme-color" content="#090909">
|
||||||
<title>Update in progress</title>
|
<title>Update in progress</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
:root { --accent: ${accentColor}; color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; overflow: hidden; background: #090909; color: #f5f5f5; }
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; overflow: hidden; background: #090909; color: #f5f5f5; }
|
||||||
body::before { content: ""; position: fixed; width: 34rem; height: 34rem; border-radius: 50%; background: rgba(159, 223, 95, .13); filter: blur(110px); transform: translate(-38vw, -30vh); }
|
body::before { content: ""; position: fixed; width: 34rem; height: 34rem; border-radius: 50%; background: color-mix(in srgb, var(--accent) 13%, transparent); filter: blur(110px); transform: translate(-38vw, -30vh); }
|
||||||
main { width: min(90vw, 34rem); padding: 3rem; text-align: center; border: 1px solid #292929; border-radius: 1.75rem; background: rgba(18, 18, 18, .92); box-shadow: 0 2rem 7rem rgba(0, 0, 0, .5); }
|
main { width: min(90vw, 34rem); padding: 3rem; text-align: center; border: 1px solid #292929; border-radius: 1.75rem; background: rgba(18, 18, 18, .92); box-shadow: 0 2rem 7rem rgba(0, 0, 0, .5); }
|
||||||
.mark { width: 4.25rem; height: 4.25rem; margin: 0 auto 1.5rem; display: grid; place-items: center; border-radius: 1.25rem; background: #9fdf5f; color: #0b1305; font-size: 2rem; font-weight: 800; box-shadow: 0 0 2.5rem rgba(159, 223, 95, .25); }
|
.logo { display: block; width: auto; max-width: min(18rem, 75%); height: auto; max-height: 6rem; margin: 0 auto 2rem; object-fit: contain; filter: drop-shadow(0 0 1.75rem color-mix(in srgb, var(--accent) 35%, transparent)); }
|
||||||
|
.brand-fallback { margin: 0 auto 2rem; color: var(--accent); font-size: 1.5rem; font-weight: 800; letter-spacing: -.04em; }
|
||||||
h1 { margin: 0; font-size: clamp(1.75rem, 6vw, 2.5rem); letter-spacing: -.045em; }
|
h1 { margin: 0; font-size: clamp(1.75rem, 6vw, 2.5rem); letter-spacing: -.045em; }
|
||||||
p { margin: 1rem auto 0; max-width: 25rem; color: #a6a6a6; font-size: 1.05rem; line-height: 1.6; }
|
p { margin: 1rem auto 0; max-width: 25rem; color: #a6a6a6; font-size: 1.05rem; line-height: 1.6; }
|
||||||
.status { margin-top: 2rem; display: inline-flex; align-items: center; gap: .65rem; color: #d7d7d7; font-size: .9rem; }
|
.status { margin-top: 2rem; display: inline-flex; align-items: center; gap: .65rem; color: #d7d7d7; font-size: .9rem; }
|
||||||
.dot { width: .6rem; height: .6rem; border-radius: 50%; background: #9fdf5f; animation: pulse 1.4s ease-in-out infinite; }
|
.dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
|
||||||
@keyframes pulse { 50% { opacity: .3; transform: scale(.75); } }
|
@keyframes pulse { 50% { opacity: .3; transform: scale(.75); } }
|
||||||
@media (max-width: 520px) { main { padding: 2.25rem 1.5rem; } }
|
@media (max-width: 520px) { main { padding: 2.25rem 1.5rem; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<div class="mark" aria-hidden="true">S</div>
|
${logoMarkup}
|
||||||
<h1>Update in progress</h1>
|
<h1>Update in progress</h1>
|
||||||
<p>We’re making Synapsis better. We’ll be right back!</p>
|
<p>We’re making Synapsis better. We’ll be right back!</p>
|
||||||
<div class="status"><span class="dot"></span>Checking for the node…</div>
|
<div class="status"><span class="dot"></span>Checking for the node…</div>
|
||||||
@@ -44,6 +115,24 @@ const page = `<!doctype html>
|
|||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
const server = http.createServer((request, response) => {
|
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, {
|
response.writeHead(503, {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Content-Length': Buffer.byteLength(page),
|
'Content-Length': Buffer.byteLength(page),
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ if [[ "${SYNAPSIS_APPLY_UPDATE:-0}" != "1" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
install_update_units
|
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 stop synapsis
|
||||||
systemctl start synapsis-maintenance
|
systemctl start synapsis-maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ export function AvatarImage({ avatarUrl, seed, isNsfw = false, nodeIsNsfw, alt =
|
|||||||
const { config } = useRuntimeConfig();
|
const { config } = useRuntimeConfig();
|
||||||
const customAvatar = avatarUrl?.trim();
|
const customAvatar = avatarUrl?.trim();
|
||||||
const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed);
|
const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed);
|
||||||
|
const localNodeIsNsfw = config?.isNsfw ?? false;
|
||||||
const blurred = shouldBlurProfileMedia({
|
const blurred = shouldBlurProfileMedia({
|
||||||
accountIsNsfw: isNsfw,
|
accountIsNsfw: isNsfw,
|
||||||
nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false,
|
nodeIsNsfw: nodeIsNsfw ?? localNodeIsNsfw,
|
||||||
|
localNodeIsNsfw,
|
||||||
viewer: user,
|
viewer: user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ export function ProfileBanner({
|
|||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { config } = useRuntimeConfig();
|
const { config } = useRuntimeConfig();
|
||||||
|
const localNodeIsNsfw = config?.isNsfw ?? false;
|
||||||
const blurred = shouldBlurProfileMedia({
|
const blurred = shouldBlurProfileMedia({
|
||||||
accountIsNsfw: isNsfw,
|
accountIsNsfw: isNsfw,
|
||||||
nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false,
|
nodeIsNsfw: nodeIsNsfw ?? localNodeIsNsfw,
|
||||||
|
localNodeIsNsfw,
|
||||||
viewer: user,
|
viewer: user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ describe('shouldBlurProfileMedia', () => {
|
|||||||
expect(shouldBlurProfileMedia({ accountIsNsfw: true, viewer: null })).toBe(true);
|
expect(shouldBlurProfileMedia({ accountIsNsfw: true, viewer: null })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blurs every account from an NSFW node when the viewer has NSFW disabled', () => {
|
it('blurs media from an NSFW node for a viewer on a non-NSFW node with NSFW disabled', () => {
|
||||||
expect(shouldBlurProfileMedia({ nodeIsNsfw: true, viewer: { nsfwEnabled: false } })).toBe(true);
|
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', () => {
|
it('shows sensitive profile media when the viewer has enabled NSFW', () => {
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ export interface ProfileMediaViewer {
|
|||||||
export function shouldBlurProfileMedia({
|
export function shouldBlurProfileMedia({
|
||||||
accountIsNsfw = false,
|
accountIsNsfw = false,
|
||||||
nodeIsNsfw = false,
|
nodeIsNsfw = false,
|
||||||
|
localNodeIsNsfw = false,
|
||||||
viewer,
|
viewer,
|
||||||
}: {
|
}: {
|
||||||
accountIsNsfw?: boolean;
|
accountIsNsfw?: boolean;
|
||||||
nodeIsNsfw?: boolean;
|
nodeIsNsfw?: boolean;
|
||||||
|
localNodeIsNsfw?: boolean;
|
||||||
viewer: ProfileMediaViewer | null;
|
viewer: ProfileMediaViewer | null;
|
||||||
}): boolean {
|
}): 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user