chore: drop old client support (#14369)

This commit is contained in:
DarkSky
2026-02-05 02:49:33 +08:00
committed by GitHub
parent de29e8300a
commit 403f16b404
103 changed files with 3293 additions and 997 deletions
@@ -28,13 +28,6 @@ class GenerateAccessTokenInput {
export class AccessTokenResolver {
constructor(private readonly models: Models) {}
@Query(() => [AccessToken], {
deprecationReason: 'use currentUser.accessTokens',
})
async accessTokens(@CurrentUser() user: CurrentUser): Promise<AccessToken[]> {
return await this.models.accessToken.list(user.id);
}
@Query(() => [RevealedAccessToken], {
deprecationReason: 'use currentUser.revealedAccessTokens',
})
@@ -16,7 +16,6 @@ import type { Request, Response } from 'express';
import {
ActionForbidden,
Cache,
Config,
CryptoHelper,
EmailTokenNotFound,
@@ -53,7 +52,9 @@ interface MagicLinkCredential {
client_nonce?: string;
}
const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`;
interface OpenAppSignInCredential {
code: string;
}
@Throttle('strict')
@Controller('/api/auth')
@@ -65,7 +66,6 @@ export class AuthController {
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config,
private readonly cache: Cache,
private readonly crypto: CryptoHelper
) {
if (env.dev) {
@@ -111,11 +111,7 @@ export class AuthController {
async signIn(
@Req() req: Request,
@Res() res: Response,
@Body() credential: SignInCredential,
/**
* @deprecated
*/
@Query('redirect_uri') redirectUri?: string
@Body() credential: SignInCredential
) {
validators.assertValidEmail(credential.email);
const canSignIn = await this.auth.canSignIn(credential.email);
@@ -132,11 +128,9 @@ export class AuthController {
);
} else {
await this.sendMagicLink(
req,
res,
credential.email,
credential.callbackUrl,
redirectUri,
credential.client_nonce
);
}
@@ -155,13 +149,25 @@ export class AuthController {
}
async sendMagicLink(
_req: Request,
res: Response,
email: string,
callbackUrl = '/magic-link',
redirectUrl?: string,
clientNonce?: string
) {
if (!this.url.isAllowedCallbackUrl(callbackUrl)) {
throw new ActionForbidden();
}
const callbackUrlObj = this.url.url(callbackUrl);
const redirectUriInCallback =
callbackUrlObj.searchParams.get('redirect_uri');
if (
redirectUriInCallback &&
!this.url.isAllowedRedirectUri(redirectUriInCallback)
) {
throw new ActionForbidden();
}
// send email magic link
const user = await this.models.user.getUserByEmail(email, {
withDisabled: true,
@@ -207,23 +213,9 @@ export class AuthController {
);
const otp = this.crypto.otp();
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
const cacheKey = OTP_CACHE_KEY(otp);
await this.cache.set(
cacheKey,
{ token, clientNonce },
{ ttl: ttlInSec * 1000 }
);
await this.models.magicLinkOtp.upsert(email, otp, token, clientNonce);
const magicLink = this.url.link(callbackUrl, {
token: otp,
email,
...(redirectUrl
? {
redirect_uri: redirectUrl,
}
: {}),
});
const magicLink = this.url.link(callbackUrl, { token: otp, email });
if (env.dev) {
// make it easier to test in dev mode
this.logger.debug(`Magic link: ${magicLink}`);
@@ -237,8 +229,9 @@ export class AuthController {
}
@Public()
@Get('/sign-out')
@Post('/sign-out')
async signOut(
@Req() req: Request,
@Res() res: Response,
@Session() session: Session | undefined,
@Query('user_id') userId: string | undefined
@@ -248,12 +241,63 @@ export class AuthController {
return;
}
const csrfCookie = req.cookies?.[AuthService.csrfCookieName] as
| string
| undefined;
const csrfHeader = req.get('x-affine-csrf-token');
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
throw new ActionForbidden();
}
await this.auth.signOut(session.sessionId, userId);
await this.auth.refreshCookies(res, session.sessionId);
res.status(HttpStatus.OK).send({});
}
@Public()
@UseNamedGuard('version')
@Post('/open-app/sign-in-code')
async openAppSignInCode(@CurrentUser() user?: CurrentUser) {
if (!user) {
throw new ActionForbidden();
}
// short-lived one-time code for handing off the authenticated session
const code = await this.models.verificationToken.create(
TokenType.OpenAppSignIn,
user.id,
5 * 60
);
return { code };
}
@Public()
@UseNamedGuard('version')
@Post('/open-app/sign-in')
async openAppSignIn(
@Req() req: Request,
@Res() res: Response,
@Body() credential: OpenAppSignInCredential
) {
if (!credential?.code) {
throw new InvalidAuthState();
}
const tokenRecord = await this.models.verificationToken.get(
TokenType.OpenAppSignIn,
credential.code
);
if (!tokenRecord?.credential) {
throw new InvalidAuthState();
}
await this.auth.setCookies(req, res, tokenRecord.credential);
res.send({ id: tokenRecord.credential });
}
@Public()
@UseNamedGuard('version')
@Post('/magic-link')
@@ -269,23 +313,20 @@ export class AuthController {
validators.assertValidEmail(email);
const cacheKey = OTP_CACHE_KEY(otp);
const cachedToken = await this.cache.get<{
token: string;
clientNonce: string;
}>(cacheKey);
let token: string | undefined;
if (cachedToken && typeof cachedToken === 'object') {
token = cachedToken.token;
if (cachedToken.clientNonce && cachedToken.clientNonce !== clientNonce) {
const consumed = await this.models.magicLinkOtp.consume(
email,
otp,
clientNonce
);
if (!consumed.ok) {
if (consumed.reason === 'nonce_mismatch') {
throw new InvalidAuthState();
}
}
if (!token) {
throw new InvalidEmailToken();
}
const token = consumed.token;
const tokenRecord = await this.models.verificationToken.verify(
TokenType.SignIn,
token,
+25 -3
View File
@@ -12,6 +12,7 @@ import { Socket } from 'socket.io';
import {
AccessDenied,
AuthenticationRequired,
Cache,
Config,
CryptoHelper,
getRequestResponseFromContext,
@@ -23,6 +24,8 @@ import { Session, TokenSession } from './session';
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
const INTERNAL_ACCESS_TOKEN_TTL_MS = 5 * 60 * 1000;
const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
@Injectable()
export class AuthGuard implements CanActivate, OnModuleInit {
@@ -30,6 +33,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
constructor(
private readonly crypto: CryptoHelper,
private readonly cache: Cache,
private readonly ref: ModuleRef,
private readonly reflector: Reflector
) {}
@@ -48,10 +52,28 @@ export class AuthGuard implements CanActivate, OnModuleInit {
[clazz, handler]
);
if (isInternal) {
// check access token: data,signature
const accessToken = req.get('x-access-token');
if (accessToken && this.crypto.verify(accessToken)) {
return true;
if (accessToken) {
const payload = this.crypto.parseInternalAccessToken(accessToken);
if (payload) {
const now = Date.now();
const method = req.method.toUpperCase();
const path = req.path;
const timestampInRange =
payload.ts <= now + INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS &&
now - payload.ts <= INTERNAL_ACCESS_TOKEN_TTL_MS;
if (timestampInRange && payload.m === method && payload.p === path) {
const nonceKey = `rpc:nonce:${payload.nonce}`;
const ok = await this.cache.setnx(nonceKey, 1, {
ttl: INTERNAL_ACCESS_TOKEN_TTL_MS,
});
if (ok) {
return true;
}
}
}
}
throw new AccessDenied('Invalid internal request');
}
@@ -159,7 +159,7 @@ export class AuthResolver {
user.id
);
const url = this.url.link(callbackUrl, { userId: user.id, token });
const url = this.url.safeLink(callbackUrl, { userId: user.id, token });
return await this.auth.sendChangePasswordEmail(user.email, url);
}
@@ -200,7 +200,7 @@ export class AuthResolver {
user.id
);
const url = this.url.link(callbackUrl, { token });
const url = this.url.safeLink(callbackUrl, { token });
return await this.auth.sendChangeEmail(user.email, url);
}
@@ -244,7 +244,10 @@ export class AuthResolver {
user.id
);
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
const url = this.url.safeLink(callbackUrl, {
token: verifyEmailToken,
email,
});
return await this.auth.sendVerifyChangeEmail(email, url);
}
@@ -258,7 +261,7 @@ export class AuthResolver {
user.id
);
const url = this.url.link(callbackUrl, { token });
const url = this.url.safeLink(callbackUrl, { token });
return await this.auth.sendVerifyEmail(user.email, url);
}
@@ -302,6 +305,6 @@ export class AuthResolver {
userId
);
return this.url.link(callbackUrl, { userId, token });
return this.url.safeLink(callbackUrl, { userId, token });
}
}
@@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { CookieOptions, Request, Response } from 'express';
import { assign, pick } from 'lodash-es';
@@ -39,6 +41,7 @@ export class AuthService implements OnApplicationBootstrap {
};
static readonly sessionCookieName = 'affine_session';
static readonly userCookieName = 'affine_user_id';
static readonly csrfCookieName = 'affine_csrf_token';
constructor(
private readonly config: Config,
@@ -171,6 +174,11 @@ export class AuthService implements OnApplicationBootstrap {
expires: newExpiresAt,
...this.cookieOptions,
});
res.cookie(AuthService.csrfCookieName, randomUUID(), {
expires: newExpiresAt,
...this.cookieOptions,
httpOnly: false,
});
return true;
}
@@ -207,6 +215,12 @@ export class AuthService implements OnApplicationBootstrap {
expires: userSession.expiresAt ?? void 0,
});
res.cookie(AuthService.csrfCookieName, randomUUID(), {
...this.cookieOptions,
httpOnly: false,
expires: userSession.expiresAt ?? void 0,
});
this.setUserCookie(res, userId);
}
@@ -227,6 +241,7 @@ export class AuthService implements OnApplicationBootstrap {
private clearCookies(res: Response<any, Record<string, any>>) {
res.clearCookie(AuthService.sessionCookieName);
res.clearCookie(AuthService.userCookieName);
res.clearCookie(AuthService.csrfCookieName);
}
setUserCookie(res: Response, userId: string) {
@@ -240,18 +240,6 @@ export class AppConfigResolver {
return this.validateConfigInternal(updates);
}
@Mutation(() => [AppConfigValidateResult], {
description: 'validate app configuration',
deprecationReason: 'use Query.validateAppConfig',
name: 'validateAppConfig',
})
async validateAppConfigMutation(
@Args('updates', { type: () => [UpdateAppConfigInput] })
updates: UpdateAppConfigInput[]
): Promise<AppConfigValidateResult[]> {
return this.validateConfigInternal(updates);
}
private validateConfigInternal(
updates: UpdateAppConfigInput[]
): AppConfigValidateResult[] {
@@ -77,14 +77,124 @@ test('should forbid access to rpc api with invalid access token', async t => {
t.pass();
});
test('should forbid replayed internal access token', async t => {
const { app } = t.context;
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}`;
const token = t.context.crypto.signInternalAccessToken({
method: 'GET',
path,
nonce: `nonce-${randomUUID()}`,
});
await app.GET(path).set('x-access-token', token).expect(404);
await app
.GET(path)
.set('x-access-token', token)
.expect({
status: 403,
code: 'Forbidden',
type: 'NO_PERMISSION',
name: 'ACCESS_DENIED',
message: 'Invalid internal request',
})
.expect(403);
t.pass();
});
test('should forbid internal access token when method mismatched', async t => {
const { app } = t.context;
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}/diff`;
await app
.POST(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect({
status: 403,
code: 'Forbidden',
type: 'NO_PERMISSION',
name: 'ACCESS_DENIED',
message: 'Invalid internal request',
})
.expect(403);
t.pass();
});
test('should forbid internal access token when path mismatched', async t => {
const { app } = t.context;
const workspaceId = '123';
const docId = '123';
const wrongPath = `/rpc/workspaces/${workspaceId}/docs/${docId}`;
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}/content`;
await app
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({
method: 'GET',
path: wrongPath,
})
)
.expect({
status: 403,
code: 'Forbidden',
type: 'NO_PERMISSION',
name: 'ACCESS_DENIED',
message: 'Invalid internal request',
})
.expect(403);
t.pass();
});
test('should forbid internal access token when expired', async t => {
const { app } = t.context;
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}`;
await app
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({
method: 'GET',
path,
now: Date.now() - 10 * 60 * 1000,
nonce: `nonce-${randomUUID()}`,
})
)
.expect({
status: 403,
code: 'Forbidden',
type: 'NO_PERMISSION',
name: 'ACCESS_DENIED',
message: 'Invalid internal request',
})
.expect(403);
t.pass();
});
test('should 404 when doc not found', async t => {
const { app } = t.context;
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}`;
await app
.GET(`/rpc/workspaces/${workspaceId}/docs/${docId}`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect({
status: 404,
code: 'Not Found',
@@ -111,9 +221,13 @@ test('should return doc when found', async t => {
},
]);
const path = `/rpc/workspaces/${workspace.id}/docs/${docId}`;
const res = await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.set('x-cloud-trace-context', 'test-trace-id/span-id')
.expect(200)
.expect('x-request-id', 'test-trace-id')
@@ -129,9 +243,13 @@ test('should 404 when doc diff not found', async t => {
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}/diff`;
await app
.POST(`/rpc/workspaces/${workspaceId}/docs/${docId}/diff`)
.set('x-access-token', t.context.crypto.sign(docId))
.POST(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'POST', path })
)
.expect({
status: 404,
code: 'Not Found',
@@ -148,9 +266,13 @@ test('should 404 when doc content not found', async t => {
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}/content`;
await app
.GET(`/rpc/workspaces/${workspaceId}/docs/${docId}/content`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect({
status: 404,
code: 'Not Found',
@@ -172,9 +294,13 @@ test('should get doc content in json format', async t => {
});
const docId = randomUUID();
const path = `/rpc/workspaces/${workspace.id}/docs/${docId}/content`;
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect('Content-Type', 'application/json; charset=utf-8')
.expect({
title: 'test title',
@@ -183,8 +309,11 @@ test('should get doc content in json format', async t => {
.expect(200);
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=false`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(`${path}?full=false`)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect('Content-Type', 'application/json; charset=utf-8')
.expect({
title: 'test title',
@@ -204,9 +333,13 @@ test('should get full doc content in json format', async t => {
});
const docId = randomUUID();
const path = `/rpc/workspaces/${workspace.id}/docs/${docId}/content`;
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=true`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(`${path}?full=true`)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect('Content-Type', 'application/json; charset=utf-8')
.expect({
title: 'test title',
@@ -220,9 +353,13 @@ test('should 404 when workspace content not found', async t => {
const { app } = t.context;
const workspaceId = '123';
const path = `/rpc/workspaces/${workspaceId}/content`;
await app
.GET(`/rpc/workspaces/${workspaceId}/content`)
.set('x-access-token', t.context.crypto.sign(workspaceId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect({
status: 404,
code: 'Not Found',
@@ -244,9 +381,13 @@ test('should get workspace content in json format', async t => {
});
const workspaceId = randomUUID();
const path = `/rpc/workspaces/${workspaceId}/content`;
await app
.GET(`/rpc/workspaces/${workspaceId}/content`)
.set('x-access-token', t.context.crypto.sign(workspaceId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect(200)
.expect({
name: 'test name',
@@ -265,9 +406,13 @@ test('should get doc markdown in json format', async t => {
});
const docId = randomUUID();
const path = `/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`;
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200)
.expect({
@@ -282,9 +427,13 @@ test('should 404 when doc markdown not found', async t => {
const workspaceId = '123';
const docId = '123';
const path = `/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`;
await app
.GET(`/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`)
.set('x-access-token', t.context.crypto.sign(docId))
.GET(path)
.set(
'x-access-token',
t.context.crypto.signInternalAccessToken({ method: 'GET', path })
)
.expect({
status: 404,
code: 'Not Found',
+12 -16
View File
@@ -257,12 +257,13 @@ export class RpcDocReader extends DatabaseDocReader {
super(cache, models, blobStorage, workspace);
}
private async fetch(
accessToken: string,
url: string,
method: 'GET' | 'POST',
body?: Uint8Array
) {
private async fetch(url: string, method: 'GET' | 'POST', body?: Uint8Array) {
const { pathname } = new URL(url);
const accessToken = this.crypto.signInternalAccessToken({
method,
path: pathname,
});
const headers: Record<string, string> = {
'x-access-token': accessToken,
'x-cloud-trace-context': getOrGenRequestId('rpc'),
@@ -293,9 +294,8 @@ export class RpcDocReader extends DatabaseDocReader {
docId: string
): Promise<DocRecord | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}`;
const accessToken = this.crypto.sign(docId);
try {
const res = await this.fetch(accessToken, url, 'GET');
const res = await this.fetch(url, 'GET');
if (!res) {
return null;
}
@@ -330,9 +330,8 @@ export class RpcDocReader extends DatabaseDocReader {
aiEditable: boolean
): Promise<DocMarkdown | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown?aiEditable=${aiEditable}`;
const accessToken = this.crypto.sign(docId);
try {
const res = await this.fetch(accessToken, url, 'GET');
const res = await this.fetch(url, 'GET');
if (!res) {
return null;
}
@@ -358,9 +357,8 @@ export class RpcDocReader extends DatabaseDocReader {
stateVector?: Uint8Array
): Promise<DocDiff | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/diff`;
const accessToken = this.crypto.sign(docId);
try {
const res = await this.fetch(accessToken, url, 'POST', stateVector);
const res = await this.fetch(url, 'POST', stateVector);
if (!res) {
return null;
}
@@ -399,9 +397,8 @@ export class RpcDocReader extends DatabaseDocReader {
fullContent = false
): Promise<PageDocContent | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/content?full=${fullContent}`;
const accessToken = this.crypto.sign(docId);
try {
const res = await this.fetch(accessToken, url, 'GET');
const res = await this.fetch(url, 'GET');
if (!res) {
return null;
}
@@ -427,9 +424,8 @@ export class RpcDocReader extends DatabaseDocReader {
workspaceId: string
): Promise<WorkspaceDocInfo | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/content`;
const accessToken = this.crypto.sign(workspaceId);
try {
const res = await this.fetch(accessToken, url, 'GET');
const res = await this.fetch(url, 'GET');
if (!res) {
return null;
}
@@ -130,7 +130,7 @@ export abstract class DocStorageAdapter extends Connection {
snapshot: DocRecord | null,
finalUpdate: DocUpdate
) {
this.logger.log(
this.logger.verbose(
`Squashing updates, spaceId: ${spaceId}, docId: ${docId}, updates: ${updates.length}`
);
@@ -152,7 +152,7 @@ export abstract class DocStorageAdapter extends Connection {
// always mark updates as merged unless throws
const count = await this.markUpdatesMerged(spaceId, docId, updates);
this.logger.log(
this.logger.verbose(
`Marked ${count} updates as merged, spaceId: ${spaceId}, docId: ${docId}, timestamp: ${timestamp}`
);
@@ -0,0 +1,90 @@
import ava, { TestFn } from 'ava';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { buildAppModule } from '../../../app.module';
import { Models } from '../../../models';
const test = ava as TestFn<{
app: TestingApp;
models: Models;
allowlistedAdminToken: string;
nonAllowlistedAdminToken: string;
userToken: string;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [buildAppModule(globalThis.env)],
});
t.context.app = app;
t.context.models = app.get(Models);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
const allowlistedAdmin = await t.context.models.user.create({
email: 'admin@affine.pro',
password: '1',
emailVerifiedAt: new Date(),
});
await t.context.models.userFeature.add(
allowlistedAdmin.id,
'administrator',
'test'
);
const allowlistedAdminToken = await t.context.models.accessToken.create({
userId: allowlistedAdmin.id,
name: 'test',
});
t.context.allowlistedAdminToken = allowlistedAdminToken.token;
const nonAllowlistedAdmin = await t.context.models.user.create({
email: 'admin2@affine.pro',
password: '1',
emailVerifiedAt: new Date(),
});
await t.context.models.userFeature.add(
nonAllowlistedAdmin.id,
'administrator',
'test'
);
const nonAllowlistedAdminToken = await t.context.models.accessToken.create({
userId: nonAllowlistedAdmin.id,
name: 'test',
});
t.context.nonAllowlistedAdminToken = nonAllowlistedAdminToken.token;
const user = await t.context.models.user.create({
email: 'user@affine.pro',
password: '1',
emailVerifiedAt: new Date(),
});
const userToken = await t.context.models.accessToken.create({
userId: user.id,
name: 'test',
});
t.context.userToken = userToken.token;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should return 404 for non-admin user', async t => {
await t.context.app
.GET('/api/queue')
.set('Authorization', `Bearer ${t.context.userToken}`)
.expect(404);
t.pass();
});
test('should allow allowlisted admin', async t => {
await t.context.app
.GET('/api/queue')
.set('Authorization', `Bearer ${t.context.allowlistedAdminToken}`)
.expect(200)
.expect('Content-Type', /text\/html/);
t.pass();
});
@@ -53,12 +53,21 @@ class QueueDashboardService implements OnModuleInit {
): Promise<void> => {
try {
const session = await this.authGuard.signIn(req, res);
const userId = session?.user?.id;
const user = session?.user;
const userId = user?.id;
const email = user?.email?.toLowerCase();
const isAdmin = userId ? await this.feature.isAdmin(userId) : false;
if (!isAdmin) {
res.status(404).end();
return;
}
if (req.method === 'GET' && (req.path === '/' || req.path === '')) {
this.logger.log(
`QueueDash accessed by ${userId} (${email ?? 'n/a'})`
);
}
} catch (error) {
this.logger.warn('QueueDash auth failed', error as Error);
res.status(404).end();
+100 -116
View File
@@ -9,6 +9,7 @@ import {
WebSocketServer,
} from '@nestjs/websockets';
import { ClsInterceptor } from 'nestjs-cls';
import semver from 'semver';
import { type Server, Socket } from 'socket.io';
import {
@@ -49,10 +50,10 @@ type EventResponse<Data = any> = Data extends never
data: Data;
};
// sync-019: legacy 0.19.x clients (broadcast-doc-updates/push-doc-updates).
// Remove after 2026-06-30 once metrics show 0 usage for 30 days.
// 020+: receives space:broadcast-doc-updates (batch) and sends space:push-doc-update.
type RoomType = 'sync' | `${string}:awareness` | 'sync-019';
// sync: shared room for space membership checks and non-protocol broadcasts.
// sync-025: legacy 0.25 doc sync protocol (space:broadcast-doc-update).
// sync-026: current doc sync protocol (space:broadcast-doc-updates).
type RoomType = 'sync' | 'sync-025' | 'sync-026' | `${string}:awareness`;
function Room(
spaceId: string,
@@ -61,6 +62,25 @@ function Room(
return `${spaceId}:${type}`;
}
const MIN_WS_CLIENT_VERSION = new semver.Range('>=0.25.0', {
includePrerelease: true,
});
const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
includePrerelease: true,
});
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
function isSupportedWsClientVersion(clientVersion: string): boolean {
return Boolean(
semver.valid(clientVersion) && MIN_WS_CLIENT_VERSION.test(clientVersion)
);
}
function getSyncProtocolRoomType(clientVersion: string): SyncProtocolRoomType {
return DOC_UPDATES_PROTOCOL_026.test(clientVersion) ? 'sync-026' : 'sync-025';
}
enum SpaceType {
Workspace = 'workspace',
Userspace = 'userspace',
@@ -90,16 +110,6 @@ interface LeaveSpaceAwarenessMessage {
docId: string;
}
/**
* @deprecated
*/
interface PushDocUpdatesMessage {
spaceType: SpaceType;
spaceId: string;
docId: string;
updates: string[];
}
interface PushDocUpdateMessage {
spaceType: SpaceType;
spaceId: string;
@@ -117,6 +127,15 @@ interface BroadcastDocUpdatesMessage {
compressed?: boolean;
}
interface BroadcastDocUpdateMessage {
spaceType: SpaceType;
spaceId: string;
docId: string;
update: string;
timestamp: number;
editor: string;
}
interface LoadDocMessage {
spaceType: SpaceType;
spaceId: string;
@@ -225,6 +244,11 @@ export class SpaceSyncGateway
}
}
private rejectJoin(client: Socket) {
// Give socket.io a chance to flush the ack packet before disconnecting.
setImmediate(() => client.disconnect());
}
handleConnection() {
this.connectionCount++;
this.logger.debug(`New connection, total: ${this.connectionCount}`);
@@ -252,23 +276,21 @@ export class SpaceSyncGateway
return;
}
const room025 = `${spaceType}:${Room(spaceId, 'sync-025')}`;
const encodedUpdates = this.encodeUpdates(updates);
this.server
.to(Room(spaceId, 'sync-019'))
.emit('space:broadcast-doc-updates', {
spaceType,
for (const update of encodedUpdates) {
const payload: BroadcastDocUpdateMessage = {
spaceType: spaceType as SpaceType,
spaceId,
docId,
updates: encodedUpdates,
update,
timestamp,
editor,
});
metrics.socketio
.counter('sync_019_broadcast')
.add(encodedUpdates.length, { event: 'doc_updates_pushed' });
editor: editor ?? '',
};
this.server.to(room025).emit('space:broadcast-doc-update', payload);
}
const room = `${spaceType}:${Room(spaceId)}`;
const room026 = `${spaceType}:${Room(spaceId, 'sync-026')}`;
const payload = this.buildBroadcastPayload(
spaceType as SpaceType,
spaceId,
@@ -277,7 +299,7 @@ export class SpaceSyncGateway
timestamp,
editor
);
this.server.to(room).emit('space:broadcast-doc-updates', payload);
this.server.to(room026).emit('space:broadcast-doc-updates', payload);
metrics.socketio
.counter('doc_updates_broadcast')
.add(payload.updates.length, {
@@ -314,16 +336,34 @@ export class SpaceSyncGateway
@MessageBody()
{ spaceType, spaceId, clientVersion }: JoinSpaceMessage
): Promise<EventResponse<{ clientId: string; success: boolean }>> {
if (
![SpaceType.Userspace, SpaceType.Workspace].includes(spaceType) ||
/^0.1/.test(clientVersion)
) {
if (![SpaceType.Userspace, SpaceType.Workspace].includes(spaceType)) {
this.rejectJoin(client);
return { data: { clientId: client.id, success: false } };
} else {
if (spaceType === SpaceType.Workspace) {
this.event.emit('workspace.embedding', { workspaceId: spaceId });
}
await this.selectAdapter(client, spaceType).join(user.id, spaceId);
}
if (!isSupportedWsClientVersion(clientVersion)) {
this.rejectJoin(client);
return { data: { clientId: client.id, success: false } };
}
if (spaceType === SpaceType.Workspace) {
this.event.emit('workspace.embedding', { workspaceId: spaceId });
}
const adapter = this.selectAdapter(client, spaceType);
await adapter.join(user.id, spaceId);
const protocolRoomType = getSyncProtocolRoomType(clientVersion);
const protocolRoom = adapter.room(spaceId, protocolRoomType);
const otherProtocolRoom = adapter.room(
spaceId,
protocolRoomType === 'sync-025' ? 'sync-026' : 'sync-025'
);
if (client.rooms.has(otherProtocolRoom)) {
await client.leave(otherProtocolRoom);
}
if (!client.rooms.has(protocolRoom)) {
await client.join(protocolRoom);
}
return { data: { clientId: client.id, success: true } };
@@ -380,68 +420,8 @@ export class SpaceSyncGateway
}
/**
* @deprecated use [space:push-doc-update] instead, client should always merge updates on their own
*
* only 0.19.x client will send this event
* client should always merge updates on their own
*/
@SubscribeMessage('space:push-doc-updates')
async onReceiveDocUpdates(
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody()
message: PushDocUpdatesMessage
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
const { spaceType, spaceId, docId, updates } = message;
const adapter = this.selectAdapter(client, spaceType);
const id = new DocID(docId, spaceId);
// TODO(@forehalo): enable after frontend supporting doc revert
// await this.ac.user(user.id).doc(spaceId, id.guid).assert('Doc.Update');
const timestamp = await adapter.push(
spaceId,
id.guid,
updates.map(update => Buffer.from(update, 'base64')),
user.id
);
metrics.socketio
.counter('sync_019_event')
.add(1, { event: 'push-doc-updates' });
// broadcast to 0.19.x clients
client.to(Room(spaceId, 'sync-019')).emit('space:broadcast-doc-updates', {
...message,
timestamp,
editor: user.id,
});
// broadcast to new clients
const decodedUpdates = updates.map(update => Buffer.from(update, 'base64'));
const payload = this.buildBroadcastPayload(
spaceType,
spaceId,
docId,
decodedUpdates,
timestamp,
user.id
);
client
.to(adapter.room(spaceId))
.emit('space:broadcast-doc-updates', payload);
metrics.socketio
.counter('doc_updates_broadcast')
.add(payload.updates.length, {
mode: payload.compressed ? 'compressed' : 'batch',
});
return {
data: {
accepted: true,
timestamp,
},
};
}
@SubscribeMessage('space:push-doc-update')
async onReceiveDocUpdate(
@ConnectedSocket() client: Socket,
@@ -461,16 +441,6 @@ export class SpaceSyncGateway
user.id
);
// broadcast to 0.19.x clients
client.to(Room(spaceId, 'sync-019')).emit('space:broadcast-doc-updates', {
spaceType,
spaceId,
docId,
updates: [update],
timestamp,
editor: user.id,
});
const payload = this.buildBroadcastPayload(
spaceType,
spaceId,
@@ -480,7 +450,7 @@ export class SpaceSyncGateway
user.id
);
client
.to(adapter.room(spaceId))
.to(adapter.room(spaceId, 'sync-026'))
.emit('space:broadcast-doc-updates', payload);
metrics.socketio
.counter('doc_updates_broadcast')
@@ -488,6 +458,17 @@ export class SpaceSyncGateway
mode: payload.compressed ? 'compressed' : 'batch',
});
client
.to(adapter.room(spaceId, 'sync-025'))
.emit('space:broadcast-doc-update', {
spaceType,
spaceId,
docId,
update,
timestamp,
editor: user.id,
} satisfies BroadcastDocUpdateMessage);
return {
data: {
accepted: true,
@@ -516,8 +497,18 @@ export class SpaceSyncGateway
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody()
{ spaceType, spaceId, docId }: JoinSpaceAwarenessMessage
{ spaceType, spaceId, docId, clientVersion }: JoinSpaceAwarenessMessage
) {
if (![SpaceType.Userspace, SpaceType.Workspace].includes(spaceType)) {
this.rejectJoin(client);
return { data: { clientId: client.id, success: false } };
}
if (!isSupportedWsClientVersion(clientVersion)) {
this.rejectJoin(client);
return { data: { clientId: client.id, success: false } };
}
await this.selectAdapter(client, spaceType).join(
user.id,
spaceId,
@@ -555,13 +546,6 @@ export class SpaceSyncGateway
.to(adapter.room(spaceId, roomType))
.emit('space:collect-awareness', { spaceType, spaceId, docId });
// TODO(@forehalo): remove backward compatibility
if (spaceType === SpaceType.Workspace) {
client
.to(adapter.room(spaceId, roomType))
.emit('new-client-awareness-init');
}
return { data: { clientId: client.id } };
}
@@ -66,21 +66,27 @@ export class UserResolver {
): Promise<typeof UserOrLimitedUser | null> {
validators.assertValidEmail(email);
// TODO(@forehalo): need to limit a user can only get another user witch is in the same workspace
// NOTE: prevent user enumeration. Only allow querying users within the same workspace scope.
if (!currentUser) {
return null;
}
const user = await this.models.user.getUserByEmail(email);
// return empty response when user not exists
if (!user) return null;
if (currentUser) {
if (user.id === currentUser.id) {
return sessionUser(user);
}
// only return limited info when not logged in
return {
email: user.email,
hasPassword: !!user.password,
};
const allowed = await this.models.workspaceUser.hasSharedWorkspace(
currentUser.id,
user.id
);
if (!allowed) return null;
return sessionUser(user);
}
@Throttle('strict')
@@ -26,6 +26,6 @@ defineModuleConfig('client', {
},
'versionControl.requiredVersion': {
desc: "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.",
default: '>=0.20.0',
default: '>=0.25.0',
},
});
@@ -7,7 +7,6 @@ import {
Mutation,
ObjectType,
Parent,
Query,
registerEnumType,
ResolveField,
Resolver,
@@ -33,7 +32,7 @@ import {
MULTIPART_PART_SIZE,
MULTIPART_THRESHOLD,
} from '../../storage/constants';
import { WorkspaceBlobSizes, WorkspaceType } from '../types';
import { WorkspaceType } from '../types';
enum BlobUploadMethod {
GRAPHQL = 'GRAPHQL',
@@ -169,14 +168,6 @@ export class WorkspaceBlobResolver {
return this.getUploadPart(user, workspace.id, key, uploadId, partNumber);
}
@Query(() => WorkspaceBlobSizes, {
deprecationReason: 'use `user.quotaUsage` instead',
})
async collectAllBlobSizes(@CurrentUser() user: CurrentUser) {
const size = await this.quota.getUserStorageUsage(user.id);
return { size };
}
@Mutation(() => String)
async setBlob(
@CurrentUser() user: CurrentUser,
@@ -412,19 +403,6 @@ export class WorkspaceBlobResolver {
return key;
}
@Mutation(() => BlobUploadPart, {
deprecationReason: 'use WorkspaceType.blobUploadPartUrl',
})
async getBlobUploadPartUrl(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('key') key: string,
@Args('uploadId') uploadId: string,
@Args('partNumber', { type: () => Int }) partNumber: number
): Promise<BlobUploadPart> {
return this.getUploadPart(user, workspaceId, key, uploadId, partNumber);
}
@Mutation(() => Boolean)
async abortBlobUpload(
@CurrentUser() user: CurrentUser,
@@ -238,20 +238,6 @@ export class WorkspaceDocResolver {
return this.models.doc.findPublics(workspace.id);
}
@ResolveField(() => DocType, {
description: 'Get public page of a workspace by page id.',
complexity: 2,
nullable: true,
deprecationReason: 'use [WorkspaceType.doc] instead',
})
async publicPage(
@CurrentUser() me: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
return this.doc(me, workspace, pageId);
}
@ResolveField(() => PaginatedDocType)
async docs(
@Parent() workspace: WorkspaceType,
@@ -314,24 +300,6 @@ export class WorkspaceDocResolver {
};
}
@Mutation(() => DocType, {
deprecationReason: 'use publishDoc instead',
})
async publishPage(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string,
@Args({
name: 'mode',
type: () => PublicDocMode,
nullable: true,
defaultValue: PublicDocMode.Page,
})
mode: PublicDocMode
) {
return this.publishDoc(user, workspaceId, pageId, mode);
}
@Mutation(() => DocType)
async publishDoc(
@CurrentUser() user: CurrentUser,
@@ -364,17 +332,6 @@ export class WorkspaceDocResolver {
return doc;
}
@Mutation(() => DocType, {
deprecationReason: 'use revokePublicDoc instead',
})
async revokePublicPage(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('docId') docId: string
) {
return this.revokePublicDoc(user, workspaceId, docId);
}
@Mutation(() => DocType)
async revokePublicDoc(
@CurrentUser() user: CurrentUser,
@@ -234,25 +234,6 @@ export class WorkspaceMemberResolver {
return results;
}
/**
* @deprecated
*/
@Mutation(() => [InviteResult], {
deprecationReason: 'use [inviteMembers] instead',
})
async inviteBatch(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[],
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean = false
) {
return this.inviteMembers(user, workspaceId, emails);
}
@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
@@ -456,20 +437,6 @@ export class WorkspaceMemberResolver {
return { workspace, user: owner, invitee, status };
}
/**
* @deprecated
*/
@Mutation(() => Boolean, {
deprecationReason: 'use [revokeMember] instead',
})
async revoke(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
return this.revokeMember(me, workspaceId, userId);
}
@Mutation(() => Boolean)
async revokeMember(
@CurrentUser() me: CurrentUser,
@@ -156,40 +156,6 @@ export class WorkspaceResolver {
};
}
@Query(() => Boolean, {
description: 'Get is owner of workspace',
complexity: 2,
deprecationReason: 'use WorkspaceType[role] instead',
})
async isOwner(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
return role?.type === WorkspaceRole.Owner;
}
@Query(() => Boolean, {
description: 'Get is admin of workspace',
complexity: 2,
deprecationReason: 'use WorkspaceType[role] instead',
})
async isAdmin(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
return role?.type === WorkspaceRole.Admin;
}
@Query(() => [WorkspaceType], {
description: 'Get all accessible workspaces for current user',
complexity: 2,