mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +08:00
chore: drop old client support (#14369)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user