Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4fd1627
feat: template preview
boris-w Dec 17, 2025
bd7e8ac
feat: add preview in template detail
boris-w Dec 17, 2025
dd5fc58
fix: remove debug code
boris-w Dec 17, 2025
fa9b0c7
fix: unit test
boris-w Dec 17, 2025
7fa8d5c
fix: permission.service unit test
boris-w Dec 17, 2025
9376161
fix: share link view in template preview pages
boris-w Dec 17, 2025
971aac3
feat: more complete template preview ui
boris-w Dec 18, 2025
c3e9c7d
fix: missing app actions in template
boris-w Dec 18, 2025
8447d22
fix: locales file conflict
boris-w Dec 18, 2025
798f8f2
feat: template support app T1316
caoxing9 Dec 17, 2025
5dcb6d3
feat: support jump to active node when create template
caoxing9 Dec 17, 2025
d52fd99
chore: update i18n
caoxing9 Dec 18, 2025
65783d8
chore: update i18n
caoxing9 Dec 18, 2025
a676b2e
perf: optimise user publish to community validation process
caoxing9 Dec 18, 2025
1e6f855
fix: base export e2e fail unexpect
caoxing9 Dec 18, 2025
ee66480
fix: losing duplicate audit-log
caoxing9 Dec 18, 2025
86d57f5
fix: publish dialog select active node error
caoxing9 Dec 18, 2025
6d42ec2
feat: unlock template recommended select
caoxing9 Dec 18, 2025
8250a61
feat: app in template preview
boris-w Dec 18, 2025
7fb09b5
fix: featured null and false filter fail
caoxing9 Dec 18, 2025
9ee460b
fix: template detail scroll
boris-w Dec 18, 2025
dd79321
chore: constant template spaceId
boris-w Dec 18, 2025
1012fc4
perf: create template should close schedule trigger workflow and auth…
caoxing9 Dec 18, 2025
277d442
fix: publish base ui error
caoxing9 Dec 18, 2025
2e228b3
feat: template preview e2e
boris-w Dec 18, 2025
7a3d179
perf: delete template old snapshot app when create new
caoxing9 Dec 18, 2025
82de000
fix: import table date with computed data error
caoxing9 Dec 18, 2025
22f6295
fix: import base e2e
caoxing9 Dec 18, 2025
902f610
fix: duplicate base do not turn on workflow and authority
caoxing9 Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ import { generateAggCacheKey } from '../../../performance-cache/generate-keys';
import type { IClsStore } from '../../../types/cls';
import { filterHasMe } from '../../../utils/filter-has-me';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { TqlPipe } from '../../record/open-api/tql.pipe';
import { AggregationOpenApiService } from './aggregation-open-api.service';

