Skip to content

Commit

Permalink
feat: add robot and anonymous display (#1110)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
tea-artist authored Nov 27, 2024
1 parent 1e6e4e1 commit 5adcc8b
Show file tree
Hide file tree
Showing 45 changed files with 476 additions and 125 deletions.
8 changes: 4 additions & 4 deletions apps/nestjs-backend/src/features/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export class AuthController {
@UseGuards(LocalAuthGuard)
@HttpCode(200)
@Post('signin')
async signin(@Req() req: Express.Request) {
return req.user;
async signin(@Req() req: Express.Request): Promise<IUserMeVo> {
return req.user as IUserMeVo;
}

@Post('signout')
Expand All @@ -46,9 +46,9 @@ export class AuthController {
@Body(new ZodValidationPipe(signupSchema)) body: ISignup,
@Res({ passthrough: true }) res: Response,
@Req() req: Express.Request
) {
): Promise<IUserMeVo> {
const user = pickUserMe(
await this.authService.signup(body.email, body.password, body.defaultSpaceName)
await this.authService.signup(body.email, body.password, body.defaultSpaceName, body.refMeta)
);
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
Expand Down
8 changes: 5 additions & 3 deletions apps/nestjs-backend/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { generateUserId, getRandomString } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type { IChangePasswordRo, IUserInfoVo, IUserMeVo } from '@teable/openapi';
import type { IChangePasswordRo, IRefMeta, IUserInfoVo, IUserMeVo } from '@teable/openapi';
import * as bcrypt from 'bcrypt';
import { omit, pick } from 'lodash';
import { isEmpty, omit, pick } from 'lodash';
import { ClsService } from 'nestjs-cls';
import { CacheService } from '../../cache/cache.service';
import { AuthConfig, type IAuthConfig } from '../../configs/auth.config';
Expand Down Expand Up @@ -70,7 +70,7 @@ export class AuthService {
return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null;
}

async signup(email: string, password: string, defaultSpaceName?: string) {
async signup(email: string, password: string, defaultSpaceName?: string, refMeta?: IRefMeta) {
const user = await this.userService.getUserByEmail(email);
if (user && (user.password !== null || user.accounts.length > 0)) {
throw new HttpException(`User ${email} is already registered`, HttpStatus.BAD_REQUEST);
Expand All @@ -84,6 +84,7 @@ export class AuthService {
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
refMeta: refMeta ? JSON.stringify(refMeta) : undefined,
},
});
}
Expand All @@ -95,6 +96,7 @@ export class AuthService {
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
refMeta: isEmpty(refMeta) ? undefined : JSON.stringify(refMeta),
},
undefined,
defaultSpaceName
Expand Down
8 changes: 4 additions & 4 deletions apps/nestjs-backend/src/features/auth/guard/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ export class AuthGuard extends PassportAuthGuard(['session', ACCESS_TOKEN_STRATE
context.getClass(),
]);

if (isPublic) {
return true;
}

const cookie = context.switchToHttp().getRequest().headers.cookie;
if (!cookie?.includes(AUTH_SESSION_COOKIE_NAME)) {
this.logger.error('Auth session cookie is not found in request cookies');
}

if (isPublic) {
return true;
}

try {
return await this.validate(context);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,10 @@ describe('TypeCastAndValidate', () => {
const field = mockDeep<SingleSelectFieldDto>({
id: 'fldxxxx',
type: FieldType.SingleSelect,
options: { choices: [{ id: '1', name: 'option 1', color: Colors.Blue }] },
options: {
choices: [{ id: '1', name: 'option 1', color: Colors.Blue }],
preventAutoNewOptions: false,
},
});
const cellValues = ['value'];
const typeCastAndValidate = new TypeCastAndValidate({
Expand Down Expand Up @@ -334,7 +337,10 @@ describe('TypeCastAndValidate', () => {
const field = mockDeep<SingleSelectFieldDto>({
id: 'fldxxxx',
type: FieldType.SingleSelect,
options: { choices: [{ id: '1', name: 'option 1', color: Colors.Blue }] },
options: {
choices: [{ id: '1', name: 'option 1', color: Colors.Blue }],
preventAutoNewOptions: false,
},
});
const cellValues = ['value'];
const typeCastAndValidate = new TypeCastAndValidate({
Expand Down
54 changes: 47 additions & 7 deletions apps/nestjs-backend/src/features/record/typecast.validate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import type { IAttachmentItem, ILinkCellValue, UserFieldCore } from '@teable/core';
import type {
IAttachmentItem,
ILinkCellValue,
ISelectFieldChoice,
ISelectFieldOptions,
UserFieldCore,
} from '@teable/core';
import {
ColorUtils,
FieldType,
Expand Down Expand Up @@ -71,6 +77,7 @@ export class TypeCastAndValidate {
private readonly field: IFieldInstance;
private readonly tableId: string;
private readonly typecast?: boolean;
private cache: Record<string, unknown> = {};

constructor({
services,
Expand All @@ -87,6 +94,12 @@ export class TypeCastAndValidate {
this.field = field;
this.typecast = typecast;
this.tableId = tableId;
if (
!this.field.isComputed &&
(this.field.type === FieldType.SingleSelect || this.field.type === FieldType.MultipleSelect)
) {
this.cache.choicesMap = keyBy((this.field.options as ISelectFieldOptions).choices, 'name');
}
}

/**
Expand Down Expand Up @@ -158,14 +171,14 @@ export class TypeCastAndValidate {
return null;
}
if (Array.isArray(value)) {
return value.filter((v) => v != null && v !== '').map(String);
return value.filter((v) => v != null && v !== '').map((v) => String(v).trim());
}
if (typeof value === 'string') {
return [value];
return [value.trim()];
}
const strValue = String(value);
if (strValue != null) {
return [String(value)];
return [String(value).trim()];
}
return null;
}
Expand All @@ -179,7 +192,7 @@ export class TypeCastAndValidate {
return;
}
const { id, type, options } = this.field as SingleSelectFieldDto | MultipleSelectFieldDto;
const existsChoicesNameMap = keyBy(options.choices, 'name');
const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;
const notExists = choicesNames.filter((name) => !existsChoicesNameMap[name]);
const colors = ColorUtils.randomColor(map(options.choices, 'color'), notExists.length);
const newChoices = notExists.map((name, index) => ({
Expand Down Expand Up @@ -210,12 +223,21 @@ export class TypeCastAndValidate {
*/
private async castToSingleSelect(cellValues: unknown[]): Promise<unknown[]> {
const allValuesSet = new Set<string>();
const { preventAutoNewOptions } = this.field.options as ISelectFieldOptions;
const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;
const newCellValues = this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {
const valueArr = this.valueToStringArray(cellValue);
const newCellValue: string | null = valueArr?.length ? valueArr[0] : null;
newCellValue && allValuesSet.add(newCellValue);
return newCellValue;
});
}) as string[];

if (preventAutoNewOptions) {
return newCellValues
? newCellValues.map((v) => (existsChoicesNameMap[v] ? v : null))
: newCellValues;
}

await this.createOptionsIfNotExists([...allValuesSet]);
return newCellValues;
}
Expand All @@ -239,14 +261,32 @@ export class TypeCastAndValidate {
*/
private async castToMultipleSelect(cellValues: unknown[]): Promise<unknown[]> {
const allValuesSet = new Set<string>();
const { preventAutoNewOptions } = this.field.options as ISelectFieldOptions;
const newCellValues = this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {
const valueArr =
typeof cellValue === 'string' ? cellValue.split(',').map((s) => s.trim()) : null;
typeof cellValue === 'string'
? cellValue.split(',').map((s) => s.trim())
: Array.isArray(cellValue)
? cellValue.filter((v) => typeof v === 'string').map((v) => v.trim())
: null;
const newCellValue: string[] | null = valueArr?.length ? valueArr : null;
// collect all options
newCellValue?.forEach((v) => v && allValuesSet.add(v));
return newCellValue;
});

if (preventAutoNewOptions) {
const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;
return newCellValues
? newCellValues.map((v) => {
if (v && Array.isArray(v)) {
return (v as string[]).filter((v) => existsChoicesNameMap[v]);
}
return v;
})
: newCellValues;
}

await this.createOptionsIfNotExists([...allValuesSet]);
return newCellValues;
}
Expand Down
68 changes: 68 additions & 0 deletions apps/nestjs-backend/src/features/user/user-init.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { join, resolve } from 'path';
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { PrismaService } from '@teable/db-main-prisma';
import { UploadType } from '@teable/openapi';
import { createReadStream } from 'fs-extra';
import sharp from 'sharp';
import StorageAdapter from '../attachments/plugins/adapter';
import { InjectStorageAdapter } from '../attachments/plugins/storage';

@Injectable()
export class UserInitService implements OnModuleInit {
private logger = new Logger(UserInitService.name);

constructor(
private readonly prismaService: PrismaService,
@InjectStorageAdapter() readonly storageAdapter: StorageAdapter
) {}

async onModuleInit() {
await this.uploadStatic(
'automationRobot',
'static/system/automation-robot.png',
UploadType.Avatar
);
await this.uploadStatic('anonymous', 'static/system/anonymous.png', UploadType.Avatar);

this.logger.log('System users initialized');
}

async uploadStatic(id: string, filePath: string, type: UploadType) {
const fileStream = createReadStream(resolve(process.cwd(), filePath));
const metaReader = sharp();
const sharpReader = fileStream.pipe(metaReader);
const { width, height, format = 'png', size = 0 } = await sharpReader.metadata();
const path = join(StorageAdapter.getDir(type), id);
const bucket = StorageAdapter.getBucket(type);
const mimetype = `image/${format}`;
const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, filePath, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': mimetype,
});
await this.prismaService.txClient().attachments.upsert({
create: {
token: id,
path,
size,
width,
height,
hash,
mimetype,
createdBy: 'system',
},
update: {
size,
width,
height,
hash,
mimetype,
lastModifiedBy: 'system',
},
where: {
token: id,
deletedTime: null,
},
});
return `/${path}`;
}
}
3 changes: 2 additions & 1 deletion apps/nestjs-backend/src/features/user/user.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import multer from 'multer';
import { StorageModule } from '../attachments/plugins/storage.module';
import { UserInitService } from './user-init.service';
import { UserController } from './user.controller';
import { UserService } from './user.service';

Expand All @@ -13,7 +14,7 @@ import { UserService } from './user.service';
}),
StorageModule,
],
providers: [UserService],
providers: [UserService, UserInitService],
exports: [UserService],
})
export class UserModule {}
Binary file added apps/nestjs-backend/static/system/anonymous.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 54 additions & 1 deletion apps/nestjs-backend/test/record.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { INestApplication } from '@nestjs/common';
import type { IFieldRo, ISelectFieldOptions } from '@teable/core';
import { CellFormat, DriverClient, FieldKeyType, FieldType, Relationship } from '@teable/core';
import { type ITableFullVo } from '@teable/openapi';
import { updateRecords, type ITableFullVo } from '@teable/openapi';
import {
convertField,
createField,
Expand Down Expand Up @@ -196,6 +196,59 @@ describe('OpenAPI RecordController (e2e)', () => {
);
});

it('should not auto create options when preventAutoNewOptions is true', async () => {
const singleSelectField = await createField(table.id, {
type: FieldType.SingleSelect,
options: {
choices: [{ name: 'red' }],
preventAutoNewOptions: true,
},
});

const multiSelectField = await createField(table.id, {
type: FieldType.MultipleSelect,
options: {
choices: [{ name: 'red' }],
preventAutoNewOptions: true,
},
});

const records1 = (
await updateRecords(table.id, {
records: [
{
id: table.records[0].id,
fields: { [singleSelectField.id]: 'red' },
},
{
id: table.records[1].id,
fields: { [singleSelectField.id]: 'blue' },
},
],
fieldKeyType: FieldKeyType.Id,
typecast: true,
})
).data;

expect(records1[0].fields[singleSelectField.id]).toEqual('red');
expect(records1[1].fields[singleSelectField.id]).toBeUndefined();

const records2 = (
await updateRecords(table.id, {
records: [
{
id: table.records[0].id,
fields: { [multiSelectField.id]: ['red', 'blue'] },
},
],
fieldKeyType: FieldKeyType.Id,
typecast: true,
})
).data;

expect(records2[0].fields[multiSelectField.id]).toEqual(['red']);
});

it('should batch create records', async () => {
const count = 100;
console.time(`create ${count} records`);
Expand Down
Loading

0 comments on commit 5adcc8b

Please sign in to comment.