mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-23 01:07:12 +08:00
chore: cleanup images (#14380)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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