Skip to content

Commit 703d65a

Browse files
boris-wcaoxing9
andauthored
feat: template preview (T1306,T1316) (#2291)
* feat: template preview * feat: add preview in template detail * fix: remove debug code * fix: unit test * fix: permission.service unit test * fix: share link view in template preview pages * feat: more complete template preview ui * fix: missing app actions in template * fix: locales file conflict * feat: template support app T1316 * feat: support jump to active node when create template * chore: update i18n * chore: update i18n * perf: optimise user publish to community validation process * fix: base export e2e fail unexpect * fix: losing duplicate audit-log * fix: publish dialog select active node error * feat: unlock template recommended select * feat: app in template preview * fix: featured null and false filter fail * fix: template detail scroll * chore: constant template spaceId * perf: create template should close schedule trigger workflow and authority * fix: publish base ui error * feat: template preview e2e * perf: delete template old snapshot app when create new * fix: import table date with computed data error * fix: import base e2e * fix: duplicate base do not turn on workflow and authority --------- Co-authored-by: caoxing <caoxing9@gmail.com>
1 parent 67710df commit 703d65a

168 files changed

Lines changed: 5327 additions & 762 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ import { generateAggCacheKey } from '../../../performance-cache/generate-keys';
3131
import type { IClsStore } from '../../../types/cls';
3232
import { filterHasMe } from '../../../utils/filter-has-me';
3333
import { ZodValidationPipe } from '../../../zod.validation.pipe';
34+
import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';
3435
import { Permissions } from '../../auth/decorators/permissions.decorator';
3536
import { TqlPipe } from '../../record/open-api/tql.pipe';
3637
import { AggregationOpenApiService } from './aggregation-open-api.service';
3738

3839
@Controller('api/table/:tableId/aggregation')
40+
@AllowAnonymous()
3941
export class AggregationOpenApiController {
4042
constructor(
4143
private readonly aggregationOpenApiService: AggregationOpenApiService,

apps/nestjs-backend/src/features/auth/auth.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { IClsStore } from '../../types/cls';
1616
import { ZodValidationPipe } from '../../zod.validation.pipe';
1717
import { DeleteUserService } from '../user/delete-user/delete-user.service';
1818
import { AuthService } from './auth.service';
19+
import { AllowAnonymous, AllowAnonymousType } from './decorators/allow-anonymous.decorator';
1920
import { TokenAccess } from './decorators/token.decorator';
2021
import { SessionService } from './session/session.service';
2122

@@ -36,6 +37,7 @@ export class AuthController {
3637
res.clearCookie(AUTH_SESSION_COOKIE_NAME);
3738
}
3839

40+
@AllowAnonymous(AllowAnonymousType.USER)
3941
@Get('/user/me')
4042
async me(@Req() request: Express.Request) {
4143
return {

apps/nestjs-backend/src/features/auth/auth.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import { SessionModule } from './session/session.module';
1616
import { SessionSerializer } from './session/session.serializer';
1717
import { SocialModule } from './social/social.module';
1818
import { AccessTokenStrategy } from './strategies/access-token.strategy';
19+
import { AnonymousStrategy } from './strategies/anonymous/anonymous.strategy';
1920
import { JwtStrategy } from './strategies/jwt.strategy';
2021
import { SessionStrategy } from './strategies/session.strategy';
2122
import { TurnstileModule } from './turnstile/turnstile.module';
22-
2323
@Module({
2424
imports: [
2525
UserModule,
@@ -51,6 +51,7 @@ import { TurnstileModule } from './turnstile/turnstile.module';
5151
SessionStoreService,
5252
AccessTokenStrategy,
5353
JwtStrategy,
54+
AnonymousStrategy,
5455
],
5556
exports: [AuthService, AuthGuard],
5657
controllers: [AuthController],
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export enum AllowAnonymousType {
4+
RESOURCE = 'resource',
5+
USER = 'user',
6+
PUBLIC = 'public',
7+
}
8+
9+
export const IS_ALLOW_ANONYMOUS = 'isAllowAnonymous';
10+
// eslint-disable-next-line @typescript-eslint/naming-convention
11+
export const AllowAnonymous = (type: AllowAnonymousType = AllowAnonymousType.RESOURCE) =>
12+
SetMetadata(IS_ALLOW_ANONYMOUS, type);

apps/nestjs-backend/src/features/auth/guard/auth.guard.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,43 @@
11
import type { ExecutionContext } from '@nestjs/common';
2-
import { Injectable, Logger } from '@nestjs/common';
2+
import { Injectable, UnauthorizedException } from '@nestjs/common';
33
import { Reflector } from '@nestjs/core';
44
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
5+
import { isAnonymous } from '@teable/core';
6+
import { ClsService } from 'nestjs-cls';
7+
import type { IClsStore } from '../../../types/cls';
8+
import { IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';
59
import { ENSURE_LOGIN } from '../decorators/ensure-login.decorator';
610
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
7-
import { ACCESS_TOKEN_STRATEGY_NAME, JWT_TOKEN_STRATEGY_NAME } from '../strategies/constant';
11+
import {
12+
ACCESS_TOKEN_STRATEGY_NAME,
13+
ANONYMOUS_STRATEGY_NAME,
14+
JWT_TOKEN_STRATEGY_NAME,
15+
} from '../strategies/constant';
16+
817
@Injectable()
918
export class AuthGuard extends PassportAuthGuard([
1019
'session',
1120
ACCESS_TOKEN_STRATEGY_NAME,
1221
JWT_TOKEN_STRATEGY_NAME,
22+
ANONYMOUS_STRATEGY_NAME,
1323
]) {
14-
private readonly logger = new Logger(AuthGuard.name);
15-
16-
constructor(private readonly reflector: Reflector) {
24+
constructor(
25+
private readonly reflector: Reflector,
26+
private readonly cls: ClsService<IClsStore>
27+
) {
1728
super();
1829
}
1930

2031
async validate(context: ExecutionContext) {
21-
return super.canActivate(context) as Promise<boolean>;
32+
const result = (await super.canActivate(context)) as boolean;
33+
const isAllowAnonymous = this.reflector.getAllAndOverride<boolean>(IS_ALLOW_ANONYMOUS, [
34+
context.getHandler(),
35+
context.getClass(),
36+
]);
37+
if (!isAllowAnonymous && isAnonymous(this.cls.get('user.id'))) {
38+
throw new UnauthorizedException();
39+
}
40+
return result;
2241
}
2342

2443
async canActivate(context: ExecutionContext) {

apps/nestjs-backend/src/features/auth/guard/permission.guard.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import type { ExecutionContext } from '@nestjs/common';
2-
import { Injectable } from '@nestjs/common';
2+
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
33
import { Reflector } from '@nestjs/core';
4-
import { HttpErrorCode, type Action } from '@teable/core';
4+
import { HttpErrorCode, isAnonymous, type Action } from '@teable/core';
55
import { ClsService } from 'nestjs-cls';
66
import { CustomHttpException } from '../../../custom.exception';
77
import type { IClsStore } from '../../../types/cls';
8+
import { AllowAnonymousType, IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';
89
import { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator';
910
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
1011
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
1112
import type { IResourceMeta } from '../decorators/resource_meta.decorator';
1213
import { RESOURCE_META } from '../decorators/resource_meta.decorator';
1314
import { IS_TOKEN_ACCESS } from '../decorators/token.decorator';
1415
import { PermissionService } from '../permission.service';
16+
import { getTemplateHeader } from '../utils';
1517

1618
@Injectable()
1719
export class PermissionGuard {
20+
private readonly logger = new Logger(PermissionGuard.name);
21+
1822
constructor(
1923
private readonly reflector: Reflector,
2024
private readonly cls: ClsService<IClsStore>,
@@ -71,7 +75,49 @@ export class PermissionGuard {
7175
return true;
7276
}
7377

74-
protected async resourcePermission(resourceId: string | undefined, permissions: Action[]) {
78+
protected async templatePermissionCheck(context: ExecutionContext, templateHeader?: string) {
79+
if (templateHeader) {
80+
const templateId = this.permissionService.getTemplateIdByHeader(templateHeader);
81+
if (!templateId) {
82+
throw new CustomHttpException(
83+
`Template header is invalid`,
84+
HttpErrorCode.RESTRICTED_RESOURCE,
85+
{
86+
localization: {
87+
i18nKey: 'httpErrors.permission.templateHeaderInvalid',
88+
},
89+
}
90+
);
91+
}
92+
}
93+
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
94+
if (!resourceId) {
95+
throw new CustomHttpException(
96+
`Template permission check ID does not exist`,
97+
HttpErrorCode.RESTRICTED_RESOURCE,
98+
{
99+
localization: {
100+
i18nKey: 'httpErrors.permission.checkIdNotExist',
101+
},
102+
}
103+
);
104+
}
105+
const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [
106+
context.getHandler(),
107+
context.getClass(),
108+
]);
109+
if (!permissions?.length) {
110+
throw new ForbiddenException('Template permissions are required');
111+
}
112+
const ownPermissions = await this.permissionService.validTemplatePermissions(
113+
resourceId,
114+
permissions
115+
);
116+
this.cls.set('permissions', ownPermissions);
117+
return true;
118+
}
119+
120+
private async resourcePermission(resourceId: string | undefined, permissions: Action[]) {
75121
if (!resourceId) {
76122
throw new CustomHttpException(
77123
`Permission check ID does not exist`,
@@ -128,7 +174,7 @@ export class PermissionGuard {
128174
context.getHandler(),
129175
context.getClass(),
130176
]);
131-
177+
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
132178
const accessTokenId = this.cls.get('accessTokenId');
133179
if (accessTokenId && !permissions?.length) {
134180
// Pre-checking of tokens
@@ -155,7 +201,6 @@ export class PermissionGuard {
155201
if (permissions?.includes('base|read_all')) {
156202
return await this.permissionBaseReadAll();
157203
}
158-
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
159204
if (!resourceId && permissions?.includes('space|read')) {
160205
return await this.permissionSpaceRead();
161206
}
@@ -164,6 +209,65 @@ export class PermissionGuard {
164209
return await this.resourcePermission(resourceId, permissions);
165210
}
166211

212+
private isAnonymous() {
213+
return isAnonymous(this.cls.get('user.id'));
214+
}
215+
216+
protected async permissionCheckWithPublicFallback(
217+
context: ExecutionContext,
218+
permissionCheck: () => Promise<boolean>
219+
) {
220+
const templateHeader = getTemplateHeader(context.switchToHttp().getRequest());
221+
const allowAnonymousType = this.reflector.getAllAndOverride<AllowAnonymousType | undefined>(
222+
IS_ALLOW_ANONYMOUS,
223+
[context.getHandler(), context.getClass()]
224+
);
225+
// anonymous resource permission check
226+
if (templateHeader && allowAnonymousType === AllowAnonymousType.RESOURCE) {
227+
return await this.templatePermissionCheck(context, templateHeader);
228+
}
229+
const isAnonymous = this.isAnonymous();
230+
// anonymous user permission check
231+
if (isAnonymous) {
232+
if (!allowAnonymousType) {
233+
throw new UnauthorizedException();
234+
}
235+
switch (allowAnonymousType) {
236+
case AllowAnonymousType.PUBLIC:
237+
return await this.templatePermissionCheck(context);
238+
case AllowAnonymousType.RESOURCE:
239+
throw new UnauthorizedException(
240+
'Anonymous resource permission check failed, template header is required'
241+
);
242+
case AllowAnonymousType.USER:
243+
return true;
244+
default:
245+
throw new UnauthorizedException('Invalid allow anonymous type');
246+
}
247+
}
248+
249+
// normal permission check
250+
try {
251+
return await permissionCheck();
252+
} catch (normalError) {
253+
// if not public type, not fallback to template permission check, throw normal error
254+
if (allowAnonymousType !== AllowAnonymousType.PUBLIC) {
255+
throw normalError;
256+
}
257+
this.logger.log('Fallback to template permission check');
258+
try {
259+
return await this.templatePermissionCheck(context);
260+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
261+
} catch (templateError: any) {
262+
this.logger.error(
263+
`Template permission check failed: ${templateError.message}`,
264+
templateError.stack
265+
);
266+
throw normalError;
267+
}
268+
}
269+
}
270+
167271
/**
168272
* permission step:
169273
* 1. public decorator sign
@@ -202,6 +306,8 @@ export class PermissionGuard {
202306
return true;
203307
}
204308

205-
return this.permissionCheck(context);
309+
return await this.permissionCheckWithPublicFallback(context, async () => {
310+
return await this.permissionCheck(context);
311+
});
206312
}
207313
}

apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2-
import {
3-
BadRequestException,
4-
ConflictException,
5-
HttpException,
6-
HttpStatus,
7-
Injectable,
8-
Logger,
9-
} from '@nestjs/common';
2+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
103
import { JwtService } from '@nestjs/jwt';
114
import { generateUserId, getRandomString, HttpErrorCode, RandomType } from '@teable/core';
125
import { PrismaService } from '@teable/db-main-prisma';

apps/nestjs-backend/src/features/auth/permission.module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
import { Global, Module } from '@nestjs/common';
2+
import { JwtModule } from '@nestjs/jwt';
3+
import { authConfig, type IAuthConfig } from '../../configs/auth.config';
24
import { PermissionGuard } from './guard/permission.guard';
35
import { PermissionService } from './permission.service';
46

57
@Global()
68
@Module({
9+
imports: [
10+
JwtModule.registerAsync({
11+
useFactory: (config: IAuthConfig) => ({
12+
secret: config.jwt.secret,
13+
signOptions: {
14+
expiresIn: config.jwt.expiresIn,
15+
},
16+
}),
17+
inject: [authConfig.KEY],
18+
}),
19+
],
720
providers: [PermissionService, PermissionGuard],
821
exports: [PermissionService, PermissionGuard],
922
})

apps/nestjs-backend/src/features/auth/permission.service.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
22
/* eslint-disable @typescript-eslint/no-explicit-any */
3-
import { ForbiddenException, NotFoundException } from '@nestjs/common';
3+
import { ForbiddenException } from '@nestjs/common';
44
import type { TestingModule } from '@nestjs/testing';
55
import { Test } from '@nestjs/testing';
66
import type { Action } from '@teable/core';
@@ -13,6 +13,7 @@ import { mockDeep, mockReset } from 'vitest-mock-extended';
1313
import { getError } from '../../../test/utils/get-error';
1414
import { GlobalModule } from '../../global/global.module';
1515
import type { IClsStore } from '../../types/cls';
16+
import { PermissionModule } from './permission.module';
1617
import { PermissionService } from './permission.service';
1718

1819
describe('PermissionService', () => {
@@ -25,8 +26,7 @@ describe('PermissionService', () => {
2526
clsServiceMock = mockDeep<ClsService<IClsStore>>();
2627

2728
const module: TestingModule = await Test.createTestingModule({
28-
imports: [GlobalModule],
29-
providers: [PermissionService, PrismaService, ClsService],
29+
imports: [GlobalModule, PermissionModule],
3030
})
3131
.overrideProvider(PrismaService)
3232
.useValue(prismaServiceMock)

0 commit comments

Comments
 (0)