Skip to content

Commit 5adcc8b

Browse files
authored
feat: add robot and anonymous display (#1110)
* feat: add robot and anonymous display * fix: core test * Fix code scanning alert no. 27: Client-side cross-site scripting * fix: ignore 1pass in field name * fix: copy overlap in cell * feat: prevent auto new options on select field * fix: redirect risk * fix: test --------- Signed-off-by: Bieber <artist@teable.io>
1 parent 1e6e4e1 commit 5adcc8b

45 files changed

Lines changed: 476 additions & 125 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/auth/auth.controller.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ export class AuthController {
2929
@UseGuards(LocalAuthGuard)
3030
@HttpCode(200)
3131
@Post('signin')
32-
async signin(@Req() req: Express.Request) {
33-
return req.user;
32+
async signin(@Req() req: Express.Request): Promise<IUserMeVo> {
33+
return req.user as IUserMeVo;
3434
}
3535

3636
@Post('signout')
@@ -46,9 +46,9 @@ export class AuthController {
4646
@Body(new ZodValidationPipe(signupSchema)) body: ISignup,
4747
@Res({ passthrough: true }) res: Response,
4848
@Req() req: Express.Request
49-
) {
49+
): Promise<IUserMeVo> {
5050
const user = pickUserMe(
51-
await this.authService.signup(body.email, body.password, body.defaultSpaceName)
51+
await this.authService.signup(body.email, body.password, body.defaultSpaceName, body.refMeta)
5252
);
5353
// set cookie, passport login
5454
await new Promise<void>((resolve, reject) => {

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
22
import { generateUserId, getRandomString } from '@teable/core';
33
import { PrismaService } from '@teable/db-main-prisma';
4-
import type { IChangePasswordRo, IUserInfoVo, IUserMeVo } from '@teable/openapi';
4+
import type { IChangePasswordRo, IRefMeta, IUserInfoVo, IUserMeVo } from '@teable/openapi';
55
import * as bcrypt from 'bcrypt';
6-
import { omit, pick } from 'lodash';
6+
import { isEmpty, omit, pick } from 'lodash';
77
import { ClsService } from 'nestjs-cls';
88
import { CacheService } from '../../cache/cache.service';
99
import { AuthConfig, type IAuthConfig } from '../../configs/auth.config';
@@ -70,7 +70,7 @@ export class AuthService {
7070
return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null;
7171
}
7272

73-
async signup(email: string, password: string, defaultSpaceName?: string) {
73+
async signup(email: string, password: string, defaultSpaceName?: string, refMeta?: IRefMeta) {
7474
const user = await this.userService.getUserByEmail(email);
7575
if (user && (user.password !== null || user.accounts.length > 0)) {
7676
throw new HttpException(`User ${email} is already registered`, HttpStatus.BAD_REQUEST);
@@ -84,6 +84,7 @@ export class AuthService {
8484
salt,
8585
password: hashPassword,
8686
lastSignTime: new Date().toISOString(),
87+
refMeta: refMeta ? JSON.stringify(refMeta) : undefined,
8788
},
8889
});
8990
}
@@ -95,6 +96,7 @@ export class AuthService {
9596
salt,
9697
password: hashPassword,
9798
lastSignTime: new Date().toISOString(),
99+
refMeta: isEmpty(refMeta) ? undefined : JSON.stringify(refMeta),
98100
},
99101
undefined,
100102
defaultSpaceName

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ export class AuthGuard extends PassportAuthGuard(['session', ACCESS_TOKEN_STRATE
2424
context.getClass(),
2525
]);
2626

27+
if (isPublic) {
28+
return true;
29+
}
30+
2731
const cookie = context.switchToHttp().getRequest().headers.cookie;
2832
if (!cookie?.includes(AUTH_SESSION_COOKIE_NAME)) {
2933
this.logger.error('Auth session cookie is not found in request cookies');
3034
}
3135

32-
if (isPublic) {
33-
return true;
34-
}
35-
3636
try {
3737
return await this.validate(context);
3838
} catch (error) {

apps/nestjs-backend/src/features/record/typecast.validate.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,10 @@ describe('TypeCastAndValidate', () => {
304304
const field = mockDeep<SingleSelectFieldDto>({
305305
id: 'fldxxxx',
306306
type: FieldType.SingleSelect,
307-
options: { choices: [{ id: '1', name: 'option 1', color: Colors.Blue }] },
307+
options: {
308+
choices: [{ id: '1', name: 'option 1', color: Colors.Blue }],
309+
preventAutoNewOptions: false,
310+
},
308311
});
309312
const cellValues = ['value'];
310313
const typeCastAndValidate = new TypeCastAndValidate({
@@ -334,7 +337,10 @@ describe('TypeCastAndValidate', () => {
334337
const field = mockDeep<SingleSelectFieldDto>({
335338
id: 'fldxxxx',
336339
type: FieldType.SingleSelect,
337-
options: { choices: [{ id: '1', name: 'option 1', color: Colors.Blue }] },
340+
options: {
341+
choices: [{ id: '1', name: 'option 1', color: Colors.Blue }],
342+
preventAutoNewOptions: false,
343+
},
338344
});
339345
const cellValues = ['value'];
340346
const typeCastAndValidate = new TypeCastAndValidate({

apps/nestjs-backend/src/features/record/typecast.validate.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { BadRequestException } from '@nestjs/common';
2-
import type { IAttachmentItem, ILinkCellValue, UserFieldCore } from '@teable/core';
2+
import type {
3+
IAttachmentItem,
4+
ILinkCellValue,
5+
ISelectFieldChoice,
6+
ISelectFieldOptions,
7+
UserFieldCore,
8+
} from '@teable/core';
39
import {
410
ColorUtils,
511
FieldType,
@@ -71,6 +77,7 @@ export class TypeCastAndValidate {
7177
private readonly field: IFieldInstance;
7278
private readonly tableId: string;
7379
private readonly typecast?: boolean;
80+
private cache: Record<string, unknown> = {};
7481

7582
constructor({
7683
services,
@@ -87,6 +94,12 @@ export class TypeCastAndValidate {
8794
this.field = field;
8895
this.typecast = typecast;
8996
this.tableId = tableId;
97+
if (
98+
!this.field.isComputed &&
99+
(this.field.type === FieldType.SingleSelect || this.field.type === FieldType.MultipleSelect)
100+
) {
101+
this.cache.choicesMap = keyBy((this.field.options as ISelectFieldOptions).choices, 'name');
102+
}
90103
}
91104

92105
/**
@@ -158,14 +171,14 @@ export class TypeCastAndValidate {
158171
return null;
159172
}
160173
if (Array.isArray(value)) {
161-
return value.filter((v) => v != null && v !== '').map(String);
174+
return value.filter((v) => v != null && v !== '').map((v) => String(v).trim());
162175
}
163176
if (typeof value === 'string') {
164-
return [value];
177+
return [value.trim()];
165178
}
166179
const strValue = String(value);
167180
if (strValue != null) {
168-
return [String(value)];
181+
return [String(value).trim()];
169182
}
170183
return null;
171184
}
@@ -179,7 +192,7 @@ export class TypeCastAndValidate {
179192
return;
180193
}
181194
const { id, type, options } = this.field as SingleSelectFieldDto | MultipleSelectFieldDto;
182-
const existsChoicesNameMap = keyBy(options.choices, 'name');
195+
const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;
183196
const notExists = choicesNames.filter((name) => !existsChoicesNameMap[name]);
184197
const colors = ColorUtils.randomColor(map(options.choices, 'color'), notExists.length);
185198
const newChoices = notExists.map((name, index) => ({
@@ -210,12 +223,21 @@ export class TypeCastAndValidate {
210223
*/
211224
private async castToSingleSelect(cellValues: unknown[]): Promise<unknown[]> {
212225
const allValuesSet = new Set<string>();
226+
const { preventAutoNewOptions } = this.field.options as ISelectFieldOptions;
227+
const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;
213228
const newCellValues = this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {
214229
const valueArr = this.valueToStringArray(cellValue);
215230
const newCellValue: string | null = valueArr?.length ? valueArr[0] : null;
216231
newCellValue && allValuesSet.add(newCellValue);
217232
return newCellValue;
218-
});
233+
}) as string[];
234+
235+
if (preventAutoNewOptions) {
236+
return newCellValues
237+
? newCellValues.map((v) => (existsChoicesNameMap[v] ? v : null))
238+
: newCellValues;
239+
}
240+
219241
await this.createOptionsIfNotExists([...allValuesSet]);
220242
return newCellValues;
221243
}
@@ -239,14 +261,32 @@ export class TypeCastAndValidate {
239261
*/
240262
private async castToMultipleSelect(cellValues: unknown[]): Promise<unknown[]> {
241263
const allValuesSet = new Set<string>();
264+
const { preventAutoNewOptions } = this.field.options as ISelectFieldOptions;
242265
const newCellValues = this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {
243266
const valueArr =
244-
typeof cellValue === 'string' ? cellValue.split(',').map((s) => s.trim()) : null;
267+
typeof cellValue === 'string'
268+
? cellValue.split(',').map((s) => s.trim())
269+
: Array.isArray(cellValue)
270+
? cellValue.filter((v) => typeof v === 'string').map((v) => v.trim())
271+
: null;
245272
const newCellValue: string[] | null = valueArr?.length ? valueArr : null;
246273
// collect all options
247274
newCellValue?.forEach((v) => v && allValuesSet.add(v));
248275
return newCellValue;
249276
});
277+
278+
if (preventAutoNewOptions) {
279+
const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;
280+
return newCellValues
281+
? newCellValues.map((v) => {
282+
if (v && Array.isArray(v)) {
283+
return (v as string[]).filter((v) => existsChoicesNameMap[v]);
284+
}
285+
return v;
286+
})
287+
: newCellValues;
288+
}
289+
250290
await this.createOptionsIfNotExists([...allValuesSet]);
251291
return newCellValues;
252292
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { join, resolve } from 'path';
2+
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
3+
import { PrismaService } from '@teable/db-main-prisma';
4+
import { UploadType } from '@teable/openapi';
5+
import { createReadStream } from 'fs-extra';
6+
import sharp from 'sharp';
7+
import StorageAdapter from '../attachments/plugins/adapter';
8+
import { InjectStorageAdapter } from '../attachments/plugins/storage';
9+
10+
@Injectable()
11+
export class UserInitService implements OnModuleInit {
12+
private logger = new Logger(UserInitService.name);
13+
14+
constructor(
15+
private readonly prismaService: PrismaService,
16+
@InjectStorageAdapter() readonly storageAdapter: StorageAdapter
17+
) {}
18+
19+
async onModuleInit() {
20+
await this.uploadStatic(
21+
'automationRobot',
22+
'static/system/automation-robot.png',
23+
UploadType.Avatar
24+
);
25+
await this.uploadStatic('anonymous', 'static/system/anonymous.png', UploadType.Avatar);
26+
27+
this.logger.log('System users initialized');
28+
}
29+
30+
async uploadStatic(id: string, filePath: string, type: UploadType) {
31+
const fileStream = createReadStream(resolve(process.cwd(), filePath));
32+
const metaReader = sharp();
33+
const sharpReader = fileStream.pipe(metaReader);
34+
const { width, height, format = 'png', size = 0 } = await sharpReader.metadata();
35+
const path = join(StorageAdapter.getDir(type), id);
36+
const bucket = StorageAdapter.getBucket(type);
37+
const mimetype = `image/${format}`;
38+
const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, filePath, {
39+
// eslint-disable-next-line @typescript-eslint/naming-convention
40+
'Content-Type': mimetype,
41+
});
42+
await this.prismaService.txClient().attachments.upsert({
43+
create: {
44+
token: id,
45+
path,
46+
size,
47+
width,
48+
height,
49+
hash,
50+
mimetype,
51+
createdBy: 'system',
52+
},
53+
update: {
54+
size,
55+
width,
56+
height,
57+
hash,
58+
mimetype,
59+
lastModifiedBy: 'system',
60+
},
61+
where: {
62+
token: id,
63+
deletedTime: null,
64+
},
65+
});
66+
return `/${path}`;
67+
}
68+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
22
import { MulterModule } from '@nestjs/platform-express';
33
import multer from 'multer';
44
import { StorageModule } from '../attachments/plugins/storage.module';
5+
import { UserInitService } from './user-init.service';
56
import { UserController } from './user.controller';
67
import { UserService } from './user.service';
78

@@ -13,7 +14,7 @@ import { UserService } from './user.service';
1314
}),
1415
StorageModule,
1516
],
16-
providers: [UserService],
17+
providers: [UserService, UserInitService],
1718
exports: [UserService],
1819
})
1920
export class UserModule {}
636 Bytes
Loading
453 Bytes
Loading

apps/nestjs-backend/test/record.e2e-spec.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { INestApplication } from '@nestjs/common';
33
import type { IFieldRo, ISelectFieldOptions } from '@teable/core';
44
import { CellFormat, DriverClient, FieldKeyType, FieldType, Relationship } from '@teable/core';
5-
import { type ITableFullVo } from '@teable/openapi';
5+
import { updateRecords, type ITableFullVo } from '@teable/openapi';
66
import {
77
convertField,
88
createField,
@@ -196,6 +196,59 @@ describe('OpenAPI RecordController (e2e)', () => {
196196
);
197197
});
198198

199+
it('should not auto create options when preventAutoNewOptions is true', async () => {
200+
const singleSelectField = await createField(table.id, {
201+
type: FieldType.SingleSelect,
202+
options: {
203+
choices: [{ name: 'red' }],
204+
preventAutoNewOptions: true,
205+
},
206+
});
207+
208+
const multiSelectField = await createField(table.id, {
209+
type: FieldType.MultipleSelect,
210+
options: {
211+
choices: [{ name: 'red' }],
212+
preventAutoNewOptions: true,
213+
},
214+
});
215+
216+
const records1 = (
217+
await updateRecords(table.id, {
218+
records: [
219+
{
220+
id: table.records[0].id,
221+
fields: { [singleSelectField.id]: 'red' },
222+
},
223+
{
224+
id: table.records[1].id,
225+
fields: { [singleSelectField.id]: 'blue' },
226+
},
227+
],
228+
fieldKeyType: FieldKeyType.Id,
229+
typecast: true,
230+
})
231+
).data;
232+
233+
expect(records1[0].fields[singleSelectField.id]).toEqual('red');
234+
expect(records1[1].fields[singleSelectField.id]).toBeUndefined();
235+
236+
const records2 = (
237+
await updateRecords(table.id, {
238+
records: [
239+
{
240+
id: table.records[0].id,
241+
fields: { [multiSelectField.id]: ['red', 'blue'] },
242+
},
243+
],
244+
fieldKeyType: FieldKeyType.Id,
245+
typecast: true,
246+
})
247+
).data;
248+
249+
expect(records2[0].fields[multiSelectField.id]).toEqual(['red']);
250+
});
251+
199252
it('should batch create records', async () => {
200253
const count = 100;
201254
console.time(`create ${count} records`);

0 commit comments

Comments
 (0)