@Controller('api/table/:tableId/aggregation')
@AllowAnonymous()
export class AggregationOpenApiController {
constructor(
private readonly aggregationOpenApiService: AggregationOpenApiService,
Expand Down
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/features/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { IClsStore } from '../../types/cls';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { DeleteUserService } from '../user/delete-user/delete-user.service';
import { AuthService } from './auth.service';
import { AllowAnonymous, AllowAnonymousType } from './decorators/allow-anonymous.decorator';
import { TokenAccess } from './decorators/token.decorator';
import { SessionService } from './session/session.service';

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

@AllowAnonymous(AllowAnonymousType.USER)
@Get('/user/me')
async me(@Req() request: Express.Request) {
return {
Expand Down
3 changes: 2 additions & 1 deletion apps/nestjs-backend/src/features/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import { SessionModule } from './session/session.module';
import { SessionSerializer } from './session/session.serializer';
import { SocialModule } from './social/social.module';
import { AccessTokenStrategy } from './strategies/access-token.strategy';
import { AnonymousStrategy } from './strategies/anonymous/anonymous.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { SessionStrategy } from './strategies/session.strategy';
import { TurnstileModule } from './turnstile/turnstile.module';

@Module({
imports: [
UserModule,
Expand Down Expand Up @@ -51,6 +51,7 @@ import { TurnstileModule } from './turnstile/turnstile.module';
SessionStoreService,
AccessTokenStrategy,
JwtStrategy,
AnonymousStrategy,
],
exports: [AuthService, AuthGuard],
controllers: [AuthController],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { SetMetadata } from '@nestjs/common';

export enum AllowAnonymousType {
RESOURCE = 'resource',
USER = 'user',
PUBLIC = 'public',
}

export const IS_ALLOW_ANONYMOUS = 'isAllowAnonymous';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const AllowAnonymous = (type: AllowAnonymousType = AllowAnonymousType.RESOURCE) =>
SetMetadata(IS_ALLOW_ANONYMOUS, type);
31 changes: 25 additions & 6 deletions apps/nestjs-backend/src/features/auth/guard/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
import type { ExecutionContext } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
import { isAnonymous } from '@teable/core';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../../types/cls';
import { IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';
import { ENSURE_LOGIN } from '../decorators/ensure-login.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ACCESS_TOKEN_STRATEGY_NAME, JWT_TOKEN_STRATEGY_NAME } from '../strategies/constant';
import {
ACCESS_TOKEN_STRATEGY_NAME,
ANONYMOUS_STRATEGY_NAME,
JWT_TOKEN_STRATEGY_NAME,
} from '../strategies/constant';

@Injectable()
export class AuthGuard extends PassportAuthGuard([
'session',
ACCESS_TOKEN_STRATEGY_NAME,
JWT_TOKEN_STRATEGY_NAME,
ANONYMOUS_STRATEGY_NAME,
]) {
private readonly logger = new Logger(AuthGuard.name);

constructor(private readonly reflector: Reflector) {
constructor(
private readonly reflector: Reflector,
private readonly cls: ClsService<IClsStore>
) {
super();
}

async validate(context: ExecutionContext) {
return super.canActivate(context) as Promise<boolean>;
const result = (await super.canActivate(context)) as boolean;
const isAllowAnonymous = this.reflector.getAllAndOverride<boolean>(IS_ALLOW_ANONYMOUS, [
context.getHandler(),
context.getClass(),
]);
if (!isAllowAnonymous && isAnonymous(this.cls.get('user.id'))) {
throw new UnauthorizedException();
}
return result;
}

async canActivate(context: ExecutionContext) {
Expand Down
118 changes: 112 additions & 6 deletions apps/nestjs-backend/src/features/auth/guard/permission.guard.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type { ExecutionContext } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { HttpErrorCode, type Action } from '@teable/core';
import { HttpErrorCode, isAnonymous, type Action } from '@teable/core';
import { ClsService } from 'nestjs-cls';
import { CustomHttpException } from '../../../custom.exception';
import type { IClsStore } from '../../../types/cls';
import { AllowAnonymousType, IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';
import { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import type { IResourceMeta } from '../decorators/resource_meta.decorator';
import { RESOURCE_META } from '../decorators/resource_meta.decorator';
import { IS_TOKEN_ACCESS } from '../decorators/token.decorator';
import { PermissionService } from '../permission.service';
import { getTemplateHeader } from '../utils';

@Injectable()
export class PermissionGuard {
private readonly logger = new Logger(PermissionGuard.name);

constructor(
private readonly reflector: Reflector,
private readonly cls: ClsService<IClsStore>,
Expand Down Expand Up @@ -71,7 +75,49 @@ export class PermissionGuard {
return true;
}

protected async resourcePermission(resourceId: string | undefined, permissions: Action[]) {
protected async templatePermissionCheck(context: ExecutionContext, templateHeader?: string) {
if (templateHeader) {
const templateId = this.permissionService.getTemplateIdByHeader(templateHeader);
if (!templateId) {
throw new CustomHttpException(
`Template header is invalid`,
HttpErrorCode.RESTRICTED_RESOURCE,
{
localization: {
i18nKey: 'httpErrors.permission.templateHeaderInvalid',
},
}
);
}
}
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
if (!resourceId) {
throw new CustomHttpException(
`Template permission check ID does not exist`,
HttpErrorCode.RESTRICTED_RESOURCE,
{
localization: {
i18nKey: 'httpErrors.permission.checkIdNotExist',
},
}
);
}
const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!permissions?.length) {
throw new ForbiddenException('Template permissions are required');
}
const ownPermissions = await this.permissionService.validTemplatePermissions(
resourceId,
permissions
);
this.cls.set('permissions', ownPermissions);
return true;
}

private async resourcePermission(resourceId: string | undefined, permissions: Action[]) {
if (!resourceId) {
throw new CustomHttpException(
`Permission check ID does not exist`,
Expand Down Expand Up @@ -128,7 +174,7 @@ export class PermissionGuard {
context.getHandler(),
context.getClass(),
]);

const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
const accessTokenId = this.cls.get('accessTokenId');
if (accessTokenId && !permissions?.length) {
// Pre-checking of tokens
Expand All @@ -155,7 +201,6 @@ export class PermissionGuard {
if (permissions?.includes('base|read_all')) {
return await this.permissionBaseReadAll();
}
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
if (!resourceId && permissions?.includes('space|read')) {
return await this.permissionSpaceRead();
}
Expand All @@ -164,6 +209,65 @@ export class PermissionGuard {
return await this.resourcePermission(resourceId, permissions);
}

private isAnonymous() {
return isAnonymous(this.cls.get('user.id'));
}

protected async permissionCheckWithPublicFallback(
context: ExecutionContext,
permissionCheck: () => Promise<boolean>
) {
const templateHeader = getTemplateHeader(context.switchToHttp().getRequest());
const allowAnonymousType = this.reflector.getAllAndOverride<AllowAnonymousType | undefined>(
IS_ALLOW_ANONYMOUS,
[context.getHandler(), context.getClass()]
);
// anonymous resource permission check
if (templateHeader && allowAnonymousType === AllowAnonymousType.RESOURCE) {
return await this.templatePermissionCheck(context, templateHeader);
}
const isAnonymous = this.isAnonymous();
// anonymous user permission check
if (isAnonymous) {
if (!allowAnonymousType) {
throw new UnauthorizedException();
}
switch (allowAnonymousType) {
case AllowAnonymousType.PUBLIC:
return await this.templatePermissionCheck(context);
case AllowAnonymousType.RESOURCE:
throw new UnauthorizedException(
'Anonymous resource permission check failed, template header is required'
);
case AllowAnonymousType.USER:
return true;
default:
throw new UnauthorizedException('Invalid allow anonymous type');
}
}

// normal permission check
try {
return await permissionCheck();
} catch (normalError) {
// if not public type, not fallback to template permission check, throw normal error
if (allowAnonymousType !== AllowAnonymousType.PUBLIC) {
throw normalError;
}
this.logger.log('Fallback to template permission check');
try {
return await this.templatePermissionCheck(context);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (templateError: any) {
this.logger.error(
`Template permission check failed: ${templateError.message}`,
templateError.stack
);
throw normalError;
}
}
}

/**
* permission step:
* 1. public decorator sign
Expand Down Expand Up @@ -202,6 +306,8 @@ export class PermissionGuard {
return true;
}

return this.permissionCheck(context);
return await this.permissionCheckWithPublicFallback(context, async () => {
return await this.permissionCheck(context);
});
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { generateUserId, getRandomString, HttpErrorCode, RandomType } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
Expand Down
13 changes: 13 additions & 0 deletions apps/nestjs-backend/src/features/auth/permission.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { authConfig, type IAuthConfig } from '../../configs/auth.config';
import { PermissionGuard } from './guard/permission.guard';
import { PermissionService } from './permission.service';

@Global()
@Module({
imports: [
JwtModule.registerAsync({
useFactory: (config: IAuthConfig) => ({
secret: config.jwt.secret,
signOptions: {
expiresIn: config.jwt.expiresIn,
},
}),
inject: [authConfig.KEY],
}),
],
providers: [PermissionService, PermissionGuard],
exports: [PermissionService, PermissionGuard],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ForbiddenException } from '@nestjs/common';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import type { Action } from '@teable/core';
Expand All @@ -13,6 +13,7 @@ import { mockDeep, mockReset } from 'vitest-mock-extended';
import { getError } from '../../../test/utils/get-error';
import { GlobalModule } from '../../global/global.module';
import type { IClsStore } from '../../types/cls';
import { PermissionModule } from './permission.module';
import { PermissionService } from './permission.service';

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

const module: TestingModule = await Test.createTestingModule({
imports: [GlobalModule],
providers: [PermissionService, PrismaService, ClsService],
imports: [GlobalModule, PermissionModule],
})
.overrideProvider(PrismaService)
.useValue(prismaServiceMock)
Expand Down
Loading