Skip to content
Merged
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
  •  
  •  
  •  
11 changes: 11 additions & 0 deletions apps/nestjs-backend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,24 @@ module.exports = {
'@teable/eslint-config-bases/sonar',
'@teable/eslint-config-bases/regexp',
'@teable/eslint-config-bases/jest',
// SSRF guardrail: steer raw outbound HTTP clients to the ssrf-http wrappers
'@teable/eslint-config-bases/no-ssrf',
// Apply prettier and disable incompatible rules
'@teable/eslint-config-bases/prettier-plugin',
],
rules: {
// optional overrides per project
},
overrides: [
{
// The paved-road wrapper is the allowed raw-client site; the low-level
// guards live in @teable/v2-utils.
files: ['src/utils/ssrf-http.ts'],
rules: {
'no-restricted-imports': 'off',
'no-restricted-syntax': 'off',
},
},
{
files: ['src/event-emitter/events/**/*.event.ts'],
rules: {
Expand Down
2 changes: 1 addition & 1 deletion apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"@teable/v2-dottea": "workspace:*",
"@teable/v2-import": "workspace:*",
"@teable/v2-table-query-ops": "workspace:*",
"@teable/v2-utils": "workspace:*",
"@valibot/to-json-schema": "1.3.0",
"ai": "6.0.169",
"ajv": "8.12.0",
Expand Down Expand Up @@ -271,7 +272,6 @@
"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
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { SelectionModule } from './features/selection/selection.module';
import { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module';
import { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module';
import { ShareModule } from './features/share/share.module';
import { ShortLinkModule } from './features/short-link/short-link.module';
import { SpaceModule } from './features/space/space.module';
import { TemplateOpenApiModule } from './features/template/template-open-api.module';
import { TrashModule } from './features/trash/trash.module';
Expand Down Expand Up @@ -82,6 +83,7 @@ export const appModules = {
CollaboratorModule,
InvitationModule,
ShareModule,
ShortLinkModule,
BaseShareModule,
NotificationModule,
AccessTokenModule,
Expand Down
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ISecurityWebConfig, IApiDocConfig } from './configs/bootstrap.conf
import { GlobalExceptionFilter } from './filter/global-exception.filter';
import { setupSwagger } from './swagger';
import type { IClsStore } from './types/cls';
import { relaxOAuthPopupCoop } from './utils/oauth-popup-coop';

const host = 'localhost';

Expand All @@ -28,6 +29,7 @@ export async function setUpAppMiddleware(app: INestApplication, configService: C
// HSTS is configured at the WAF level. Disable it here to avoid sending duplicate
// `Strict-Transport-Security` headers with potentially different max-age values.
app.use(helmet({ hsts: false }));
app.use(relaxOAuthPopupCoop);
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: true }));

Expand Down
25 changes: 25 additions & 0 deletions apps/nestjs-backend/src/cache/cache.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type Keyv from 'keyv';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CacheService } from './cache.service';

describe('CacheService.getMany', () => {
let keyv: { get: ReturnType<typeof vi.fn> };
let service: CacheService;

beforeEach(() => {
keyv = { get: vi.fn().mockResolvedValue([]) };
service = new CacheService(keyv as unknown as Keyv<any>);
});

it('short-circuits an empty key list without hitting the store', async () => {
expect(await service.getMany([])).toEqual([]);
expect(keyv.get).not.toHaveBeenCalled();
});

it('forwards a non-empty key list to the store', async () => {
keyv.get.mockResolvedValue(['a-value', undefined]);
expect(await service.getMany(['a', 'b'] as any)).toEqual(['a-value', undefined]);
expect(keyv.get).toHaveBeenCalledWith(['a', 'b']);
});
});
10 changes: 8 additions & 2 deletions apps/nestjs-backend/src/cache/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,17 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
await this.cacheManager.set(key as string, value, numberTTL ? numberTTL * 1000 : undefined);
}

async del<TKey extends keyof T>(key: TKey): Promise<void> {
await this.cacheManager.delete(key as string);
// Returns true if the key existed and was deleted, so callers can use it
// as an atomic consume for one-time tokens.
async del<TKey extends keyof T>(key: TKey): Promise<boolean> {
return await this.cacheManager.delete(key as string);
}

async getMany<TKey extends keyof T>(keys: TKey[]): Promise<Array<T[TKey] | undefined>> {
// Redis-backed stores forward this to MGET, which rejects an empty key list
if (keys.length === 0) {
return [];
}
return this.cacheManager.get(keys as string[]);
}

Expand Down
32 changes: 32 additions & 0 deletions apps/nestjs-backend/src/cache/redis-native.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { hash } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import type { Redis } from 'ioredis';
import { LRUCache } from 'lru-cache';
import { CacheService } from './cache.service';

/**
Expand All @@ -14,6 +16,7 @@ import { CacheService } from './cache.service';
export class RedisNativeService {
private readonly logger = new Logger(RedisNativeService.name);
private readonly redis: Redis | undefined;
private readonly scriptShas = new LRUCache<string, string>({ max: 256 });

constructor(cacheService: CacheService) {
try {
Expand Down Expand Up @@ -242,6 +245,35 @@ export class RedisNativeService {
return this.client.eval(script, keys.length, ...keys, ...args);
}

/**
* Same contract as {@link eval}, but sends the script's SHA1 (40 bytes) instead of
* its source on every call. Prefer this for scripts on hot paths.
*
* NOSCRIPT (server restart / SCRIPT FLUSH / a replica that never saw the script)
* falls back to EVAL, which both answers the call and reloads the script into the
* server cache so subsequent EVALSHAs hit.
*
* @param script - Lua script source code
* @param keys - KEYS array accessible in Lua as KEYS[1], KEYS[2], ...
* @param args - ARGV array accessible in Lua as ARGV[1], ARGV[2], ...
* @returns Script return value (type depends on the Lua script)
*/
async evalsha(script: string, keys: string[], args: (string | number)[]): Promise<unknown> {
let sha = this.scriptShas.get(script);
if (!sha) {
sha = hash('sha1', script, 'hex');
this.scriptShas.set(script, sha);
}
try {
return await this.client.evalsha(sha, keys.length, ...keys, ...args);
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('NOSCRIPT')) {
throw err;
}
return this.client.eval(script, keys.length, ...keys, ...args);
}
}

/**
* Execute multiple commands in a single network roundtrip (pipeline).
* @param commands - Array of operations, each with an op type, key, and optional args
Expand Down
10 changes: 10 additions & 0 deletions apps/nestjs-backend/src/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface ICacheStore {
[key: `automation:fail-notify-count:${string}`]: number;
// Watchdog round-robin scan cursor per status (staleAt stored as ISO string).
[key: `automation:orphan-cursor:${string}`]: { staleAt: string; key: string };
[key: `task:watchdog-cursor:${string}`]: { staleAt: string; key: string };
// Distributed lock keys
[key: `lock:${string}`]: string;
[key: `import:result:manifest:${string}`]: {
Expand All @@ -52,6 +53,13 @@ export interface ICacheStore {
[key: `import:latest-job:${string}`]: string;
// trash cleanup: per-item backoff after failed cleanup attempts
[key: `trash-cleanup:skipped:${string}`]: { attempts: number; retryAfter: number };
// space-load exporter (EE): pg_stat counter baseline + sampling watermarks
['space-load:pgstat-baseline']: {
snapshotAt: string;
totals: Record<string, [number, number, number]>;
};
['space-load:compute-watermark']: { watermark: string; boundaryKeys: string[] };
['space-load:dead-letter-watermark']: string;
}

export interface IAttachmentSignatureCache {
Expand All @@ -75,6 +83,8 @@ export interface IAttachmentLocalTokenCache {
export interface IAttachmentPreviewCache {
url: string;
expiresIn: number;
/** Storage config fingerprint the URL was generated under; mismatch = stale. */
configSig?: string;
}

