Skip to content

Commit 4bd5d03

Browse files
authored
fix: missing signed url in attachment cell op (#1065)
* fix: missing signed url in attachment cell op * refactor: process images via file path using sharp
1 parent e93dc65 commit 4bd5d03

7 files changed

Lines changed: 250 additions & 61 deletions

File tree

apps/nestjs-backend/src/features/attachments/plugins/minio.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,19 @@ export class MinioStorage implements StorageAdapter {
204204
if (!mimetype?.startsWith('image/')) {
205205
throw new BadRequestException('Invalid image');
206206
}
207+
const sourceFilePath = resolve(StorageAdapter.TEMPORARY_DIR, encodeURIComponent(path));
208+
const writeStream = fse.createWriteStream(sourceFilePath);
207209
const stream = await this.minioClientPrivateNetwork.getObject(bucket, objectName);
208-
const metaReader = sharp({ failOn: 'none', unlimited: true }).resize(width, height);
209-
const sharpReader = stream.pipe(metaReader);
210-
await sharpReader.toFile(resizedImagePath);
210+
// stream save in sourceFilePath
211+
stream.pipe(writeStream);
212+
const metaReader = sharp(sourceFilePath, { failOn: 'none', unlimited: true }).resize(
213+
width,
214+
height
215+
);
216+
await metaReader.toFile(resizedImagePath);
217+
// delete source file
218+
fse.removeSync(sourceFilePath);
219+
211220
const upload = await this.uploadFileWidthPath(bucket, newPath, resizedImagePath, {
212221
'Content-Type': mimetype,
213222
});

apps/nestjs-backend/src/features/attachments/plugins/s3.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,15 +253,23 @@ export class S3Storage implements StorageAdapter {
253253
if (!mimetype?.startsWith('image/')) {
254254
throw new BadRequestException('Invalid image');
255255
}
256-
const metaReader = sharp({ failOn: 'none', unlimited: true }).resize(width, height);
257-
const sharpReader = (stream as Readable).pipe(metaReader);
258-
await sharpReader.toFile(resizedImagePath);
256+
if (!stream) {
257+
throw new BadRequestException("can't get image stream");
258+
}
259+
const sourceFilePath = resolve(StorageAdapter.TEMPORARY_DIR, encodeURIComponent(path));
260+
const writeStream = fse.createWriteStream(sourceFilePath);
261+
(stream as Readable).pipe(writeStream);
262+
const metaReader = sharp(sourceFilePath, { failOn: 'none', unlimited: true }).resize(
263+
width,
264+
height
265+
);
266+
await metaReader.toFile(resizedImagePath);
267+
fse.removeSync(sourceFilePath);
259268
const upload = await this.uploadFileWidthPath(bucket, newPath, resizedImagePath, {
260269
'Content-Type': mimetype,
261270
});
262271
// delete resized image
263272
fse.removeSync(resizedImagePath);
264-
265273
return upload.path;
266274
}
267275
}

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

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@ import {
99
nullsToUndefined,
1010
} from '@teable/core';
1111
import type { PrismaService } from '@teable/db-main-prisma';
12-
import { UploadType } from '@teable/openapi';
1312
import { keyBy, map } from 'lodash';
1413
import { fromZodError } from 'zod-validation-error';
1514
import type { AttachmentsStorageService } from '../attachments/attachments-storage.service';
16-
import StorageAdapter from '../attachments/plugins/adapter';
1715
import type { CollaboratorService } from '../collaborator/collaborator.service';
1816
import type { FieldConvertingService } from '../field/field-calculate/field-converting.service';
1917
import type { IFieldInstance } from '../field/model/factory';
@@ -307,49 +305,7 @@ export class TypeCastAndValidate {
307305
return attachmentCellValue;
308306
}
309307

310-
const attachmentsWithPresignedUrls = attachmentCellValue.map(async (item) => {
311-
const { thumbnailPath, ...cellValue } = item;
312-
const { path, mimetype, token } = cellValue;
313-
// presigned just for realtime op preview
314-
const presignedUrl = await this.services.attachmentsStorageService.getPreviewUrlByPath(
315-
StorageAdapter.getBucket(UploadType.Table),
316-
path,
317-
token,
318-
undefined,
319-
{
320-
// eslint-disable-next-line @typescript-eslint/naming-convention
321-
'Content-Type': mimetype,
322-
// eslint-disable-next-line @typescript-eslint/naming-convention
323-
'Content-Disposition': `attachment; filename="${item.name}"`,
324-
}
325-
);
326-
let smThumbnailUrl = presignedUrl;
327-
let lgThumbnailUrl = presignedUrl;
328-
if (thumbnailPath) {
329-
if (thumbnailPath.sm) {
330-
smThumbnailUrl = await this.services.attachmentsStorageService.getTableThumbnailUrl(
331-
thumbnailPath.sm,
332-
mimetype
333-
);
334-
}
335-
if (thumbnailPath.lg) {
336-
lgThumbnailUrl = await this.services.attachmentsStorageService.getPreviewUrlByPath(
337-
StorageAdapter.getBucket(UploadType.Table),
338-
thumbnailPath.lg,
339-
mimetype
340-
);
341-
}
342-
}
343-
344-
return {
345-
...item,
346-
smThumbnailUrl,
347-
lgThumbnailUrl,
348-
presignedUrl,
349-
};
350-
});
351-
352-
return Promise.all(attachmentsWithPresignedUrls);
308+
return Promise.all(attachmentCellValue);
353309
});
354310
return await Promise.all(allAttachmentsPromises);
355311
}
@@ -393,7 +349,7 @@ export class TypeCastAndValidate {
393349

394350
private async getAttachmentItemMap(
395351
cellValues: unknown[]
396-
): Promise<Record<string, IAttachmentItem & { thumbnailPath?: { sm?: string; lg?: string } }>> {
352+
): Promise<Record<string, IAttachmentItem>> {
397353
// Extract and flatten attachment IDs from cell values
398354
const attachmentIds = cellValues
399355
.flat()
@@ -419,7 +375,6 @@ export class TypeCastAndValidate {
419375
path: true,
420376
width: true,
421377
height: true,
422-
thumbnailPath: true,
423378
},
424379
});
425380

