mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 17:43:51 +00:00
Compare commits
1 Commits
v0.26.0-be
...
canary
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0cf5681c4 |
26
.dockerignore
Normal file
26
.dockerignore
Normal file
@@ -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
|
||||
19
.github/deployment/node/Dockerfile
vendored
19
.github/deployment/node/Dockerfile
vendored
@@ -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/*
|
||||
|
||||
@@ -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": {
|
||||
|
||||
355
packages/backend/server/scripts/docker-clean.mjs
Normal file
355
packages/backend/server/scripts/docker-clean.mjs
Normal file
@@ -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(() => {}),
|
||||
]);
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<T> =
|
||||
@@ -10,6 +11,10 @@ type WebsocketResponse<T> =
|
||||
|
||||
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>(t: ExecutionContext, res: WebsocketResponse<T>): 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
91
packages/backend/server/src/base/utils/client-version.ts
Normal file
91
packages/backend/server/src/base/utils/client-version.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './client-version';
|
||||
export * from './duration';
|
||||
export * from './promise';
|
||||
export * from './request';
|
||||
|
||||
@@ -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<string, semver.Range | null>();
|
||||
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 &&
|
||||
|
||||
@@ -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<RoomType, 'sync-025' | 'sync-026'>;
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user