Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ For HTTP-ish integrations, keep framework-independent contracts/mappers in `pack

## UI components (frontend)

- In app UIs (e.g. `apps/playground`), use shadcn wrappers from `apps/playground/src/components/ui/*` (or `@teable/ui-lib`) instead of importing Radix primitives directly.
- If a shadcn wrapper is missing, add it under `apps/playground/src/components/ui` before using the primitive.
- In app UIs, use local shadcn wrappers (or `@teable/ui-lib`) instead of importing Radix primitives directly.
- If a shadcn wrapper is missing for an app UI, add it under that app's local `src/components/ui` before using the primitive.

## Dependency injection (DI)

Expand Down
3 changes: 2 additions & 1 deletion apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@
"@teable/db-main-prisma": "workspace:^",
"@teable/openapi": "workspace:^",
"@teable/v2-adapter-db-postgres-pg": "workspace:*",
"@teable/v2-adapter-undo-redo-keyv": "workspace:*",
"@teable/v2-adapter-realtime-sharedb": "workspace:*",
"@teable/v2-adapter-undo-redo-keyv": "workspace:*",
"@teable/v2-container-node": "workspace:*",
"@teable/v2-contract-http": "workspace:*",
"@teable/v2-contract-http-implementation": "workspace:*",
Expand Down Expand Up @@ -257,6 +257,7 @@
"react-dom": "18.3.1",
"redlock": "5.0.0-beta.2",
"reflect-metadata": "0.2.1",
"request-filtering-agent": "3.2.0",
"rxjs": "7.8.1",
"sharedb": "5.2.2",
"sharp": "0.33.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,12 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
throw new BadRequestException('Exact date must be entered');
}

return [dateUtil.date(exactDate).startOf('day'), dateUtil.date(exactDate).endOf('day')];
const parsedDate = dateUtil.date(exactDate);
if (hasTimeFormat) {
return [parsedDate, parsedDate];
}

return [parsedDate.startOf('day'), parsedDate.endOf('day')];
};

// Helper function to determine date range for a given exact formatted date.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export const splitAccessToken = (accessToken: string) => {
if (prefix !== authConfig().accessToken.prefix) {
return null;
}
const { sign } = getAccessTokenEncryptor().decrypt(encryptedSign);
let sign: string | null = null;
try {
sign = getAccessTokenEncryptor().decrypt(encryptedSign).sign;
} catch (error) {
return null;
}
if (!sign) {
return null;
}
Expand Down
134 changes: 74 additions & 60 deletions apps/nestjs-backend/src/features/attachments/attachments.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/naming-convention */
import fs from 'fs';
import type { IncomingHttpHeaders } from 'http';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { join, resolve } from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { Injectable, Logger } from '@nestjs/common';
import { HttpErrorCode, type IAttachmentItem } from '@teable/core';
import { generateAttachmentId } from '@teable/core';
Expand All @@ -26,12 +26,13 @@ import { StorageConfig, IStorageConfig } from '../../configs/storage';
import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';
import { CustomHttpException } from '../../custom.exception';
import type { IClsStore } from '../../types/cls';
import { FileUtils } from '../../utils';
import { FileUtils, getSsrfSafeAgents } from '../../utils';
import { second } from '../../utils/second';
import { AttachmentsCropQueueProcessor } from './attachments-crop.processor';
import { AttachmentsStorageService } from './attachments-storage.service';
import StorageAdapter from './plugins/adapter';
import type { LocalStorage } from './plugins/local';
import { extractLocalFilePath, validateReadPath } from './plugins/local.helper';
import { InjectStorageAdapter } from './plugins/storage';
import { getSafeUploadContentType } from './plugins/utils';
import { getExtensionPreview } from './utils';
Expand Down Expand Up @@ -77,15 +78,8 @@ export class AttachmentsService {

async readLocalFile(path: string, token?: string) {
const localStorage = this.storageAdapter as LocalStorage;
validateReadPath(path, localStorage.storageDir);
let respHeaders: Record<string, string> = {};

if (!path) {
throw new CustomHttpException('Could not find attachment', HttpErrorCode.NOT_FOUND, {
localization: {
i18nKey: 'httpErrors.attachment.notFound',
},
});
}
const { bucket, token: tokenInPath } = localStorage.parsePath(path);
if (token && !StorageAdapter.isPublicBucket(bucket)) {
respHeaders = localStorage.verifyReadToken(token).respHeaders ?? {};
Expand All @@ -110,8 +104,9 @@ export class AttachmentsService {
}

localFileConditionalCaching(path: string, reqHeaders: IncomingHttpHeaders, res: Response) {
const ifModifiedSince = reqHeaders['if-modified-since'];
const localStorage = this.storageAdapter as LocalStorage;
validateReadPath(path, localStorage.storageDir);
const ifModifiedSince = reqHeaders['if-modified-since'];
const lastModifiedTimestamp = localStorage.getLastModifiedTime(path);
if (!lastModifiedTimestamp) {
throw new CustomHttpException('Could not find attachment', HttpErrorCode.VALIDATION_ERROR, {
Expand All @@ -137,19 +132,7 @@ export class AttachmentsService {
const contentLength = signatureRo.contentLength;
const MAX_FILE_SIZE = this.thresholdConfig.maxAttachmentUploadSize;
if (contentLength > MAX_FILE_SIZE) {
const maxSize = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2);
throw new CustomHttpException(
`File size exceeds the maximum limit of ${maxSize} MB`,
HttpErrorCode.VALIDATION_ERROR,
{
localization: {
i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',
context: {
maxSize: `${maxSize}MB`,
},
},
}
);
this.throwFileSizeExceeded(MAX_FILE_SIZE);
}
const hash = presignedParams.hash;
const dir = StorageAdapter.getDir(type);
Expand Down Expand Up @@ -294,19 +277,7 @@ export class AttachmentsService {
);

if (contentLength > MAX_FILE_SIZE) {
const maxSize = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2);
throw new CustomHttpException(
`File size exceeds the maximum limit of ${maxSize} MB`,
HttpErrorCode.VALIDATION_ERROR,
{
localization: {
i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',
context: {
maxSize: `${maxSize}MB`,
},
},
}
);
this.throwFileSizeExceeded(MAX_FILE_SIZE);
}

const filename = this.getFilenameFromUrl(fileUrl);
Expand All @@ -329,21 +300,72 @@ export class AttachmentsService {
});
} finally {
if (tempFilePath) {
fs.unlinkSync(tempFilePath);
await fse.remove(tempFilePath);
}
}
}

private throwFileSizeExceeded(maxFileSize: number): never {
const maxSize = (maxFileSize / (1024 * 1024)).toFixed(2);
throw new CustomHttpException(
`File size exceeds the maximum limit of ${maxSize} MB`,
HttpErrorCode.VALIDATION_ERROR,
{
localization: {
i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',
context: { maxSize: `${maxSize}MB` },
},
}
);
}

private extractLocalFilePath(fileUrl: string): string | null {
const localStorage = this.storageAdapter as LocalStorage;
return extractLocalFilePath(fileUrl, this.storageConfig.provider, localStorage.storageDir);
}

/**
* Read a local file into a temp path, validating size up-front via stat.
*/
private async getLocalFileInfo(
relativePath: string,
maxFileSize: number
): Promise<{ contentLength: number; contentType: string; tempFilePath: string }> {
const localStorage = this.storageAdapter as LocalStorage;
const resolvedPath = resolve(localStorage.storageDir, relativePath);

// Fast size check before streaming — avoids unnecessary I/O for oversized files
const stat = await fse.stat(resolvedPath);
if (stat.size > maxFileSize) {
this.throwFileSizeExceeded(maxFileSize);
}

const tempFilePath = join(tmpdir(), `temp-${nanoid()}`);
await pipeline(localStorage.read(relativePath), fse.createWriteStream(tempFilePath));

return {
contentLength: stat.size,
contentType: mimeTypes.lookup(relativePath) || 'application/octet-stream',
tempFilePath,
};
}

private async getFileInfo(
fileUrl: string,
maxFileSize: number
): Promise<{ contentLength: number; contentType: string; tempFilePath: string | null }> {
// Local provider: read directly from filesystem, bypass HTTP entirely
const localRelativePath = this.extractLocalFilePath(fileUrl);
if (localRelativePath) {
return this.getLocalFileInfo(localRelativePath, maxFileSize);
}

let contentLength: number | undefined;
let contentType: string | undefined;
let tempFilePath: string | null = null;

try {
const headResponse = await axios.head(fileUrl);
const headResponse = await axios.head(fileUrl, getSsrfSafeAgents());
contentLength =
headResponse.headers['content-length'] && parseInt(headResponse.headers['content-length']);
contentType =
Expand All @@ -354,21 +376,20 @@ export class AttachmentsService {
`HEAD request successful. Content-Length: ${contentLength}, Content-Type: ${contentType}`
);
} catch (error) {
console.warn('HEAD request failed, falling back to GET:', error);
this.logger.warn('HEAD request failed, falling back to GET:', error);
}

if (!contentLength) {
this.logger.log('Content length not available from HEAD request. Downloading file...');
const tempFileName = `temp-${nanoid()}`;
tempFilePath = join(tmpdir(), tempFileName);
tempFilePath = join(tmpdir(), `temp-${nanoid()}`);

const { contentType: contentTypeFromDownLoad } = await this.downloadFile(
fileUrl,
tempFilePath,
maxFileSize
);
// why do not get from downloadFile function causing mismatch size when call it in different environment.
contentLength = fs.statSync(tempFilePath).size;
const stat = await fse.stat(tempFilePath);
contentLength = stat.size;
this.logger.log(`File downloaded. Size: ${contentLength} bytes`);

if (!contentType) {
Expand All @@ -394,14 +415,17 @@ export class AttachmentsService {
if (tempFilePath) {
await this.uploadStreamToStorage(
url,
fs.createReadStream(tempFilePath),
fse.createReadStream(tempFilePath),
contentType,
contentLength
);
this.logger.log('Upload from temporary file completed');
} else {
this.logger.log(`Downloading and uploading from URL: ${fileUrl}`);
const response = await axios.get(fileUrl, { responseType: 'stream' });
const response = await axios.get(fileUrl, {
responseType: 'stream',
...getSsrfSafeAgents(),
});
await this.uploadStreamToStorage(url, response.data, contentType, contentLength);
}
}
Expand Down Expand Up @@ -449,10 +473,11 @@ export class AttachmentsService {
method: 'get',
url: url,
responseType: 'stream',
...getSsrfSafeAgents(),
});

return new Promise((resolve, reject) => {
const writer = fs.createWriteStream(filePath);
const writer = fse.createWriteStream(filePath);
const cleanup = () => {
writer.removeAllListeners();
writer.destroy();
Expand All @@ -465,18 +490,7 @@ export class AttachmentsService {
downloadedBytes += chunk.length;
if (downloadedBytes > maxSize) {
cleanup();
throw new CustomHttpException(
`File size exceeds the maximum limit of ${(maxSize / (1024 * 1024)).toFixed(2)} MB`,
HttpErrorCode.VALIDATION_ERROR,
{
localization: {
i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',
context: {
maxSize: `${(maxSize / (1024 * 1024)).toFixed(2)}MB`,
},
},
}
);
this.throwFileSizeExceeded(maxSize);
}
});

Expand Down
Loading
Loading