@@ -430,7 +385,6 @@ export class TypeCastAndValidate {
430385
const metadata = metadataMap[detail.token];
431386
acc[metadata.attachmentId] = {
432387
...nullsToUndefined(detail),
433-
thumbnailPath: detail.thumbnailPath ? JSON.parse(detail.thumbnailPath) : undefined,
434388
name: metadata.name,
435389
id: generateAttachmentId(),
436390
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { AttachmentsStorageModule } from '../../features/attachments/attachments-storage.module';
3+
import { RepairAttachmentOpService } from './repair-attachment-op.service';
4+
5+
@Module({
6+
imports: [AttachmentsStorageModule],
7+
providers: [RepairAttachmentOpService],
8+
exports: [RepairAttachmentOpService],
9+
})
10+
export class RepairAttachmentOpModule {}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { Injectable } from '@nestjs/common';
2+
import type { IAttachmentCellValue, IOtOperation } from '@teable/core';
3+
import { RecordOpBuilder } from '@teable/core';
4+
import { PrismaService } from '@teable/db-main-prisma';
5+
import { UploadType } from '@teable/openapi';
6+
import type { EditOp, CreateOp, DeleteOp } from 'sharedb';
7+
import { CacheService } from '../../cache/cache.service';
8+
import { AttachmentsStorageService } from '../../features/attachments/attachments-storage.service';
9+
import StorageAdapter from '../../features/attachments/plugins/adapter';
10+
import { getTableThumbnailToken } from '../../utils/generate-thumbnail-path';
11+
import { Timing } from '../../utils/timing';
12+
import type { IRawOpMap } from '../interface';
13+
14+
@Injectable()
15+
export class RepairAttachmentOpService {
16+
constructor(
17+
private readonly prismaService: PrismaService,
18+
private readonly cacheService: CacheService,
19+
private readonly attachmentsStorageService: AttachmentsStorageService
20+
) {}
21+
22+
private isEditOp(rawOp: EditOp | CreateOp | DeleteOp): rawOp is EditOp {
23+
return Boolean(!rawOp.del && !rawOp.create && rawOp.op);
24+
}
25+
26+
private getAttachmentCell(op: IOtOperation) {
27+
const setRecordOp = RecordOpBuilder.editor.setRecord.detect(op);
28+
if (!setRecordOp) {
29+
return;
30+
}
31+
const newCellValue = setRecordOp.newCellValue;
32+
if (newCellValue && Array.isArray(newCellValue) && newCellValue?.[0]?.mimetype) {
33+
return newCellValue as IAttachmentCellValue;
34+
}
35+
}
36+
37+
private getCollectionsAttachmentToken(rawOp: EditOp | CreateOp | DeleteOp): string[] | undefined {
38+
if (!this.isEditOp(rawOp)) {
39+
return;
40+
}
41+
return rawOp.op.reduce((acc, op) => {
42+
const attachmentCell = this.getAttachmentCell(op);
43+
if (!attachmentCell) {
44+
return acc;
45+
}
46+
attachmentCell.forEach((cell) => {
47+
if (!cell.presignedUrl) {
48+
acc.push(cell.token);
49+
}
50+
});
51+
return acc;
52+
}, []);
53+
}
54+
55+
private async getThumbnailPathTokenMap(tokens: string[]) {
56+
const thumbnailPathTokenMap: Record<
57+
string,
58+
{
59+
sm?: string;
60+
lg?: string;
61+
}
62+
> = {};
63+
// once handle 1000 tokens
64+
const batchSize = 1000;
65+
for (let i = 0; i < tokens.length; i += batchSize) {
66+
const batch = tokens.slice(i, i + batchSize);
67+
const attachments = await this.prismaService.txClient().attachments.findMany({
68+
where: { token: { in: batch } },
69+
select: { token: true, thumbnailPath: true },
70+
});
71+
attachments.forEach((attachment) => {
72+
if (attachment.thumbnailPath) {
73+
thumbnailPathTokenMap[attachment.token] = JSON.parse(attachment.thumbnailPath);
74+
}
75+
});
76+
}
77+
return thumbnailPathTokenMap;
78+
}
79+
80+
private async getCachePreviewUrlTokenMap(tokens: string[]) {
81+
const previewUrlTokenMap: Record<string, string> = {};
82+
// once handle 1000 tokens
83+
const batchSize = 1000;
84+
for (let i = 0; i < tokens.length; i += batchSize) {
85+
const batch = tokens.slice(i, i + batchSize);
86+
const previewUrls = await this.cacheService.getMany(
87+
batch.map((token) => `attachment:preview:${token}` as const)
88+
);
89+
previewUrls.forEach((urlCache, index) => {
90+
if (urlCache) {
91+
previewUrlTokenMap[batch[i + index]] = urlCache.url;
92+
}
93+
});
94+
}
95+
return previewUrlTokenMap;
96+
}
97+
98+
@Timing()
99+
async getCollectionsAttachmentsContext(rawOpMaps: IRawOpMap[]) {
100+
const collectionsAttachmentTokens: Record<string, string[]> = {};
101+
for (const rawOpMap of rawOpMaps) {
102+
for (const collection in rawOpMap) {
103+
const data = rawOpMap[collection];
104+
for (const docId in data) {
105+
const rawOp = data[docId] as EditOp | CreateOp | DeleteOp;
106+
const attachmentCells = this.getCollectionsAttachmentToken(rawOp);
107+
const tableId = collection.split('_')[1];
108+
if (attachmentCells?.length) {
109+
collectionsAttachmentTokens[`${tableId}-${docId}`] = attachmentCells;
110+
}
111+
}
112+
}
113+
}
114+
const tokens = Object.values(collectionsAttachmentTokens).flat();
115+
const uniqueTokens = [...new Set(tokens)];
116+
const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap(uniqueTokens);
117+
const cachePreviewUrlTokenMap = await this.getCachePreviewUrlTokenMap(uniqueTokens);
118+
return {
119+
thumbnailPathTokenMap,
120+
cachePreviewUrlTokenMap,
121+
};
122+
}
123+
124+
private async presignedAttachmentUrl(
125+
item: { name: string; path: string; token: string; mimetype: string },
126+
context: {
127+
thumbnailPathTokenMap: Record<string, { sm?: string; lg?: string }>;
128+
cachePreviewUrlTokenMap: Record<string, string>;
129+
}
130+
) {
131+
const { thumbnailPathTokenMap, cachePreviewUrlTokenMap } = context;
132+
const { path, token, mimetype, name } = item;
133+
134+
const presignedUrl =
135+
cachePreviewUrlTokenMap[token] ??
136+
(await this.attachmentsStorageService.getPreviewUrlByPath(
137+
StorageAdapter.getBucket(UploadType.Table),
138+
path,
139+
token,
140+
undefined,
141+
{
142+
// eslint-disable-next-line @typescript-eslint/naming-convention
143+
'Content-Type': mimetype,
144+
// eslint-disable-next-line @typescript-eslint/naming-convention
145+
'Content-Disposition': `attachment; filename="${name}"`,
146+
}
147+
));
148+
let smThumbnailUrl: string | undefined;
149+
let lgThumbnailUrl: string | undefined;
150+
if (mimetype.startsWith('image/') && thumbnailPathTokenMap && thumbnailPathTokenMap[token]) {
151+
const { sm: smThumbnailPath, lg: lgThumbnailPath } = thumbnailPathTokenMap[token]!;
152+
if (smThumbnailPath) {
153+
smThumbnailUrl =
154+
cachePreviewUrlTokenMap?.[getTableThumbnailToken(smThumbnailPath)] ??
155+
(await this.attachmentsStorageService.getTableThumbnailUrl(smThumbnailPath, mimetype));
156+
}
157+
if (lgThumbnailPath) {
158+
lgThumbnailUrl =
159+
cachePreviewUrlTokenMap?.[getTableThumbnailToken(lgThumbnailPath)] ??
160+
(await this.attachmentsStorageService.getTableThumbnailUrl(lgThumbnailPath, mimetype));
161+
}
162+
smThumbnailUrl = smThumbnailUrl || presignedUrl;
163+
lgThumbnailUrl = lgThumbnailUrl || presignedUrl;
164+
}
165+
return {
166+
presignedUrl,
167+
smThumbnailUrl,
168+
lgThumbnailUrl,
169+
};
170+
}
171+
172+
async repairAttachmentOp(
173+
rawOp: EditOp | CreateOp | DeleteOp,
174+
context: {
175+
thumbnailPathTokenMap: Record<string, { sm?: string; lg?: string }>;
176+
cachePreviewUrlTokenMap: Record<string, string>;
177+
}
178+
) {
179+
if (!this.isEditOp(rawOp)) {
180+
return rawOp;
181+
}
182+
for (const op of rawOp.op) {
183+
const newAttachmentCell = this.getAttachmentCell(op);
184+
if (!newAttachmentCell) {
185+
continue;
186+
}
187+
for (const item of newAttachmentCell) {
188+
if (!item.presignedUrl) {
189+
const { presignedUrl, smThumbnailUrl, lgThumbnailUrl } =
190+
await this.presignedAttachmentUrl(item, context);
191+
item.presignedUrl = presignedUrl;
192+
item.smThumbnailUrl = smThumbnailUrl;
193+
item.lgThumbnailUrl = lgThumbnailUrl;
194+
}
195+
}
196+
op.oi = newAttachmentCell;
197+
}
198+
return rawOp;
199+
}
200+
}

apps/nestjs-backend/src/share-db/share-db.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Module } from '@nestjs/common';
22
import { TableModule } from '../features/table/table.module';
33
import { ReadonlyModule } from './readonly/readonly.module';
4+
import { RepairAttachmentOpModule } from './repair-attachment-op/repair-attachment-op.module';
45
import { ShareDbAdapter } from './share-db.adapter';
56
import { ShareDbService } from './share-db.service';
67

78
@Module({
8-
imports: [TableModule, ReadonlyModule],
9+
imports: [TableModule, ReadonlyModule, RepairAttachmentOpModule],
910
providers: [ShareDbService, ShareDbAdapter],
1011
exports: [ShareDbService],
1112
})

0 commit comments

Comments
 (0)