export interface IOauth2State {
Expand Down
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/configs/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const authConfig = registerAs('auth', () => ({
process.env.BACKEND_SIGNUP_VERIFICATION_EXPIRES_IN ??
'30m',
socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [],
// Same switch that gates LocalAuthModule registration in auth.module.ts
passwordLoginDisabled: process.env.PASSWORD_LOGIN_DISABLED === 'true',
github: {
clientID: process.env.BACKEND_GITHUB_CLIENT_ID,
clientSecret: process.env.BACKEND_GITHUB_CLIENT_SECRET,
Expand Down
41 changes: 41 additions & 0 deletions apps/nestjs-backend/src/configs/env.validation.schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ describe('envValidationSchema', () => {
expect(value.DATABASE_URL).toContain('/teable');
});

it('accepts a positive process-level database pool limit', () => {
const { error, value } = envValidationSchema.validate(
createEnv({
PRISMA_DATABASE_URL: 'postgresql://teable:teable@127.0.0.1:5432/teable?schema=public',
DATABASE_POOL_MAX: '8',
BYODB_DATA_DB_POOL_MAX: '4',
})
);

expect(error).toBeUndefined();
expect(value.DATABASE_POOL_MAX).toBe(8);
expect(value.BYODB_DATA_DB_POOL_MAX).toBe(4);
});

it('rejects missing meta database envs', () => {
const { error } = envValidationSchema.validate(createEnv());

Expand Down Expand Up @@ -81,4 +95,31 @@ describe('envValidationSchema', () => {

expect(error?.message).toContain('requires a producer or consumer role');
});

it('accepts integer space scheduling limits', () => {
const { error, value } = envValidationSchema.validate(
createEnv({
PRISMA_DATABASE_URL: 'postgresql://teable:teable@127.0.0.1:5432/teable?schema=public',
SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT: '20',
})
);

expect(error).toBeUndefined();
expect(value.SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT).toBe(20);
});

it.each([
['SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT', 'abc'],
['SPACE_WORKFLOW_RUN_DEFAULT_LIMIT', '2.5'],
['SPACE_WORKFLOW_RUN_DEFAULT_LIMIT', '0'],
])('rejects invalid space scheduling limit %s=%s', (key, value) => {
const { error } = envValidationSchema.validate(
createEnv({
PRISMA_DATABASE_URL: 'postgresql://teable:teable@127.0.0.1:5432/teable?schema=public',
[key]: value,
})
);

expect(error?.message).toContain(key);
});
});
6 changes: 6 additions & 0 deletions apps/nestjs-backend/src/configs/env.validation.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const envValidationSchema = Joi.object({
PRISMA_DATABASE_URL: Joi.string(),
PRISMA_META_DATABASE_URL: Joi.string(),
DATABASE_URL: Joi.string(),
DATABASE_POOL_MAX: Joi.number().integer().positive().optional(),
BYODB_DATA_DB_POOL_MAX: Joi.number().integer().positive().optional(),
V2_COMPUTED_UPDATE_MODE: Joi.string().valid('sync').optional(),

STORAGE_PREFIX: Joi.string().uri().optional(),
Expand Down Expand Up @@ -47,6 +49,10 @@ export const envValidationSchema = Joi.object({
V2_COMPUTED_OUTBOX_TRIGGER_PUBLISH_TIMEOUT_MS: Joi.number().integer().positive().default(1000),
V2_COMPUTED_OUTBOX_MONITOR_CONCURRENCY: Joi.number().integer().positive().default(4),
V2_COMPUTED_OUTBOX_MONITOR_INTERVAL_MS: Joi.number().integer().positive().default(30000),

// per-space scheduling default concurrency limits
SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT: Joi.number().integer().positive().optional(),
SPACE_WORKFLOW_RUN_DEFAULT_LIMIT: Joi.number().integer().positive().optional(),
// github auth
BACKEND_GITHUB_CLIENT_ID: Joi.when('SOCIAL_AUTH_PROVIDERS', {
is: Joi.string()
Expand Down
4 changes: 3 additions & 1 deletion apps/nestjs-backend/src/configs/oauth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { registerAs } from '@nestjs/config';
export const oauthConfig = registerAs('oauth', () => ({
accessTokenExpireIn: process.env.BACKEND_OAUTH_ACCESS_TOKEN_EXPIRE_IN || '10m',
refreshTokenExpireIn: process.env.BACKEND_OAUTH_REFRESH_TOKEN_EXPIRE_IN || '30d',
transactionExpireIn: process.env.BACKEND_OAUTH_TRANSACTION_EXPIRE_IN || '5m',
// 10m matches the OAuth state/relay TTLs of the app popup flow: the consent
// page must stay submittable for as long as the sign-in window is open.
transactionExpireIn: process.env.BACKEND_OAUTH_TRANSACTION_EXPIRE_IN || '10m',
codeExpireIn: process.env.BACKEND_OAUTH_CODE_EXPIRE_IN || '5m',
authorizedExpireIn: process.env.BACKEND_OAUTH_AUTHORIZED_EXPIRE_IN || '7d',
tokenRateLimit: Number(process.env.BACKEND_OAUTH_TOKEN_RATE_LIMIT || 30),
Expand Down
13 changes: 13 additions & 0 deletions apps/nestjs-backend/src/configs/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ export const storageConfig = registerAs('storage', () => ({
accessKey: process.env.BACKEND_STORAGE_S3_ACCESS_KEY!,
secretKey: process.env.BACKEND_STORAGE_S3_SECRET_KEY!,
maxSockets: Number(process.env.BACKEND_STORAGE_S3_MAX_SOCKETS ?? 100),
// Path-style addressing (https://endpoint/bucket/key) for S3-compatible
// storage that cannot serve virtual-hosted bucket subdomains. Only affects
// presigned URLs handed to browsers; the internal client has its own flag
// below because the two endpoints may differ in addressing support (e.g.
// a path-style public proxy in front of virtual-hosted-only Aliyun OSS).
forcePathStyle: process.env.BACKEND_STORAGE_S3_FORCE_PATH_STYLE === 'true',
// Tri-state: when unset, the internal client inherits forcePathStyle if it
// shares the public endpoint, and stays virtual-hosted when a separate
// internal endpoint is configured (its addressing support is independent
// of the public side's, e.g. Aliyun OSS internal is virtual-hosted-only).
internalForcePathStyle: process.env.BACKEND_STORAGE_S3_INTERNAL_FORCE_PATH_STYLE
? process.env.BACKEND_STORAGE_S3_INTERNAL_FORCE_PATH_STYLE === 'true'
: undefined,
},
uploadMethod: process.env.BACKEND_STORAGE_UPLOAD_METHOD ?? 'put',
encryption: {
Expand Down
7 changes: 7 additions & 0 deletions apps/nestjs-backend/src/configs/threshold.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export const thresholdConfig = registerAs('threshold', () => ({
httpRequestTimeout: Number(process.env.AUTOMATION_HTTP_REQUEST_TIMEOUT ?? 300_000), // 5 mins
watchdogDisabled: process.env.AUTOMATION_WATCHDOG_DISABLED === 'true',
},
// per-space scheduling defaults; clamped to each resource's worker pool at resolve time
spaceScheduling: {
aiFieldGenerationDefaultLimit: Number(
process.env.SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT ?? 10
),
workflowRunDefaultLimit: Number(process.env.SPACE_WORKFLOW_RUN_DEFAULT_LIMIT ?? 10),
},
}));

export const ThresholdConfig = () => Inject(thresholdConfig.KEY);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ export abstract class AbstractGroupQuery implements IGroupQueryInterface {
if (selection) {
return selection as string;
}
return field.dbFieldName;
// Quote so case-sensitive column names survive Postgres lower-case folding
return this.quoteIdentifier(field.dbFieldName);
}

private quoteIdentifier(identifier: string): string {
if (!identifier || (identifier.startsWith('"') && identifier.endsWith('"'))) {
return identifier;
}
return `"${identifier.replace(/"/g, '""')}"`;
}

private parseGroups(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export class GroupQueryPostgres extends AbstractGroupQuery {
const columnName = this.getTableColumnName(field);

if (this.isDistinct) {
return this.originQueryBuilder.countDistinct(columnName);
// raw to keep knex from re-quoting the already-quoted columnName
return this.originQueryBuilder.countDistinct(this.knex.raw(columnName));
}
return this.originQueryBuilder
.select({ [field.dbFieldName]: this.knex.raw(columnName) })
Expand Down Expand Up @@ -109,7 +110,7 @@ export class GroupQueryPostgres extends AbstractGroupQuery {

return this.originQueryBuilder.countDistinct(column);
}
return this.originQueryBuilder.countDistinct(columnName);
return this.originQueryBuilder.countDistinct(this.knex.raw(columnName));
}

if (isUserOrLink(type)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,15 @@ describe('FieldFormatter', () => {
'CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;',
]);
});

it('casts pg_indexes name columns to text and scopes index info to the table schema', () => {
const builder = new IndexBuilderPostgres();

const sql = builder.getIndexInfoSql('base_table.records');

expect(sql).toContain('schemaname::text, tablename::text, indexname::text, indexdef');
expect(sql).not.toContain('SELECT *');
expect(sql).toContain(`schemaname = 'base_table'`);
expect(sql).toContain(`tablename = 'records'`);
});
});
Loading
Loading