mirror of
https://github.com/docmost/docmost.git
synced 2026-08-21 03:21:05 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab43031375 | ||
|
|
8c2c49ea6d | ||
|
|
8913d20aa0 | ||
|
|
232beda471 | ||
|
|
911c1057d6 | ||
|
|
d136864ef1 |
@@ -52,7 +52,7 @@
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.16.1",
|
||||
"mitt": "3.0.1",
|
||||
"nanoid": "3.3.17",
|
||||
"nanoid": "3.3.18",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "19.2.7",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface IAiSearchResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function hintVectorCache(): Promise<void> {
|
||||
try {
|
||||
await api.post("/ai/vector-cache-hint");
|
||||
} catch {
|
||||
// best-effort cache hint
|
||||
}
|
||||
}
|
||||
|
||||
export async function aiAnswers(
|
||||
params: IPageSearchParams,
|
||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||
|
||||
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
|
||||
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
|
||||
import { getAiVectorDriver } from "@/lib/config.ts";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
spaceId?: string;
|
||||
}
|
||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const { t } = useTranslation();
|
||||
const hasAiFeature = useHasFeature(Feature.AI);
|
||||
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
|
||||
@@ -96,6 +101,15 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
/>
|
||||
));
|
||||
|
||||
const handleSpotlightOpen = () => {
|
||||
if (
|
||||
workspace?.settings?.ai?.search === true &&
|
||||
getAiVectorDriver() === "turbopuffer"
|
||||
) {
|
||||
hintVectorCache();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiltersChange = (newFilters: any) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
@@ -115,6 +129,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
<Spotlight.Root
|
||||
size="xl"
|
||||
maxHeight={600}
|
||||
onSpotlightOpen={handleSpotlightOpen}
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
|
||||
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
|
||||
return castToBoolean(getConfigValue("CLOUD"));
|
||||
}
|
||||
|
||||
export function getAiVectorDriver(): string {
|
||||
return getConfigValue("AI_VECTOR_DRIVER");
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
AI_VECTOR_DRIVER,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@@ -31,6 +32,7 @@ export default defineConfig(({ mode }) => {
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
AI_VECTOR_DRIVER,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
|
||||
+11
-10
@@ -40,32 +40,33 @@
|
||||
"@clickhouse/client": "1.18.2",
|
||||
"@docmost/base-formula": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/multipart": "10.0.0",
|
||||
"@fastify/static": "10.1.2",
|
||||
"@fastify/cookie": "11.1.2",
|
||||
"@fastify/multipart": "10.1.1",
|
||||
"@fastify/static": "10.1.3",
|
||||
"@keyv/redis": "5.1.6",
|
||||
"@langchain/core": "1.1.46",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"@nest-lab/throttler-storage-redis": "1.2.0",
|
||||
"@nestjs-labs/nestjs-ioredis": "11.0.4",
|
||||
"@nestjs/bullmq": "11.0.4",
|
||||
"@nestjs/bullmq": "11.0.5",
|
||||
"@nestjs/cache-manager": "3.1.3",
|
||||
"@nestjs/common": "11.1.28",
|
||||
"@nestjs/common": "11.2.1",
|
||||
"@nestjs/config": "4.0.4",
|
||||
"@nestjs/core": "11.1.27",
|
||||
"@nestjs/core": "11.2.1",
|
||||
"@nestjs/event-emitter": "3.1.0",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"@nestjs/passport": "11.0.5",
|
||||
"@nestjs/platform-fastify": "11.1.28",
|
||||
"@nestjs/platform-socket.io": "11.1.28",
|
||||
"@nestjs/platform-fastify": "11.2.1",
|
||||
"@nestjs/platform-socket.io": "11.2.1",
|
||||
"@nestjs/schedule": "6.1.3",
|
||||
"@nestjs/terminus": "11.1.1",
|
||||
"@nestjs/throttler": "6.5.0",
|
||||
"@nestjs/websockets": "11.1.28",
|
||||
"@nestjs/websockets": "11.2.1",
|
||||
"@node-saml/passport-saml": "5.1.0",
|
||||
"@socket.io/redis-adapter": "8.3.0",
|
||||
"@turbopuffer/turbopuffer": "^2.8.0",
|
||||
"ai": "6.0.134",
|
||||
"ai-sdk-ollama": "3.8.1",
|
||||
"bcrypt": "6.0.0",
|
||||
@@ -119,7 +120,7 @@
|
||||
"tmp-promise": "3.0.3",
|
||||
"typesense": "3.0.5",
|
||||
"undici": "7.29.0",
|
||||
"ws": "8.21.0",
|
||||
"ws": "8.21.3",
|
||||
"yauzl": "3.4.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
|
||||
@@ -22,11 +22,13 @@ import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis';
|
||||
import { parseRedisUrl } from './common/helpers';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -53,6 +55,7 @@ try {
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
EncryptionModule,
|
||||
RedisModule.forRootAsync({
|
||||
useClass: RedisConfigService,
|
||||
}),
|
||||
@@ -60,10 +63,20 @@ try {
|
||||
isGlobal: true,
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
const redisUrl = environmentService.getRedisUrl();
|
||||
const { family, tls } = parseRedisUrl(redisUrl);
|
||||
|
||||
return {
|
||||
ttl: 5 * 1000,
|
||||
stores: [new KeyvRedis(redisUrl)],
|
||||
stores: [
|
||||
new KeyvRedis({
|
||||
url: redisUrl,
|
||||
socket: {
|
||||
family,
|
||||
reconnectStrategy: defaultReconnectStrategy,
|
||||
...tls,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
|
||||
@@ -66,6 +66,7 @@ export class CollaborationGateway {
|
||||
password: this.redisConfig.password,
|
||||
db: this.redisConfig.db,
|
||||
family: this.redisConfig.family,
|
||||
tls: this.redisConfig.tls,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
}),
|
||||
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
JSONContent,
|
||||
} from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
import { collapseBlankLines } from '../common/helpers';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||
@@ -146,7 +147,7 @@ export function htmlToJson(html: string) {
|
||||
}
|
||||
|
||||
export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './utils';
|
||||
export * from './text.utils';
|
||||
export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { collapseBlankLines } from './text.utils';
|
||||
|
||||
describe('collapseBlankLines', () => {
|
||||
it.each([
|
||||
['a\n\n\n\nb', 'a\n\nb'],
|
||||
['a\n\nb', 'a\n\nb'],
|
||||
['a\nb', 'a\nb'],
|
||||
['\n\n\n\na\n\n\n', '\n\na\n\n'],
|
||||
['no newlines', 'no newlines'],
|
||||
['', ''],
|
||||
])('collapses %j to %j', (input, expected) => {
|
||||
expect(collapseBlankLines(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function collapseBlankLines(text: string): string {
|
||||
return text.replace(/\n{2,}/g, '\n\n');
|
||||
}
|
||||
@@ -30,13 +30,14 @@ export type RedisConfig = {
|
||||
db: number;
|
||||
password?: string;
|
||||
family?: number;
|
||||
tls?: { rejectUnauthorized?: boolean };
|
||||
};
|
||||
|
||||
export function parseRedisUrl(redisUrl: string): RedisConfig {
|
||||
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6]
|
||||
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6][&rejectUnauthorized=false]
|
||||
const url = new URL(redisUrl);
|
||||
const { hostname, port, password, pathname, searchParams } = url;
|
||||
const portInt = parseInt(port, 10);
|
||||
const { hostname, port, password, pathname, protocol, searchParams } = url;
|
||||
const portInt = port ? parseInt(port, 10) : 6379;
|
||||
|
||||
let db: number = 0;
|
||||
// extract db value if present
|
||||
@@ -54,7 +55,14 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
|
||||
family = parseInt(familyParam, 10);
|
||||
}
|
||||
|
||||
return { host: hostname, port: portInt, password, db, family };
|
||||
const tls =
|
||||
protocol === 'rediss:'
|
||||
? searchParams.get('rejectUnauthorized') === 'false'
|
||||
? { rejectUnauthorized: false }
|
||||
: {}
|
||||
: undefined;
|
||||
|
||||
return { host: hostname, port: portInt, password: password || undefined, db, family, tls };
|
||||
}
|
||||
|
||||
export function createRetryStrategy() {
|
||||
|
||||
@@ -496,10 +496,21 @@ export class PageService {
|
||||
},
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageIds: pageIdsToMove,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
});
|
||||
await this.aiQueue.add(
|
||||
QueueJob.PAGE_MOVED_TO_SPACE,
|
||||
{
|
||||
pageIds: pageIdsToMove,
|
||||
spaceId,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
},
|
||||
{
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 2 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -339,15 +339,25 @@ export class SpaceMemberService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||
await this.validateLastAdmin(dto.spaceId);
|
||||
}
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await trx
|
||||
.selectFrom('spaces')
|
||||
.select('id')
|
||||
.where('id', '=', dto.spaceId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
await this.spaceMemberRepo.updateSpaceMember(
|
||||
{ role: dto.role },
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
);
|
||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||
await this.validateLastAdmin(dto.spaceId, trx);
|
||||
}
|
||||
|
||||
await this.spaceMemberRepo.updateSpaceMember(
|
||||
{ role: dto.role },
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
||||
@@ -368,10 +378,14 @@ export class SpaceMemberService {
|
||||
});
|
||||
}
|
||||
|
||||
async validateLastAdmin(spaceId: string): Promise<void> {
|
||||
async validateLastAdmin(
|
||||
spaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
|
||||
SpaceRole.ADMIN,
|
||||
spaceId,
|
||||
trx,
|
||||
);
|
||||
if (spaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
|
||||
export class SpaceEvent {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -22,12 +23,12 @@ export class SpaceListener {
|
||||
|
||||
@OnEvent(EventName.SPACE_DELETED)
|
||||
async handleSpaceDeleted(event: SpaceEvent) {
|
||||
const { spaceId } = event;
|
||||
const { spaceId, workspaceId } = event;
|
||||
if (this.isTypesense()) {
|
||||
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId, workspaceId });
|
||||
}
|
||||
|
||||
isTypesense(): boolean {
|
||||
|
||||
@@ -46,8 +46,10 @@ export class SpaceMemberRepo {
|
||||
updatableSpaceMember: UpdatableSpaceMember,
|
||||
spaceMemberId: string,
|
||||
spaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('spaceMembers')
|
||||
.set(updatableSpaceMember)
|
||||
.where('id', '=', spaceMemberId)
|
||||
@@ -92,8 +94,13 @@ export class SpaceMemberRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
|
||||
const { count } = await this.db
|
||||
async roleCountBySpaceId(
|
||||
role: string,
|
||||
spaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const { count } = await db
|
||||
.selectFrom('spaceMembers')
|
||||
.select((eb) => eb.fn.count('role').as('count'))
|
||||
.where('role', '=', role)
|
||||
|
||||
@@ -230,6 +230,7 @@ export class SpaceRepo {
|
||||
|
||||
this.eventEmitter.emit(EventName.SPACE_DELETED, {
|
||||
spaceId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,24 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateAiEmbeddingFingerprint(
|
||||
workspaceId: string,
|
||||
fingerprint: { driver: string; model: string; dimensions: number },
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
||||
|| jsonb_build_object('embedding', ${JSON.stringify(fingerprint)}::text::jsonb))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updateSharingSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: b38dc5ecd1...e13af0ce05
@@ -0,0 +1,13 @@
|
||||
export class UnableToInitialize extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Unable to initialize the encryption service: ${message}`);
|
||||
this.name = 'UnableToInitialize';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnableToDecrypt extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`Unable to decrypt the ciphertext: ${reason}`);
|
||||
this.name = 'UnableToDecrypt';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
||||
|
||||
const buildService = (appSecret: string | undefined) => {
|
||||
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
||||
return new EncryptionService(env);
|
||||
};
|
||||
|
||||
const decodeEnvelope = (encrypted: string) =>
|
||||
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
};
|
||||
|
||||
const encodeEnvelope = (envelope: {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EncryptionService,
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: { getAppSecret: () => APP_SECRET },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EncryptionService>(EncryptionService);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('compiles via Nest DI', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
||||
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
||||
expect(() => buildService('')).toThrow(UnableToInitialize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt + decrypt round-trip', () => {
|
||||
it('decrypts back to the original plaintext', () => {
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const encrypted = service.encrypt('');
|
||||
expect(service.decrypt(encrypted)).toBe('');
|
||||
});
|
||||
|
||||
it('handles unicode (multi-byte UTF-8)', () => {
|
||||
const plaintext = 'héllo 🔐 世界';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles long plaintext (>1 block)', () => {
|
||||
const plaintext = 'a'.repeat(10_000);
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
||||
const plaintext = 'same input';
|
||||
const a = service.encrypt(plaintext);
|
||||
const b = service.encrypt(plaintext);
|
||||
expect(a).not.toBe(b);
|
||||
expect(service.decrypt(a)).toBe(plaintext);
|
||||
expect(service.decrypt(b)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-key isolation', () => {
|
||||
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
||||
const other = buildService('totally-different-secret-value-9876543210');
|
||||
const encrypted = service.encrypt('secret');
|
||||
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tamper detection', () => {
|
||||
it('rejects modified ciphertext', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
||||
tamperedCipher[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
cipherText: tamperedCipher.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
||||
tamperedTag[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
authTag: tamperedTag.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedIV = Buffer.from(env.iv, 'base64');
|
||||
tamperedIV[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
iv: tamperedIV.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payloads', () => {
|
||||
it('rejects non-base64 garbage', () => {
|
||||
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
||||
UnableToDecrypt,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects base64 of non-JSON', () => {
|
||||
const garbage = Buffer.from('not json at all').toString('base64');
|
||||
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects JSON missing required fields', () => {
|
||||
const partial = encodeEnvelope({
|
||||
iv: Buffer.alloc(12).toString('base64'),
|
||||
authTag: Buffer.alloc(16).toString('base64'),
|
||||
} as never);
|
||||
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
iv: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
authTag: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelope format', () => {
|
||||
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
||||
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
||||
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// https://github.com/nhedger/nestjs-encryption - MIT
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_DOMAIN = 'docmost:encryption:v1';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
type AEADPayload<TFormat = string | Buffer> = {
|
||||
iv: TFormat;
|
||||
authTag: TFormat;
|
||||
cipherText: TFormat;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(environmentService: EnvironmentService) {
|
||||
const appSecret = environmentService.getAppSecret();
|
||||
if (!appSecret) {
|
||||
throw new UnableToInitialize('APP_SECRET is not set.');
|
||||
}
|
||||
this.key = createHash('sha256')
|
||||
.update(KEY_DOMAIN)
|
||||
.update(appSecret)
|
||||
.digest();
|
||||
}
|
||||
|
||||
public encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
||||
const cipherText = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const aead: AEADPayload<string> = {
|
||||
iv: iv.toString('base64'),
|
||||
authTag: authTag.toString('base64'),
|
||||
cipherText: cipherText.toString('base64'),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
||||
}
|
||||
|
||||
public decrypt(encrypted: string): string {
|
||||
try {
|
||||
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(cipherText),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (e: unknown) {
|
||||
throw new UnableToDecrypt((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
||||
const payload = Buffer.from(encodedPayload, 'base64');
|
||||
|
||||
let deserializedPkg: Record<string, unknown>;
|
||||
try {
|
||||
deserializedPkg = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
||||
}
|
||||
|
||||
for (const field of ['iv', 'authTag', 'cipherText']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
||||
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
||||
}
|
||||
}
|
||||
|
||||
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
||||
if (iv.length !== IV_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
||||
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cipherText = Buffer.from(
|
||||
deserializedPkg.cipherText as string,
|
||||
'base64',
|
||||
);
|
||||
|
||||
return { iv, authTag, cipherText };
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,31 @@ export class EnvironmentService {
|
||||
return val === 'true';
|
||||
}
|
||||
|
||||
getAiVectorDriver(): string {
|
||||
return this.configService
|
||||
.get<string>('AI_VECTOR_DRIVER', 'pgvector')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
getTurbopufferApiKey(): string {
|
||||
return this.configService.get<string>('TURBOPUFFER_API_KEY');
|
||||
}
|
||||
|
||||
getTurbopufferRegion(): string {
|
||||
return this.configService.get<string>('TURBOPUFFER_REGION');
|
||||
}
|
||||
|
||||
getTurbopufferBaseUrl(): string {
|
||||
return this.configService.get<string>('TURBOPUFFER_BASE_URL');
|
||||
}
|
||||
|
||||
getTurbopufferNamespacePrefix(): string {
|
||||
return this.configService.get<string>(
|
||||
'TURBOPUFFER_NAMESPACE_PREFIX',
|
||||
'docmost',
|
||||
);
|
||||
}
|
||||
|
||||
getOpenAiApiKey(): string {
|
||||
return this.configService.get<string>('OPENAI_API_KEY');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Matches,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
validateSync,
|
||||
@@ -108,6 +109,41 @@ export class EnvironmentVariables {
|
||||
@IsString()
|
||||
AI_DRIVER: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER)
|
||||
@IsIn(['pgvector', 'turbopuffer'])
|
||||
@IsString()
|
||||
AI_VECTOR_DRIVER: string;
|
||||
|
||||
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER === 'turbopuffer')
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
TURBOPUFFER_API_KEY: string;
|
||||
|
||||
@ValidateIf(
|
||||
(obj) =>
|
||||
obj.AI_VECTOR_DRIVER === 'turbopuffer' && !obj.TURBOPUFFER_BASE_URL,
|
||||
)
|
||||
@IsNotEmpty({
|
||||
message:
|
||||
'TURBOPUFFER_REGION is required when AI_VECTOR_DRIVER is turbopuffer, unless TURBOPUFFER_BASE_URL is set',
|
||||
})
|
||||
@IsString()
|
||||
TURBOPUFFER_REGION: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.TURBOPUFFER_BASE_URL != '' && obj.TURBOPUFFER_BASE_URL != null)
|
||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
||||
TURBOPUFFER_BASE_URL: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z0-9\-_.]{1,90}$/, {
|
||||
message:
|
||||
'TURBOPUFFER_NAMESPACE_PREFIX may only contain letters, digits, dot, dash, underscore (max 90 chars)',
|
||||
})
|
||||
TURBOPUFFER_NAMESPACE_PREFIX: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
AI_EMBEDDING_MODEL: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { Redis } from 'ioredis';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class RedisHealthIndicator {
|
||||
@@ -19,8 +20,10 @@ export class RedisHealthIndicator {
|
||||
const indicator = this.healthIndicatorService.check(key);
|
||||
|
||||
try {
|
||||
const redis = new Redis(this.environmentService.getRedisUrl(), {
|
||||
const redisUrl = this.environmentService.getRedisUrl();
|
||||
const redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 15,
|
||||
tls: parseRedisUrl(redisUrl).tls,
|
||||
});
|
||||
|
||||
await redis.ping();
|
||||
|
||||
@@ -61,6 +61,7 @@ export enum QueueJob {
|
||||
WORKSPACE_DELETED = 'workspace-deleted',
|
||||
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
|
||||
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
|
||||
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
|
||||
|
||||
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
tls: redisConfig.tls,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
defaultJobOptions: {
|
||||
|
||||
@@ -19,6 +19,7 @@ export class RedisConfigService implements RedisOptionsFactory {
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
tls: redisConfig.tls,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,6 +49,10 @@ export class StaticModule implements OnModuleInit {
|
||||
: undefined,
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
AI_VECTOR_DRIVER:
|
||||
this.environmentService.getAiVectorDriver() === 'turbopuffer'
|
||||
? 'turbopuffer'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||
|
||||
@@ -17,6 +17,7 @@ export class WsRedisIoAdapter extends IoAdapter {
|
||||
|
||||
const options: RedisOptions = {
|
||||
family: this.redisConfig.family,
|
||||
tls: this.redisConfig.tls,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
};
|
||||
|
||||
|
||||
Generated
+200
-285
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -6,12 +6,12 @@ patchedDependencies:
|
||||
overrides:
|
||||
prosemirror-changeset: 2.4.0
|
||||
glob: 13.0.6
|
||||
ws: 8.21.0
|
||||
ws: 8.21.3
|
||||
dompurify: 3.4.13
|
||||
mermaid: 11.16.1
|
||||
undici: 7.29.0
|
||||
tmp: 0.2.7
|
||||
nanoid@^3: 3.3.17
|
||||
nanoid@^3: 3.3.18
|
||||
lodash-es: 4.18.1
|
||||
express-rate-limit: 8.2.2
|
||||
flatted: 3.4.2
|
||||
|
||||
Reference in New Issue
Block a user