From a0cf5681c4ef06ea6208e7ed72a1d90cd9b587dc Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:49:02 +0800 Subject: [PATCH] chore: cleanup images (#14380) ## Summary by CodeRabbit * **New Features** * Added canary build version support with automatic validation and age-based restrictions for testing pre-release versions. * **Chores** * Enhanced Docker build process with multi-stage builds, image optimization, and memory allocation improvements. * Reorganized dependencies to distinguish development-only packages. --- .dockerignore | 26 ++ .github/deployment/node/Dockerfile | 19 +- packages/backend/server/package.json | 4 +- .../backend/server/scripts/docker-clean.mjs | 355 ++++++++++++++++++ .../server/src/__tests__/auth/guard.spec.ts | 71 +++- .../server/src/__tests__/sync/gateway.spec.ts | 124 ++++++ .../server/src/__tests__/version.spec.ts | 54 ++- .../server/src/base/utils/client-version.ts | 91 +++++ .../backend/server/src/base/utils/index.ts | 1 + .../backend/server/src/core/auth/guard.ts | 11 + .../backend/server/src/core/sync/gateway.ts | 24 +- .../server/src/core/version/service.ts | 21 +- 12 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 packages/backend/server/scripts/docker-clean.mjs create mode 100644 packages/backend/server/src/base/utils/client-version.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..5cfbb2f9b3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +.github/**/*.md +.gitignore + +# Local dependency/build artifacts +/node_modules +/target + +# Yarn v4 artifacts (not needed for image packaging) +/.yarn/cache +/.yarn/unplugged +/.yarn/install-state.gz +/.pnp.* + +# Test artifacts +/test-results +/playwright-report +/coverage +/.coverage + +# OS noise +.DS_Store + +# Sourcemaps (keep server sourcemap for backend stacktraces) +**/*.map +!packages/backend/server/dist/main.js.map diff --git a/.github/deployment/node/Dockerfile b/.github/deployment/node/Dockerfile index 0585da280e..7f637cbcfd 100644 --- a/.github/deployment/node/Dockerfile +++ b/.github/deployment/node/Dockerfile @@ -1,11 +1,28 @@ -FROM node:22-bookworm-slim +# syntax=docker/dockerfile:1.7 + +FROM node:22-bookworm-slim AS assets +WORKDIR /app COPY ./packages/backend/server /app COPY ./packages/frontend/apps/web/dist /app/static COPY ./packages/frontend/admin/dist /app/static/admin COPY ./packages/frontend/apps/mobile/dist /app/static/mobile + +# Keep server sourcemap for stacktraces, but don't ship frontend/node_modules sourcemaps. +ARG TARGETARCH +ARG TARGETVARIANT +# Needed for Prisma engine resolution (and potential engine download during cleanup). +RUN apt-get update && \ + apt-get install -y --no-install-recommends openssl ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +RUN AFFINE_DOCKER_CLEAN=1 TARGETARCH="${TARGETARCH}" TARGETVARIANT="${TARGETVARIANT}" node ./scripts/docker-clean.mjs + +FROM node:22-bookworm-slim WORKDIR /app +COPY --from=assets /app /app + RUN apt-get update && \ apt-get install -y --no-install-recommends openssl libjemalloc2 && \ rm -rf /var/lib/apt/lists/* diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 8ed5f0de14..23e541a2bb 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -117,8 +117,6 @@ "socket.io": "^4.8.1", "stripe": "^17.7.0", "tldts": "^7.0.19", - "ts-node": "^10.9.2", - "typescript": "^5.7.2", "winston": "^3.17.0", "yjs": "^13.6.27", "zod": "^3.25.76" @@ -154,6 +152,8 @@ "sinon": "^21.0.1", "socket.io-client": "^4.8.3", "supertest": "^7.1.4", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", "why-is-node-running": "^3.2.2" }, "nodemonConfig": { diff --git a/packages/backend/server/scripts/docker-clean.mjs b/packages/backend/server/scripts/docker-clean.mjs new file mode 100644 index 0000000000..6a23def4db --- /dev/null +++ b/packages/backend/server/scripts/docker-clean.mjs @@ -0,0 +1,355 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const APP_ROOT = process.env.APP_ROOT ?? DEFAULT_APP_ROOT; +const TARGETARCH = process.env.TARGETARCH ?? ''; +const TARGETVARIANT = process.env.TARGETVARIANT ?? ''; +const ALLOW_RUN = process.env.AFFINE_DOCKER_CLEAN === '1'; +const VERBOSE = process.env.AFFINE_DOCKER_CLEAN_VERBOSE === '1'; + +function log(message) { + console.log(`[docker-clean] ${message}`); +} + +function debug(message) { + if (VERBOSE) { + log(message); + } +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function safeReadDir(dirPath) { + try { + return await fs.readdir(dirPath); + } catch { + return []; + } +} + +async function rmrf(targetPath) { + await fs.rm(targetPath, { recursive: true, force: true }); +} + +async function deleteFilesByExtension(rootDir, extension) { + if (!(await exists(rootDir))) { + return 0; + } + + let deleted = 0; + const stack = [rootDir]; + + while (stack.length) { + const current = stack.pop(); + let dir; + try { + dir = await fs.opendir(current); + } catch (err) { + debug(`skip unreadable dir ${current}: ${err?.message ?? String(err)}`); + continue; + } + + try { + for await (const dirent of dir) { + const fullPath = path.join(current, dirent.name); + if (dirent.isDirectory()) { + stack.push(fullPath); + continue; + } + + if ( + (dirent.isFile() || dirent.isSymbolicLink()) && + dirent.name.endsWith(extension) + ) { + try { + await fs.unlink(fullPath); + deleted += 1; + } catch (err) { + debug( + `failed to delete ${fullPath}: ${err?.message ?? String(err)}` + ); + } + } + } + } finally { + await dir.close().catch(() => {}); + } + } + + return deleted; +} + +function normalizeTargetKey(arch, variant) { + // BuildKit: TARGETARCH=arm TARGETVARIANT=v7 + if (arch === 'arm' && variant === 'v7') { + return 'armv7'; + } + return `${arch}${variant ?? ''}`; +} + +function serverNativeArch(targetKey) { + switch (targetKey) { + case 'amd64': + return 'x64'; + case 'arm64': + return 'arm64'; + case 'armv7': + case 'arm': + return 'armv7'; + default: + return ''; + } +} + +async function pruneServerNative(distDir, keepArch) { + if (!keepArch) { + return; + } + + const keepName = `server-native.${keepArch}.node`; + const entries = await safeReadDir(distDir); + + await Promise.all( + entries.map(async name => { + if ( + name.startsWith('server-native.') && + name.endsWith('.node') && + name !== keepName + ) { + await fs.rm(path.join(distDir, name), { force: true }).catch(() => {}); + } + }) + ); +} + +function cpuPruneRegexes(targetKey) { + switch (targetKey) { + case 'arm64': + return [/-linux-x64-/, /-linux-x64$/, /-linux-arm-/, /-linux-arm$/]; + case 'amd64': + return [/-linux-arm64-/, /-linux-arm64$/, /-linux-arm-/, /-linux-arm$/]; + case 'armv7': + case 'arm': + return [/-linux-x64-/, /-linux-x64$/, /-linux-arm64-/, /-linux-arm64$/]; + default: + return []; + } +} + +function shouldPruneDir(name, regexes) { + return regexes.some(re => re.test(name)); +} + +async function pruneOptionalNativeDeps(nodeModulesDir, regexes) { + if (!regexes.length || !(await exists(nodeModulesDir))) { + return; + } + + const topLevel = await safeReadDir(nodeModulesDir); + + for (const name of topLevel) { + const fullPath = path.join(nodeModulesDir, name); + const stat = await fs.lstat(fullPath).catch(() => null); + if (!stat?.isDirectory()) { + continue; + } + + if (name.startsWith('@')) { + const scopedEntries = await safeReadDir(fullPath); + for (const scopedName of scopedEntries) { + const scopedFullPath = path.join(fullPath, scopedName); + const scopedStat = await fs.lstat(scopedFullPath).catch(() => null); + if (!scopedStat?.isDirectory()) { + continue; + } + if (shouldPruneDir(scopedName, regexes)) { + await rmrf(scopedFullPath).catch(() => {}); + } + } + continue; + } + + if (shouldPruneDir(name, regexes)) { + await rmrf(fullPath).catch(() => {}); + } + } +} + +function preferredPrismaTargets(targetKey) { + switch (targetKey) { + case 'arm64': + return ['linux-arm64-openssl-3.0.x', 'linux-arm64-openssl-1.1.x']; + case 'amd64': + return ['debian-openssl-3.0.x', 'debian-openssl-1.1.x']; + case 'armv7': + case 'arm': + return ['linux-arm-openssl-3.0.x', 'linux-arm-openssl-1.1.x']; + default: + return []; + } +} + +async function pickExistingPrismaTarget(prismaClientDir, candidates) { + const entries = new Set(await safeReadDir(prismaClientDir)); + for (const target of candidates) { + if (entries.has(`libquery_engine-${target}.so.node`)) { + return target; + } + } + return ''; +} + +async function prunePrismaQueryEngines(dirPath, keepTarget) { + if (!keepTarget || !(await exists(dirPath))) { + return; + } + + const keepName = `libquery_engine-${keepTarget}.so.node`; + const entries = await safeReadDir(dirPath); + + if (!entries.includes(keepName)) { + return; + } + + for (const name of entries) { + if ( + name.startsWith('libquery_engine-') && + name.endsWith('.so.node') && + name !== keepName + ) { + await fs.rm(path.join(dirPath, name), { force: true }).catch(() => {}); + } + } +} + +function runPrismaVersion(prismaBinPath, cwd) { + const result = spawnSync(prismaBinPath, ['-v'], { + cwd, + env: process.env, + stdio: VERBOSE ? 'inherit' : 'ignore', + }); + return result.status === 0; +} + +async function prunePrismaEngines(appRoot, targetKey) { + const prismaClientDir = path.join( + appRoot, + 'node_modules', + '.prisma', + 'client' + ); + const prismaPkgDir = path.join(appRoot, 'node_modules', 'prisma'); + const prismaEnginesDir = path.join( + appRoot, + 'node_modules', + '@prisma', + 'engines' + ); + const prismaBinPath = path.join(appRoot, 'node_modules', '.bin', 'prisma'); + + if (!(await exists(prismaClientDir))) { + return; + } + + const keepTarget = await pickExistingPrismaTarget( + prismaClientDir, + preferredPrismaTargets(targetKey) + ); + + if (!keepTarget) { + debug('no prisma keepTarget detected, skip prisma pruning'); + return; + } + + await prunePrismaQueryEngines(prismaClientDir, keepTarget); + await prunePrismaQueryEngines(prismaPkgDir, keepTarget); + + const keepSchemaEngine = path.join( + prismaEnginesDir, + `schema-engine-${keepTarget}` + ); + + if ((await exists(prismaBinPath)) && !(await exists(keepSchemaEngine))) { + runPrismaVersion(prismaBinPath, appRoot); + } + + if (!(await exists(keepSchemaEngine))) { + debug(`missing ${keepSchemaEngine}, skip pruning @prisma/engines`); + return; + } + + const keepLibQueryEngine = `libquery_engine-${keepTarget}.so.node`; + const entries = await safeReadDir(prismaEnginesDir); + + for (const name of entries) { + const isEngine = + name.startsWith('schema-engine-') || name.startsWith('libquery_engine-'); + if (!isEngine) { + continue; + } + + const keep = + name === `schema-engine-${keepTarget}` || name === keepLibQueryEngine; + if (!keep) { + await fs + .rm(path.join(prismaEnginesDir, name), { force: true }) + .catch(() => {}); + } + } +} + +const targetKey = normalizeTargetKey(TARGETARCH, TARGETVARIANT); + +log(`root=${APP_ROOT} target=${targetKey || '(unknown)'}`); + +if (!ALLOW_RUN) { + log('skip (set AFFINE_DOCKER_CLEAN=1 to enable)'); + process.exit(0); +} + +const deletedStaticMaps = await deleteFilesByExtension( + path.join(APP_ROOT, 'static'), + '.map' +); +const deletedNodeModulesMaps = await deleteFilesByExtension( + path.join(APP_ROOT, 'node_modules'), + '.map' +); + +debug(`deleted static maps: ${deletedStaticMaps}`); +debug(`deleted node_modules maps: ${deletedNodeModulesMaps}`); + +const distDir = path.join(APP_ROOT, 'dist'); +await pruneServerNative(distDir, serverNativeArch(targetKey)); + +await pruneOptionalNativeDeps( + path.join(APP_ROOT, 'node_modules'), + cpuPruneRegexes(targetKey) +); + +await prunePrismaEngines(APP_ROOT, targetKey); + +await Promise.all([ + rmrf(path.join(APP_ROOT, 'node_modules', 'typescript')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'node_modules', 'ts-node')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'node_modules', '@types')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'src')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'scripts')).catch(() => {}), + rmrf(path.join(APP_ROOT, '.gitignore')).catch(() => {}), + rmrf(path.join(APP_ROOT, '.dockerignore')).catch(() => {}), + rmrf(path.join(APP_ROOT, '.env.example')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'ava.config.js')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'tsconfig.json')).catch(() => {}), + rmrf(path.join(APP_ROOT, 'config.example.json')).catch(() => {}), +]); diff --git a/packages/backend/server/src/__tests__/auth/guard.spec.ts b/packages/backend/server/src/__tests__/auth/guard.spec.ts index ecd3d21d31..9011914bcc 100644 --- a/packages/backend/server/src/__tests__/auth/guard.spec.ts +++ b/packages/backend/server/src/__tests__/auth/guard.spec.ts @@ -4,7 +4,7 @@ import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import request from 'supertest'; -import { ConfigFactory } from '../../base'; +import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base'; import { AuthModule, CurrentUser, Public, Session } from '../../core/auth'; import { AuthService } from '../../core/auth/service'; import { Models } from '../../models'; @@ -29,6 +29,10 @@ class TestController { } } +function makeCanaryDateVersion(date: Date, build = '015') { + return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`; +} + const test = ava as TestFn<{ app: TestingApp; server: any; @@ -272,3 +276,68 @@ test('should not block public handler when client version is unsupported', async t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`))); t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`))); }); + +test('should allow recent canary date version in canary namespace', async t => { + t.context.config.override({ + client: { + versionControl: { + enabled: true, + requiredVersion: '>=0.25.0', + }, + }, + }); + + const prevNamespace = env.NAMESPACE; + // @ts-expect-error test + env.NAMESPACE = 'dev'; + + try { + const res = await request(t.context.server) + .get('/private') + .set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) + .set('x-affine-version', makeCanaryDateVersion(new Date(), '015')) + .expect(200); + + t.is(res.body.user.id, t.context.u1.id); + } finally { + // @ts-expect-error test + env.NAMESPACE = prevNamespace; + } +}); + +test('should kick out old canary date version in canary namespace', async t => { + t.context.config.override({ + client: { + versionControl: { + enabled: true, + requiredVersion: '>=0.25.0', + }, + }, + }); + + const prevNamespace = env.NAMESPACE; + // @ts-expect-error test + env.NAMESPACE = 'dev'; + + try { + const old = new Date( + Date.now() - + (CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000 + ); + const oldVersion = makeCanaryDateVersion(old, '015'); + + const res = await request(t.context.server) + .get('/private') + .set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) + .set('x-affine-version', oldVersion) + .expect(403); + + t.is( + res.body.message, + `Unsupported client with version [${oldVersion}], required version is [canary (within 2 months)].` + ); + } finally { + // @ts-expect-error test + env.NAMESPACE = prevNamespace; + } +}); diff --git a/packages/backend/server/src/__tests__/sync/gateway.spec.ts b/packages/backend/server/src/__tests__/sync/gateway.spec.ts index 5f25c3ef8e..be2c76cf1e 100644 --- a/packages/backend/server/src/__tests__/sync/gateway.spec.ts +++ b/packages/backend/server/src/__tests__/sync/gateway.spec.ts @@ -2,6 +2,7 @@ import test, { type ExecutionContext } from 'ava'; import { io, type Socket as SocketIOClient } from 'socket.io-client'; import { Doc, encodeStateAsUpdate } from 'yjs'; +import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS } from '../../base'; import { createTestingApp, TestingApp } from '../utils'; type WebsocketResponse = @@ -10,6 +11,10 @@ type WebsocketResponse = const WS_TIMEOUT_MS = 5_000; +function makeCanaryDateVersion(date: Date, build = '015') { + return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`; +} + function unwrapResponse(t: ExecutionContext, res: WebsocketResponse): T { if ('data' in res) { return res.data; @@ -285,6 +290,83 @@ test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', as } }); +test('canary date clientVersion should use sync-026 in canary namespace', async t => { + const prevNamespace = env.NAMESPACE; + // @ts-expect-error test + env.NAMESPACE = 'dev'; + + try { + const { user, cookieHeader } = await login(app); + const spaceId = user.id; + const update = createYjsUpdateBase64(); + + const sender = createClient(url, cookieHeader); + const receiver = createClient(url, cookieHeader); + + try { + await Promise.all([waitForConnect(sender), waitForConnect(receiver)]); + + const receiverJoin = unwrapResponse( + t, + await emitWithAck<{ clientId: string; success: boolean }>( + receiver, + 'space:join', + { + spaceType: 'userspace', + spaceId, + clientVersion: makeCanaryDateVersion(new Date(), '015'), + } + ) + ); + t.true(receiverJoin.success); + + const senderJoin = unwrapResponse( + t, + await emitWithAck<{ clientId: string; success: boolean }>( + sender, + 'space:join', + { spaceType: 'userspace', spaceId, clientVersion: '0.25.0' } + ) + ); + t.true(senderJoin.success); + + const onUpdates = waitForEvent<{ + spaceType: string; + spaceId: string; + docId: string; + updates: string[]; + }>(receiver, 'space:broadcast-doc-updates'); + const noUpdate = expectNoEvent(receiver, 'space:broadcast-doc-update'); + + const pushRes = await emitWithAck<{ accepted: true; timestamp?: number }>( + sender, + 'space:push-doc-update', + { + spaceType: 'userspace', + spaceId, + docId: 'doc-canary', + update, + } + ); + unwrapResponse(t, pushRes); + + const message = await onUpdates; + t.is(message.spaceType, 'userspace'); + t.is(message.spaceId, spaceId); + t.is(message.docId, 'doc-canary'); + t.deepEqual(message.updates, [update]); + + await noUpdate; + } finally { + sender.disconnect(); + receiver.disconnect(); + } + } finally { + // @ts-expect-error test + env.NAMESPACE = prevNamespace; + } +}); + test('clientVersion<0.25.0 should be rejected and disconnected', async t => { const { user, cookieHeader } = await login(app); const spaceId = user.id; @@ -309,6 +391,48 @@ test('clientVersion<0.25.0 should be rejected and disconnected', async t => { } }); +test('old canary date clientVersion should be rejected and disconnected in canary namespace', async t => { + const prevNamespace = env.NAMESPACE; + // @ts-expect-error test + env.NAMESPACE = 'dev'; + + try { + const { user, cookieHeader } = await login(app); + const spaceId = user.id; + + const socket = createClient(url, cookieHeader); + try { + await waitForConnect(socket); + + const old = new Date( + Date.now() - + (CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000 + ); + + const res = unwrapResponse( + t, + await emitWithAck<{ clientId: string; success: boolean }>( + socket, + 'space:join', + { + spaceType: 'userspace', + spaceId, + clientVersion: makeCanaryDateVersion(old, '015'), + } + ) + ); + t.false(res.success); + + await waitForDisconnect(socket); + } finally { + socket.disconnect(); + } + } finally { + // @ts-expect-error test + env.NAMESPACE = prevNamespace; + } +}); + test('space:join-awareness should reject clientVersion<0.25.0', async t => { const { user, cookieHeader } = await login(app); const spaceId = user.id; diff --git a/packages/backend/server/src/__tests__/version.spec.ts b/packages/backend/server/src/__tests__/version.spec.ts index 6e156273b7..418bf579bc 100644 --- a/packages/backend/server/src/__tests__/version.spec.ts +++ b/packages/backend/server/src/__tests__/version.spec.ts @@ -3,7 +3,11 @@ import test from 'ava'; import Sinon from 'sinon'; import { AppModule } from '../app.module'; -import { ConfigFactory, UseNamedGuard } from '../base'; +import { + CANARY_CLIENT_VERSION_MAX_AGE_DAYS, + ConfigFactory, + UseNamedGuard, +} from '../base'; import { Public } from '../core/auth/guard'; import { VersionService } from '../core/version/service'; import { createTestingApp, TestingApp } from './utils'; @@ -33,6 +37,10 @@ function checkVersion(enabled = true) { }); } +function makeCanaryDateVersion(date: Date, build = '015') { + return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`; +} + test.before(async () => { app = await createTestingApp({ imports: [AppModule], @@ -197,3 +205,47 @@ test('should test prerelease version', async t => { t.is(res.status, 200); }); + +test('should allow recent canary date version in canary namespace', async t => { + const prevNamespace = env.NAMESPACE; + // @ts-expect-error test + env.NAMESPACE = 'dev'; + + try { + const res = await app + .GET('/guarded/test') + .set('x-affine-version', makeCanaryDateVersion(new Date(), '015')); + + t.is(res.status, 200); + } finally { + // @ts-expect-error test + env.NAMESPACE = prevNamespace; + } +}); + +test('should reject old canary date version in canary namespace', async t => { + const prevNamespace = env.NAMESPACE; + // @ts-expect-error test + env.NAMESPACE = 'dev'; + + try { + const old = new Date( + Date.now() - + (CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000 + ); + const oldVersion = makeCanaryDateVersion(old, '015'); + + const res = await app + .GET('/guarded/test') + .set('x-affine-version', oldVersion); + + t.is(res.status, 403); + t.is( + res.body.message, + `Unsupported client with version [${oldVersion}], required version is [canary (within 2 months)].` + ); + } finally { + // @ts-expect-error test + env.NAMESPACE = prevNamespace; + } +}); diff --git a/packages/backend/server/src/base/utils/client-version.ts b/packages/backend/server/src/base/utils/client-version.ts new file mode 100644 index 0000000000..66be07cba9 --- /dev/null +++ b/packages/backend/server/src/base/utils/client-version.ts @@ -0,0 +1,91 @@ +const DAY_MS = 24 * 60 * 60 * 1000; + +// Example: 2026.2.6-canary.015 +const CANARY_DATE_VERSION_RE = + /^v?(\d{4})\.(\d{1,2})\.(\d{1,2})-canary\.(\d+)(?:\+.*)?$/i; + +export const CANARY_CLIENT_VERSION_MAX_AGE_DAYS = 62; // ~2 months +export const CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS = 2; + +export type CanaryDateClientVersion = { + raw: string; + normalized: string; + dateMs: number; +}; + +export function parseCanaryDateClientVersion( + version: string +): CanaryDateClientVersion | null { + const raw = version.trim(); + const match = CANARY_DATE_VERSION_RE.exec(raw); + if (!match) { + return null; + } + + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + const build = match[4].replace(/^0+(?=\d)/, ''); + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + year < 0 || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return null; + } + + const dateMs = Date.UTC(year, month - 1, day); + const date = new Date(dateMs); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + + return { + raw, + normalized: `${year}.${month}.${day}-canary.${build}`, + dateMs, + }; +} + +export type CanaryClientVersionCheckResult = + | { matched: false } + | { matched: true; allowed: boolean; normalized: string }; + +export function checkCanaryDateClientVersion( + version: string, + options?: { + nowMs?: number; + maxAgeDays?: number; + maxFutureSkewDays?: number; + } +): CanaryClientVersionCheckResult { + const parsed = parseCanaryDateClientVersion(version); + if (!parsed) { + return { matched: false }; + } + + const nowMs = options?.nowMs ?? Date.now(); + const maxAgeDays = options?.maxAgeDays ?? CANARY_CLIENT_VERSION_MAX_AGE_DAYS; + const maxFutureSkewDays = + options?.maxFutureSkewDays ?? CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS; + + const ageMs = nowMs - parsed.dateMs; + const maxAgeMs = maxAgeDays * DAY_MS; + const maxFutureSkewMs = maxFutureSkewDays * DAY_MS; + + return { + matched: true, + allowed: ageMs <= maxAgeMs && ageMs >= -maxFutureSkewMs, + normalized: parsed.normalized, + }; +} diff --git a/packages/backend/server/src/base/utils/index.ts b/packages/backend/server/src/base/utils/index.ts index d1f5694df1..45b386e942 100644 --- a/packages/backend/server/src/base/utils/index.ts +++ b/packages/backend/server/src/base/utils/index.ts @@ -1,3 +1,4 @@ +export * from './client-version'; export * from './duration'; export * from './promise'; export * from './request'; diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index 5c7f874fcc..287537af27 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -14,6 +14,7 @@ import { AccessDenied, AuthenticationRequired, Cache, + checkCanaryDateClientVersion, Config, CryptoHelper, getClientVersionFromRequest, @@ -35,6 +36,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { private auth!: AuthService; private readonly cachedVersionRange = new Map(); private static readonly HARD_REQUIRED_VERSION = '>=0.25.0'; + private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)'; constructor( private readonly crypto: CryptoHelper, @@ -218,6 +220,15 @@ export class AuthGuard implements CanActivate, OnModuleInit { ): { ok: true } | { ok: false; requiredVersion: string } { const requiredVersion = this.config.client.versionControl.requiredVersion; + if (clientVersion && env.namespaces.canary) { + const canaryCheck = checkCanaryDateClientVersion(clientVersion); + if (canaryCheck.matched) { + return canaryCheck.allowed + ? { ok: true } + : { ok: false, requiredVersion: AuthGuard.CANARY_REQUIRED_VERSION }; + } + } + const configRange = this.getVersionRange(requiredVersion); if ( configRange && diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index 54d4bf327c..ff4484fa90 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -14,6 +14,7 @@ import { type Server, Socket } from 'socket.io'; import { CallMetric, + checkCanaryDateClientVersion, DocNotFound, DocUpdateBlocked, EventBus, @@ -71,14 +72,33 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', { type SyncProtocolRoomType = Extract; +function normalizeWsClientVersion(clientVersion: string): string | null { + if (env.namespaces.canary) { + const canaryCheck = checkCanaryDateClientVersion(clientVersion); + if (canaryCheck.matched) { + return canaryCheck.allowed ? canaryCheck.normalized : null; + } + } + + return clientVersion; +} + function isSupportedWsClientVersion(clientVersion: string): boolean { + const normalized = normalizeWsClientVersion(clientVersion); + if (!normalized) { + return false; + } + return Boolean( - semver.valid(clientVersion) && MIN_WS_CLIENT_VERSION.test(clientVersion) + semver.valid(normalized) && MIN_WS_CLIENT_VERSION.test(normalized) ); } function getSyncProtocolRoomType(clientVersion: string): SyncProtocolRoomType { - return DOC_UPDATES_PROTOCOL_026.test(clientVersion) ? 'sync-026' : 'sync-025'; + const normalized = normalizeWsClientVersion(clientVersion); + return DOC_UPDATES_PROTOCOL_026.test(normalized ?? clientVersion) + ? 'sync-026' + : 'sync-025'; } enum SpaceType { diff --git a/packages/backend/server/src/core/version/service.ts b/packages/backend/server/src/core/version/service.ts index bc7705103e..8611714699 100644 --- a/packages/backend/server/src/core/version/service.ts +++ b/packages/backend/server/src/core/version/service.ts @@ -1,18 +1,37 @@ import { Injectable, Logger } from '@nestjs/common'; import semver from 'semver'; -import { Config, UnsupportedClientVersion } from '../../base'; +import { + checkCanaryDateClientVersion, + Config, + UnsupportedClientVersion, +} from '../../base'; @Injectable() export class VersionService { private readonly logger = new Logger(VersionService.name); private static readonly HARD_REQUIRED_VERSION = '>=0.25.0'; + private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)'; constructor(private readonly config: Config) {} async checkVersion(clientVersion?: string) { const requiredVersion = this.config.client.versionControl.requiredVersion; + if (clientVersion && env.namespaces.canary) { + const canaryCheck = checkCanaryDateClientVersion(clientVersion); + if (canaryCheck.matched) { + if (canaryCheck.allowed) { + return true; + } + + throw new UnsupportedClientVersion({ + clientVersion, + requiredVersion: VersionService.CANARY_REQUIRED_VERSION, + }); + } + } + const hardRange = await this.getVersionRange( VersionService.HARD_REQUIRED_VERSION );