Merge branch 'main' into feat/content

This commit is contained in:
Philipinho
2026-02-11 18:49:21 -08:00
222 changed files with 12036 additions and 6843 deletions
+30 -24
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.24.1",
"version": "0.25.3",
"description": "",
"author": "",
"private": true,
@@ -33,31 +33,31 @@
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11",
"@nestjs/core": "^11.1.13",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.13",
"@nestjs/platform-socket.io": "^11.1.13",
"@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11",
"@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
@@ -74,11 +74,15 @@
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0",
"lib0": "^0.2.117",
"mammoth": "^1.11.0",
"mime-types": "^2.1.35",
"msgpackr": "^1.11.8",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.1",
@@ -86,9 +90,11 @@
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.5",
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
@@ -98,39 +104,39 @@
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0",
"ws": "^8.19.0",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.4",
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
"@types/jest": "^30.0.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.11",
"@types/supertest": "^6.0.2",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
"eslint": "^9.20.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"react-email": "5.2.8",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
+2
View File
@@ -18,6 +18,7 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = [];
try {
@@ -35,6 +36,7 @@ try {
@Module({
imports: [
LoggerModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -30,14 +30,22 @@ export class CollabWsAdapter {
return this.wss;
}
public destroy() {
public close() {
try {
this.wss.clients.forEach((client) => {
client.terminate();
});
this.wss.close();
} catch (err) {
console.error(err);
}
}
public destroy() {
try {
this.wss.close();
this.wss.clients.forEach((client) => {
client.terminate();
});
} catch (err) {
console.error(err);
}
}
}
@@ -1,10 +1,9 @@
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
import { Hocuspocus } from '@hocuspocus/server';
import { IncomingMessage } from 'http';
import WebSocket from 'ws';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { Injectable } from '@nestjs/common';
import { Redis } from '@hocuspocus/extension-redis';
import { EnvironmentService } from '../integrations/environment/environment.service';
import {
createRetryStrategy,
@@ -12,19 +11,39 @@ import {
RedisConfig,
} from '../common/helpers';
import { LoggerExtension } from './extensions/logger.extension';
import {
RedisSyncExtension,
SerializedHTTPRequest,
} from './extensions/redis-sync';
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
import RedisClient from 'ioredis';
import { pack, unpack } from 'msgpackr';
import { nanoid } from 'nanoid';
import * as os from 'node:os';
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import {
CollaborationHandler,
CollabEventHandlers,
} from './collaboration.handler';
@Injectable()
export class CollaborationGateway {
private hocuspocus: Hocuspocus;
private readonly hocuspocus: Hocuspocus;
private redisConfig: RedisConfig;
// @ts-ignore
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
null;
private readonly withRedis: boolean;
constructor(
private authenticationExtension: AuthenticationExtension,
private persistenceExtension: PersistenceExtension,
private loggerExtension: LoggerExtension,
private environmentService: EnvironmentService,
private collabEventsService: CollaborationHandler,
) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.withRedis = !this.environmentService.isCollabDisableRedis();
this.hocuspocus = new Hocuspocus({
debounce: 10000,
@@ -34,26 +53,80 @@ export class CollaborationGateway {
this.authenticationExtension,
this.persistenceExtension,
this.loggerExtension,
...(this.environmentService.isCollabDisableRedis()
? []
: [
new Redis({
host: this.redisConfig.host,
port: this.redisConfig.port,
options: {
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
},
}),
]),
],
});
if (this.withRedis) {
// @ts-ignore
this.redisSync = new RedisSyncExtension({
redis: new RedisClient({
host: this.redisConfig.host,
port: this.redisConfig.port,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
}),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
prefix: 'collab',
pack,
unpack,
// @ts-ignore
customEvents: this.collabEventsService.getHandlers(this.hocuspocus),
});
this.hocuspocus.configuration.extensions.push(this.redisSync);
// @ts-ignore
this.redisSync.onConfigure({ instance: this.hocuspocus });
}
}
private serializeRequest(request: IncomingMessage): SerializedHTTPRequest {
return {
method: request.method ?? 'GET',
url: request.url ?? '/',
headers: {
'sec-websocket-key': request.headers['sec-websocket-key'] ?? '',
'sec-websocket-protocol':
request.headers['sec-websocket-protocol'] ?? '',
},
socket: { remoteAddress: request.socket?.remoteAddress ?? '' },
};
}
handleConnection(client: WebSocket, request: IncomingMessage): any {
this.hocuspocus.handleConnection(client, request);
if (this.redisSync) {
const serializedHTTPRequest = this.serializeRequest(request);
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
// Create wrapper socket that only receives events via emit()
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
const wrappedSocket = new WsSocketWrapper(client);
// Route through RedisSync extension (this calls handleConnection internally)
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
// Forward raw WebSocket messages to the extension
client.on('message', (data: ArrayBuffer) => {
this.redisSync!.onSocketMessage(
wrappedSocket as any,
serializedHTTPRequest,
data,
);
});
// Forward close events
client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason);
});
// Forward pong events for keepalive
client.on('pong', (data: Buffer) => {
wrappedSocket.emit('pong', data);
});
} else {
// Fallback to direct Hocuspocus connection
this.hocuspocus.handleConnection(client, request);
}
}
getConnectionCount() {
@@ -64,11 +137,52 @@ export class CollaborationGateway {
return this.hocuspocus.getDocumentsCount();
}
handleYjsEvent<TName extends keyof CollabEventHandlers>(
eventName: TName,
documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1],
) {
return this.redisSync?.handleEvent(eventName, documentName, payload);
}
openDirectConnection(documentName: string, context?: any) {
return this.hocuspocus.openDirectConnection(documentName, context);
}
async destroy(): Promise<void> {
//await this.hocuspocus.destroy();
/*
*Can be used before calling openDirectConnection directly
*/
async lockDocument(documentName: string) {
return this.redisSync.lockDocument(documentName);
}
/*
*Releases a document lock and stops the interval that maintains it.
*/
async releaseLock(documentName: string) {
return this.redisSync.releaseLock(documentName);
}
async destroy(collabWsAdapter: CollabWsAdapter): Promise<void> {
// eslint-disable-next-line no-async-promise-executor
await new Promise(async (resolve) => {
try {
// Wait for all documents to unload
this.hocuspocus.configuration.extensions.push({
async afterUnloadDocument({ instance }) {
if (instance.getDocumentsCount() === 0) resolve('');
},
});
collabWsAdapter?.close();
if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
this.hocuspocus.closeConnections();
} catch (error) {
console.error(error);
}
});
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus });
}
}
@@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor() {}
getHandlers(hocuspocus: Hocuspocus) {
return {
alterState: async (documentName: string, payload: { pageId: string }) => {
// dummy
// this.logger.log('Processing', documentName, payload);
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
// const fragment = doc.getXmlFragment('default');
//});
},
};
}
async withYdocConnection(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
fn: (doc: Document) => void,
): Promise<void> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
await connection.transact(fn);
} finally {
await connection.disconnect();
}
}
}
@@ -7,8 +7,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener';
import { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
@Module({
providers: [
@@ -16,7 +18,9 @@ import { LoggerExtension } from './extensions/logger.extension';
AuthenticationExtension,
PersistenceExtension,
LoggerExtension,
HistoryListener,
HistoryProcessor,
CollabHistoryService,
CollaborationHandler,
],
exports: [CollaborationGateway],
imports: [TokenModule],
@@ -46,16 +50,12 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
});
wss.on('error', (error) =>
this.logger.log('WebSocket server error:', error),
this.logger.error('WebSocket server error:', error),
);
}
async onModuleDestroy(): Promise<void> {
if (this.collaborationGateway) {
await this.collaborationGateway.destroy();
}
if (this.collabWsAdapter) {
this.collabWsAdapter.destroy();
}
await this.collaborationGateway?.destroy(this.collabWsAdapter);
this.collabWsAdapter?.destroy();
}
}
@@ -34,6 +34,7 @@ import {
Highlight,
UniqueID,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -41,9 +42,9 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { turndown } from '../integrations/export/turndown-utils';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
StarterKit.configure({
@@ -112,13 +113,57 @@ export function jsonToText(tiptapJson: JSONContent) {
}
export function jsonToNode(tiptapJson: JSONContent) {
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
const schema = getSchema(tiptapExtensions);
try {
return Node.fromJSON(schema, tiptapJson);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes('Unknown node type')
) {
Logger.warn('Stripping unknown node types from document:', error.message);
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
return Node.fromJSON(schema, cleanedJson);
}
throw error;
}
}
export function getPageId(documentName: string) {
return documentName.split('.')[1];
}
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
): JSONContent | null {
if (!json || typeof json !== 'object') return json;
// Recursively clean children first, flattening any unwrapped content
if (json.content && Array.isArray(json.content)) {
const newContent: JSONContent[] = [];
for (const child of json.content) {
const cleaned = stripUnknownNodes(child, schema);
if (Array.isArray(cleaned)) {
newContent.push(...cleaned);
} else if (cleaned) {
newContent.push(cleaned);
}
}
json.content = newContent;
}
// Check if this node is unknown AFTER processing children
if (json.type && !schema.nodes[json.type]) {
// Unwrap: return cleaned children directly instead of wrapping
return (
json.content && json.content.length > 0 ? json.content : null
) as any;
}
return json;
}
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
if (node.type === 'text') {
const ytext = new Y.XmlText();
@@ -150,5 +195,5 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
export function jsonToMarkdown(tiptapJson: any): string {
const html = jsonToHtml(tiptapJson);
return turndown(html);
return htmlToMarkdown(html);
}
@@ -0,0 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
export class LoggerExtension implements Extension {
private readonly logger = new Logger('Collab' + LoggerExtension.name);
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug(`User disconnected from "${data.documentName}".`);
}
async afterUnloadDocument(data: onLoadDocumentPayload) {
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
}
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug('User disconnected from ' + data.documentName);
}
}
@@ -13,7 +13,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
@@ -22,8 +21,17 @@ import {
extractPageMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
import {
IPageBacklinkJob,
IPageHistoryJob,
} from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
@Injectable()
export class PersistenceExtension implements Extension {
@@ -33,9 +41,10 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@@ -101,6 +110,7 @@ export class PersistenceExtension implements Extension {
}
let page: Page = null;
const editingUserIds = this.consumeContributors(documentName);
try {
await executeTx(this.db, async (trx) => {
@@ -123,13 +133,9 @@ export class PersistenceExtension implements Extension {
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from(
new Set([...existingContributors, ...newContributors]),
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
);
this.contributors.delete(documentName);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
@@ -153,13 +159,7 @@ export class PersistenceExtension implements Extension {
}
if (page) {
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
@@ -174,6 +174,8 @@ export class PersistenceExtension implements Extension {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
await this.enqueuePageHistory(page);
}
}
@@ -193,4 +195,26 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName;
this.contributors.delete(documentName);
}
private consumeContributors(documentName: string): string[] {
const contributorSet = this.contributors.get(documentName);
if (!contributorSet) return [];
const userIds = [...contributorSet];
this.contributors.delete(documentName);
return userIds;
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =
pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id } as IPageHistoryJob,
{ jobId: page.id, delay },
);
}
}
@@ -0,0 +1,70 @@
import type RedisClient from 'ioredis';
import { EventEmitter } from 'tseep';
import type {
Pack,
RSAMessageClose,
RSAMessagePing,
RSAMessageSend,
} from './redis-sync.types';
export class CollabProxySocket extends EventEmitter {
private readonly replyTo: string;
private readonly serverChannel: string;
private readonly socketId: string;
private pub: RedisClient;
private readonly pack: Pack;
readyState = 1;
constructor(
pub: RedisClient,
pack: Pack,
replyTo: string,
serverChannel: string,
socketId: string,
) {
super();
this.replyTo = replyTo;
this.socketId = socketId;
this.serverChannel = serverChannel;
this.pub = pub;
this.pack = pack;
this.once('close', () => {
this.readyState = 3;
});
}
private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
this.pub.publish(this.replyTo, this.pack(msg));
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId: this.socketId,
};
this.publish(msg);
}
ping() {
if (this.readyState !== 1) return;
const msg: RSAMessagePing = {
type: 'ping',
socketId: this.socketId,
replyTo: this.serverChannel,
};
this.publish(msg);
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
const msg: RSAMessageSend = {
type: 'send',
socketId: this.socketId,
message,
};
this.publish(msg);
}
}
@@ -0,0 +1,2 @@
export * from './redis-sync.extension';
export type { SerializedHTTPRequest } from './redis-sync.extension';
@@ -0,0 +1,378 @@
// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT
import {
Extension,
Hocuspocus,
IncomingMessage,
afterUnloadDocumentPayload,
onConfigurePayload,
onLoadDocumentPayload,
} from '@hocuspocus/server';
import RedisClient from 'ioredis';
import { readVarString } from 'lib0/decoding.js';
import { CollabProxySocket } from './collab-proxy-socket';
import {
BaseWebSocket,
Configuration,
CustomEvents,
Pack,
RSAMessage,
RSAMessageCloseProxy,
RSAMessageCustomEventComplete,
RSAMessageCustomEventStart,
RSAMessagePong,
RSAMessageProxy,
RSAMessageUnload,
SerializedHTTPRequest,
Unpack,
} from './redis-sync.types';
export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
type ServerId = string;
type DocumentName = string;
type SocketId = string;
export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
priority = 1000;
private readonly pub: RedisClient;
private sub: RedisClient;
private readonly pack: Pack;
private readonly unpack: Unpack;
private originSockets: Record<SocketId, BaseWebSocket> = {};
private locks: Record<DocumentName, NodeJS.Timeout> = {};
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
private proxySockets: Record<SocketId, CollabProxySocket> = {};
private readonly prefix: string;
private readonly lockPrefix: string;
private readonly msgChannel: string;
private readonly serverId: ServerId;
private readonly customEventTTL: number;
private readonly lockTTL: number;
private instance!: Hocuspocus;
private readonly customEvents: TCE;
private replyIdCounter: number = 0;
// @ts-ignore
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
{};
constructor(configuration: Configuration<TCE>) {
const {
redis,
pack,
unpack,
serverId,
lockTTL,
prefix,
customEvents,
customEventTTL,
} = configuration;
this.pub = redis.duplicate();
this.sub = redis.duplicate();
this.pack = pack;
this.unpack = unpack;
this.serverId = serverId;
this.lockTTL = lockTTL ?? 10_000;
this.customEventTTL = customEventTTL ?? 30_000;
this.prefix = prefix ?? 'collab';
this.lockPrefix = `${this.prefix}Lock`;
this.msgChannel = `${this.prefix}Msg`;
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
this.sub.on('messageBuffer', this.handleRedisMessage);
this.pub.on('error', () => {});
this.sub.on('error', () => {});
}
private getKey(documentName: string) {
return `${this.lockPrefix}:${documentName}`;
}
private closeProxy(socketId: string) {
const proxySocket = this.proxySockets[socketId];
if (proxySocket) {
proxySocket.emit(
'close',
1000,
Buffer.from('provider_initiated', 'utf-8'),
);
delete this.proxySockets[socketId];
}
}
private pongProxy(socketId: string) {
this.proxySockets[socketId]?.emit('pong');
}
private handleProxyMessage(
msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
) {
const { replyTo, message, serializedHTTPRequest } = msg;
const { headers } = serializedHTTPRequest;
const socketId = headers['sec-websocket-key']!;
let socket = this.proxySockets[socketId];
if (!socket) {
socket = new CollabProxySocket(
this.pub,
this.pack,
replyTo,
`${this.msgChannel}:${this.serverId}`,
socketId,
);
this.proxySockets[socketId] = socket;
this.instance.handleConnection(
socket as any,
serializedHTTPRequest as any,
{},
);
}
socket.emit('message', message);
}
private getOrClaimLock(documentName: string) {
const lockPromise = this.pub.set(
this.getKey(documentName),
this.serverId,
'PX',
this.lockTTL,
'NX',
'GET',
);
this.lockPromises[documentName] = lockPromise;
// Briefly cache the serverId that claimed the doc to reduce load on redis
// When the claimant unloads the doc, it will send an unload message to immediately clear this
// a lockTTL / 2 guarantees stale reads < lockTTL upon server crash
setTimeout(() => {
delete this.lockPromises[documentName];
}, this.lockTTL / 2);
return lockPromise;
}
private getOrClaimLockThrottled(documentName: string) {
const existingWorkerIdPromise = this.lockPromises[documentName];
if (existingWorkerIdPromise) return existingWorkerIdPromise;
return this.getOrClaimLock(documentName);
}
private handleRedisMessage = async (
_channel: Buffer,
packedMessage: Buffer,
) => {
const msg = this.unpack(packedMessage) as RSAMessage;
const { type } = msg;
if (type === 'proxy') {
this.handleProxyMessage(msg);
return;
}
if (type === 'closeProxy') {
this.closeProxy(msg.socketId);
return;
}
if (type === 'pong') {
this.pongProxy(msg.socketId);
return;
}
if (type === 'unload') {
delete this.lockPromises[msg.documentName];
return;
}
if (type === 'customEventStart') {
const { documentName, eventName, payload, replyTo, replyId } = msg;
const res = await this.handleEventLocally(
eventName as Extract<keyof TCE, string>,
documentName,
payload,
);
const reply: RSAMessageCustomEventComplete = {
type: 'customEventComplete',
replyId,
payload: res,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
return;
}
if (type === 'customEventComplete') {
const { replyId, payload } = msg;
const resolveFn = this.pendingReplies[replyId];
if (!resolveFn) return;
delete this.pendingReplies[replyId];
resolveFn(payload);
return;
}
const { socketId } = msg;
const socket = this.originSockets[socketId];
if (!socket) {
// origin socket already cleaned up
return;
}
if (type === 'close') {
socket.close(msg.code, msg.reason);
} else if (type === 'ping') {
// Reply instantly to the proxy socket, without forwarding to client
// The origin socket handles heartbeat for itself
const { replyTo, socketId } = msg;
const reply: RSAMessagePong = {
type: 'pong',
socketId,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
} else if (type === 'send') {
socket.send(msg.message);
}
};
async maintainLock(documentName: string) {
this.locks[documentName] = setInterval(() => {
this.pub.set(
this.getKey(documentName),
this.serverId,
'PX',
this.lockTTL,
);
}, this.lockTTL / 2);
}
async releaseLock(documentName: string) {
clearInterval(this.locks[documentName]);
delete this.locks[documentName];
return this.pub.del(this.getKey(documentName));
}
private async handleEventLocally<TName extends Extract<keyof TCE, string>>(
eventName: TName,
documentName: string,
payload: any,
) {
const handler = this.customEvents[eventName];
if (!handler) throw new Error(`Invalid eventName: ${eventName}`);
const result = await handler(documentName, payload);
return result as Promise<ReturnType<TCE[TName]>>;
}
async handleEvent<TName extends Extract<keyof TCE, string>>(
eventName: TName,
documentName: string,
payload: any,
) {
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
return this.handleEventLocally(eventName, documentName, payload);
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below
const replyId = this.replyIdCounter;
// another server owns the doc
const proxyMessage: RSAMessageCustomEventStart = {
eventName,
documentName,
payload,
replyTo: `${this.msgChannel}:${this.serverId}`,
replyId,
type: 'customEventStart',
};
const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
// @ts-ignore
const { promise, resolve, reject } = Promise.withResolvers();
this.pendingReplies[replyId] = resolve;
setTimeout(() => {
reject('TIMEOUT');
}, this.customEventTTL);
return promise as Promise<ReturnType<TCE[TName]>>;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
return this.handleEventLocally(eventName, documentName, payload);
}
async lockDocument(documentName: string) {
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
throw new Error(`Could not lock document: ${documentName}`);
}
this.maintainLock(documentName);
return () => this.releaseLock(documentName);
}
/* WebSocket Server Hooks */
onSocketOpen(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
context = {},
) {
const socketId = serializedHTTPRequest.headers['sec-websocket-key']!;
this.originSockets[socketId] = ws;
this.instance.handleConnection(
ws as any,
serializedHTTPRequest as any,
context,
);
}
async onSocketMessage(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
detachableMsg: ArrayBuffer,
) {
const message = new Uint8Array(detachableMsg.slice());
const tmpMsg = new IncomingMessage(detachableMsg);
const documentName = readVarString(tmpMsg.decoder);
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
ws.emit('message', message);
return;
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
// another server owns the doc
const proxyMessage: RSAMessageProxy = {
serializedHTTPRequest: serializedHTTPRequest,
replyTo: `${this.msgChannel}:${this.serverId}`,
message,
type: 'proxy',
};
const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
return;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
ws.emit('message', message);
}
onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) {
const socket = this.originSockets[socketId];
if (!socket) return;
// at this point the socket is considered GC'd and we cannot call close
// The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit
socket?.emit('close', code, reason);
delete this.originSockets[socketId];
const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId };
this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {});
}
/* Hocuspocus hooks */
async onConfigure({ instance }: onConfigurePayload) {
this.instance = instance;
}
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentName } = data;
// Refresh the lock TTL
this.maintainLock(documentName);
}
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
const { documentName } = data;
this.releaseLock(documentName);
// Broadcast to cluster to immediately remove the cached redis value
const msg: RSAMessageUnload = { type: 'unload', documentName };
this.pub.publish(this.msgChannel, this.pack(msg));
}
async onDestroy() {
this.pub.disconnect(false);
this.sub.disconnect(false);
}
}
@@ -0,0 +1,121 @@
import EventEmitter from 'node:events';
import { IncomingHttpHeaders } from 'node:http2';
import RedisClient from 'ioredis';
export type SecondParam<T> = T extends (
arg1: unknown,
arg2: infer A,
...args: unknown[]
) => unknown
? A
: never;
export type SerializedHTTPRequest = {
method: string;
url: string;
headers: IncomingHttpHeaders;
socket: { remoteAddress: string };
};
export type RSAMessageProxy = {
type: 'proxy';
replyTo: string;
message: Uint8Array<ArrayBufferLike>;
serializedHTTPRequest: SerializedHTTPRequest;
};
export type RSAMessageCloseProxy = {
type: 'closeProxy';
socketId: string;
};
export type RSAMessageUnload = {
type: 'unload';
documentName: string;
};
export type RSAMessageClose = {
type: 'close';
code?: number;
reason?: string;
socketId: string;
};
export type RSAMessagePing = {
type: 'ping';
socketId: string;
replyTo: string;
};
export type RSAMessagePong = {
type: 'pong';
socketId: string;
};
export type RSAMessageSend = {
type: 'send';
// @ts-ignore
message: Uint8Array<ArrayBufferLike>;
socketId: string;
};
export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
type: 'customEventStart';
documentName: string;
eventName: TName;
payload: TPayload;
replyTo: string;
replyId: number;
};
export type RSAMessageCustomEventComplete = {
type: 'customEventComplete';
replyId: number;
payload: unknown;
};
export type RSAMessage =
| RSAMessageProxy
| RSAMessageCloseProxy
| RSAMessageUnload
| RSAMessageClose
| RSAMessagePing
| RSAMessagePong
| RSAMessageSend
| RSAMessageCustomEventStart
| RSAMessageCustomEventComplete;
// @ts-ignore
export type Pack = (msg: RSAMessage) => string | Buffer<ArrayBufferLike>;
export type Unpack = (
// @ts-ignore
packedMessage: Uint8Array | Buffer<ArrayBufferLike>,
) => RSAMessage;
type ServerId = string;
type DocumentName = string;
type CustomEventName = string;
export type CustomEvents = Record<
CustomEventName,
(documentName: string, payload: unknown) => Promise<unknown>
>;
export interface Configuration<TCE> {
redis: RedisClient;
pack: Pack;
unpack: Unpack;
serverId: ServerId;
lockTTL?: number;
customEventTTL?: number;
prefix?: string;
customEvents?: TCE;
}
export type BaseWebSocket = EventEmitter & {
readyState: number;
close(code?: number, reason?: string): void;
ping(): void;
send(message: Uint8Array): void;
};
@@ -0,0 +1,47 @@
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
/**
* Wrapper around ws WebSocket that only receives events via emit().
* This prevents double-handling when used with RedisSyncExtension.
*/
export class WsSocketWrapper extends EventEmitter {
private ws: WebSocket;
readyState = 1;
constructor(ws: WebSocket) {
super();
this.ws = ws;
this.once('close', () => {
this.readyState = 3;
});
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
this.readyState = 3;
try {
this.ws.close(code, reason);
} catch (e) {
// Socket already closed
}
}
ping() {
if (this.readyState !== 1) return;
try {
this.ws.ping();
} catch (e) {
// Socket already closed
}
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
try {
this.ws.send(message);
} catch (e) {
// Socket already closed
}
}
}
@@ -1,44 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util';
export class UpdatedPageEvent {
page: Page;
}
@Injectable()
export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name);
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
@OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) {
const { page } = event;
const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) {
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
if (
!lastHistory ||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES)
) {
try {
await this.pageHistoryRepo.saveHistory(page);
this.logger.debug(`New history created for: ${page.id}`);
} catch (err) {
this.logger.error(`Failed to create history for page: ${page.id}`, err);
}
}
}
}
@@ -0,0 +1,84 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(HistoryProcessor.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
) {
super();
}
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
if (job.name !== QueueJob.PAGE_HISTORY) return;
try {
const { pageId } = job.data;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (!page) {
this.logger.warn(`Page ${pageId} not found, skipping history`);
await this.collabHistory.clearContributors(pageId);
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
try {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
throw err;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -8,15 +8,22 @@ import { QueueModule } from '../../integrations/queue/queue.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
@Module({
imports: [
LoggerModule,
DatabaseModule,
EnvironmentModule,
CollaborationModule,
QueueModule,
HealthModule,
EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
],
controllers: [
AppController,
@@ -5,22 +5,27 @@ import {
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
CollabAppModule,
new FastifyAdapter({
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
maxParamLength: 500,
routerOptions: {
maxParamLength: 1000,
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
},
}),
{
logger: new InternalLogFilter(),
logger: false,
bufferLogs: false,
},
);
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { exclude: ['/'] });
app.enableCors();
@@ -32,7 +37,8 @@ async function bootstrap() {
const logger = new Logger('CollabServer');
const port = process.env.COLLAB_PORT || 3001;
await app.listen(port, '0.0.0.0', () => {
const host = process.env.HOST || '0.0.0.0';
await app.listen(port, host, () => {
logger.log(`Listening on http://127.0.0.1:${port}`);
});
}
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const REDIS_KEY_PREFIX = 'history:contributors:';
@Injectable()
export class CollabHistoryService {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async addContributors(pageId: string, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
}
async popContributors(pageId: string): Promise<string[]> {
const key = REDIS_KEY_PREFIX + pageId;
const count = await this.redis.scard(key);
if (count === 0) return [];
return await this.redis.spop(key, count);
}
async clearContributors(pageId: string): Promise<void> {
await this.redis.del(REDIS_KEY_PREFIX + pageId);
}
}
@@ -0,0 +1,71 @@
// https://github.com/WebReflection/html-escaper
/**
* Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const { replace } = '';
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
const ca = /[&<>'"]/g;
const esca = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
};
const pe = (m) => esca[m];
/**
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
* @param {string} es the input to safely escape
* @returns {string} the escaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlEscape = (es) => replace.call(es, ca, pe);
// unescape
const unes = {
'&amp;': '&',
'&#38;': '&',
'&lt;': '<',
'&#60;': '<',
'&gt;': '>',
'&#62;': '>',
'&apos;': "'",
'&#39;': "'",
'&quot;': '"',
'&#34;': '"',
};
const cape = (m) => unes[m];
/**
* Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`,
* and `'`.
* @param {string} un a previously escaped string
* @returns {string} the unescaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlUnescape = (un) => replace.call(un, es, cape);
@@ -0,0 +1,16 @@
export type ExportPageMetadata = {
pageId: string;
slugId: string;
icon: string | null;
position: string;
parentPath: string | null;
createdAt: string;
updatedAt: string;
};
export type ExportMetadata = {
exportedAt: string;
source: 'docmost';
version: string;
pages: Record<string, ExportPageMetadata>;
};
+36
View File
@@ -2,6 +2,7 @@ import * as path from 'path';
import * as bcrypt from 'bcrypt';
import { sanitize } from 'sanitize-filename-ts';
import { FastifyRequest } from 'fastify';
import { Readable, Transform } from 'stream';
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
@@ -98,3 +99,38 @@ export function hasLicenseOrEE(opts: {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}
/**
* Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
* - Removes `schema` parameter (has no effect via connection string)
* Note: If we don't strip them, the connection will fail
*/
export function normalizePostgresUrl(url: string): string {
const parsed = new URL(url);
const newParams = new URLSearchParams();
for (const [key, value] of parsed.searchParams) {
if (key === 'sslmode' && value === 'no-verify') continue;
if (key === 'schema') continue;
newParams.append(key, value);
}
parsed.search = newParams.toString();
return parsed.toString();
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
transform(chunk, encoding, callback) {
bytesRead += chunk.length;
callback(null, chunk);
},
});
source.pipe(stream);
source.on('error', (err) => stream.emit('error', err));
return { stream, getBytesRead: () => bytesRead };
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
import { createPinoConfig } from './pino.config';
@Module({
imports: [PinoLoggerModule.forRoot(createPinoConfig())],
exports: [PinoLoggerModule],
})
export class LoggerModule {}
@@ -0,0 +1,84 @@
import { Params } from 'nestjs-pino';
import { stdTimeFunctions } from 'pino';
const CONTEXTS_TO_IGNORE = [
'InstanceLoader',
'RoutesResolver',
'RouterExplorer',
'LegacyRouteConverter',
'WebSocketsController',
];
export function createPinoConfig(): Params {
const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production';
const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true';
const logHttp = process.env.LOG_HTTP?.toLowerCase() === 'true';
const level = isProduction && !isDebugMode ? 'info' : 'debug';
return {
pinoHttp: {
level,
timestamp: stdTimeFunctions.isoTime,
transport: !isProduction
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
formatters: {
level: (label) => ({ level: label }),
},
hooks: {
logMethod(inputArgs, method) {
if (isProduction && !isDebugMode) {
for (const arg of inputArgs) {
if (typeof arg === 'object' && arg !== null && 'context' in arg) {
const context = (arg as Record<string, unknown>)['context'];
if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) {
return;
}
}
}
}
return method.apply(this, inputArgs);
},
},
serializers: {
req: (req) => {
const forwardedFor = req.headers?.['x-forwarded-for'];
const ip =
req.headers?.['cf-connecting-ip'] ||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
req.remoteAddress;
return {
method: req.method,
url: req.url,
ip,
userAgent: req.headers?.['user-agent'],
};
},
res: (res) => ({
statusCode: res.statusCode,
}),
},
customLogLevel: (_req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
autoLogging: logHttp
? {
ignore: (req) =>
req.url === '/api/health' || req.url === '/api/health/live',
}
: false,
},
};
}
@@ -17,13 +17,13 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
@@ -151,6 +151,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@@ -181,20 +182,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
@@ -203,6 +191,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@@ -241,20 +230,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
return await this.sendFileResponse(req, res, attachment, 'public');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
@@ -367,14 +343,14 @@ export class AttachmentController {
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
const fileStream = await this.storageService.readStream(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
// this.logger.error(err);
// this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@@ -429,4 +405,70 @@ export class AttachmentController {
return;
}
}
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
attachment: Attachment,
cacheScope: 'private' | 'public',
) {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (rangeHeader && fileSize) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2]
? Math.min(parseInt(match[2], 10), fileSize - 1)
: fileSize - 1;
if (start >= fileSize || start > end) {
res.status(416);
res.header('Content-Range', `bytes */${fileSize}`);
return res.send();
}
const fileStream = await this.storageService.readRangeStream(
attachment.filePath,
{ start, end },
);
res.status(206);
res.headers({
'Content-Type': attachment.mimeType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': end - start + 1,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
return res.send(fileStream);
}
}
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
const isSvg = attachment.fileExt === '.svg';
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize);
}
return res.send(fileStream);
}
}
@@ -5,15 +5,17 @@ import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
export interface PreparedFile {
buffer: Buffer;
buffer?: Buffer;
fileName: string;
fileSize: number;
fileExtension: string;
mimeType: string;
multiPartFile?: MultipartFile;
}
export async function prepareFile(
filePromise: Promise<MultipartFile>,
options: { skipBuffer?: boolean } = {},
): Promise<PreparedFile> {
const file = await filePromise;
@@ -22,10 +24,16 @@ export async function prepareFile(
}
try {
const buffer = await file.toBuffer();
let buffer: Buffer | undefined;
let fileSize = 0;
if (!options.skipBuffer) {
buffer = await file.toBuffer();
fileSize = buffer.length;
}
const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase();
return {
@@ -34,6 +42,7 @@ export async function prepareFile(
fileSize,
fileExtension,
mimeType: file.mimetype,
multiPartFile: file,
};
} catch (error) {
throw error;
@@ -4,6 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
@@ -26,6 +27,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { createByteCountingStream } from '../../../common/helpers/utils';
@Injectable()
export class AttachmentService {
@@ -49,7 +51,9 @@ export class AttachmentService {
attachmentId?: string;
}) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise);
const preparedFile: PreparedFile = await prepareFile(filePromise, {
skipBuffer: true,
});
let isUpdate = false;
let attachmentId = null;
@@ -81,13 +85,21 @@ export class AttachmentService {
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
await this.uploadToDrive(filePath, preparedFile.buffer);
const { stream, getBytesRead } = createByteCountingStream(
preparedFile.multiPartFile.file,
);
await this.uploadToDrive(filePath, stream);
// Update fileSize from the consumed stream
preparedFile.fileSize = getBytesRead();
let attachment: Attachment = null;
try {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
fileSize: preparedFile.fileSize,
updatedAt: new Date(),
},
attachmentId,
@@ -142,7 +154,10 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
const processedBuffer = await compressAndResizeIcon(
preparedFile.buffer,
type,
);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
@@ -232,9 +247,9 @@ export class AttachmentService {
}
}
async uploadToDrive(filePath: string, fileBuffer: any) {
async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
try {
await this.storageService.upload(filePath, fileBuffer);
await this.storageService.upload(filePath, fileContent);
} catch (err) {
this.logger.error('Error uploading file to drive:', err);
throw new BadRequestException('Error uploading file to drive');
@@ -9,16 +9,14 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class CommentService {
constructor(
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async findById(commentId: string) {
@@ -68,14 +66,14 @@ export class CommentService {
async findByPageId(
pageId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Comment>> {
): Promise<CursorPaginationResult<Comment>> {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new BadRequestException('Page not found');
}
return await this.commentRepo.findPageComments(pageId, pagination);
return this.commentRepo.findPageComments(pageId, pagination);
}
async update(
@@ -11,7 +11,7 @@ import { UpdateGroupDto } from '../dto/update-group.dto';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { GroupUserService } from './group-user.service';
@Injectable()
@@ -132,12 +132,8 @@ export class GroupService {
async getWorkspaceGroups(
workspaceId: string,
paginationOptions: PaginationOptions,
): Promise<PaginationResult<Group>> {
const groups = await this.groupRepo.getGroupsPaginated(
workspaceId,
paginationOptions,
);
return groups;
): Promise<CursorPaginationResult<Group>> {
return this.groupRepo.getGroupsPaginated(workspaceId, paginationOptions);
}
async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
@@ -230,7 +230,6 @@ export class PageController {
}
}
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(
@@ -2,25 +2,25 @@ import { Injectable } from '@nestjs/common';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageHistory } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId);
return await this.pageHistoryRepo.findById(historyId, {
includeContent: true,
});
}
async findHistoryByPageId(
pageId: string,
paginationOptions: PaginationOptions,
): Promise<PaginationResult<any>> {
const pageHistory = await this.pageHistoryRepo.findPageHistoryByPageId(
): Promise<CursorPaginationResult<PageHistory>> {
return this.pageHistoryRepo.findPageHistoryByPageId(
pageId,
paginationOptions,
);
return pageHistory;
}
}
@@ -10,9 +10,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
executeWithPagination,
PaginationResult,
} from '@docmost/db/pagination/pagination';
CursorPaginationResult,
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@@ -305,7 +305,7 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
): Promise<any> {
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
let query = this.db
.selectFrom('pages')
.select([
@@ -320,7 +320,6 @@ export class PageService {
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', (ob) => ob.collate('C').asc())
.where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId);
@@ -330,12 +329,19 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: 250,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
position: cursor.position,
id: cursor.id,
}),
});
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string) {
@@ -699,22 +705,22 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPages(userId, pagination);
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPages(userId, pagination);
}
async getDeletedSpacePages(
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
}
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
@@ -26,11 +26,11 @@ export class SearchService {
userId?: string;
workspaceId: string;
},
): Promise<SearchResponseDto[]> {
): Promise<{ items: SearchResponseDto[] }> {
const { query } = searchParams;
if (query.length < 1) {
return;
return { items: [] };
}
const searchQuery = tsquery(query.trim() + '*');
@@ -62,7 +62,7 @@ export class SearchService {
)
.where('deletedAt', 'is', null)
.orderBy('rank', 'desc')
.limit(searchParams.limit | 25)
.limit(searchParams.limit || 25)
.offset(searchParams.offset || 0);
if (!searchParams.shareId) {
@@ -86,7 +86,7 @@ export class SearchService {
const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return [];
return { items: [] };
}
const pageIdsToSearch = [];
@@ -108,10 +108,10 @@ export class SearchService {
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
return { items: [] };
}
} else {
return [];
return { items: [] };
}
//@ts-ignore
@@ -127,7 +127,7 @@ export class SearchService {
return result;
});
return searchResults;
return { items: searchResults };
}
async searchSuggestions(
@@ -7,6 +7,7 @@ import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { htmlEscape } from '../../common/helpers/html-escaper';
@Controller('share')
export class ShareSeoController {
@@ -68,7 +69,7 @@ export class ShareSeoController {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = share.sharedPage.title ?? 'untitled';
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
+41 -2
View File
@@ -64,8 +64,18 @@ export class ShareController {
throw new BadRequestException();
}
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
shareData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Shared page not found');
}
return {
...(await this.shareService.getSharedPage(dto, workspace.id)),
...shareData,
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
@@ -86,6 +96,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
share.workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return share;
}
@@ -127,6 +145,14 @@ export class ShareController {
throw new ForbiddenException();
}
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
page.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({
page,
authUserId: user.id,
@@ -176,8 +202,21 @@ export class ShareController {
@Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace,
) {
const treeData = await this.shareService.getShareTree(
dto.shareId,
workspace.id,
);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
treeData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return {
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
...treeData,
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
@@ -264,6 +264,31 @@ export class ShareService {
return ancestor;
}
async isSharingAllowed(
workspaceId: string,
spaceId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('workspaces')
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
.select([
'workspaces.settings as workspaceSettings',
'spaces.settings as spaceSettings',
])
.where('workspaces.id', '=', workspaceId)
.where('spaces.id', '=', spaceId)
.executeTakeFirst();
if (!result) return false;
const workspaceDisabled =
(result.workspaceSettings as any)?.sharing?.disabled === true;
const spaceDisabled =
(result.spaceSettings as any)?.sharing?.disabled === true;
return !workspaceDisabled && !spaceDisabled;
}
async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
@@ -1,10 +1,14 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString()
@IsNotEmpty()
@IsUUID()
spaceId: string;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
}
@@ -13,7 +13,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class SpaceMemberService {
@@ -68,18 +68,16 @@ export class SpaceMemberService {
spaceId: string,
workspaceId: string,
pagination: PaginationOptions,
) {
): Promise<CursorPaginationResult<any>> {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
const members = await this.spaceMemberRepo.getSpaceMembersPaginated(
return await this.spaceMemberRepo.getSpaceMembersPaginated(
spaceId,
pagination,
);
return members;
}
async addMembersToSpaceBatch(
@@ -276,7 +274,7 @@ export class SpaceMemberService {
async getUserSpaces(
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
): Promise<CursorPaginationResult<Space>> {
return this.spaceMemberRepo.getUserSpaces(userId, pagination);
}
}
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -8,7 +9,6 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space, User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
@@ -17,12 +17,19 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
@Injectable()
export class SpaceService {
constructor(
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
private shareRepo: ShareRepo,
private workspaceRepo: WorkspaceRepo,
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
@@ -105,6 +112,31 @@ export class SpaceService {
}
}
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
}
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -130,13 +162,8 @@ export class SpaceService {
async getWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
const spaces = await this.spaceRepo.getSpacesInWorkspace(
workspaceId,
pagination,
);
return spaces;
): Promise<CursorPaginationResult<Space>> {
return this.spaceRepo.getSpacesInWorkspace(workspaceId, pagination);
}
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
@@ -30,4 +30,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
}
@@ -23,7 +23,7 @@ import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-ac
import { TokenService } from '../../auth/services/token.service';
import { nanoIdGen } from '../../../common/helpers';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { DomainService } from 'src/integrations/environment/domain.service';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -64,12 +64,13 @@ export class WorkspaceInvitationService {
);
}
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return result;
}
async getInvitationById(invitationId: string, workspace: Workspace) {
@@ -5,6 +5,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
@@ -19,7 +20,6 @@ import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@@ -28,12 +28,13 @@ import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import { v4 } from 'uuid';
import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@Injectable()
export class WorkspaceService {
@@ -48,6 +49,8 @@ export class WorkspaceService {
private userRepo: UserRepo,
private environmentService: EnvironmentService,
private domainService: DomainService,
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -359,6 +362,32 @@ export class WorkspaceService {
delete updateWorkspaceDto.generativeAi;
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId);
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, {
@@ -376,13 +405,8 @@ export class WorkspaceService {
async getWorkspaceUsers(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<User>> {
const users = await this.userRepo.getUsersPaginated(
workspaceId,
pagination,
);
return users;
): Promise<CursorPaginationResult<User>> {
return this.userRepo.getUsersPaginated(workspaceId, pagination);
}
async updateWorkspaceUserRole(
+23 -20
View File
@@ -7,8 +7,7 @@ import {
} from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely';
import { Pool, types } from 'pg';
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -26,9 +25,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { normalizePostgresUrl } from '../common/helpers';
@Global()
@Module({
@@ -37,26 +36,30 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
imports: [],
inject: [EnvironmentService],
useFactory: (environmentService: EnvironmentService) => ({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: environmentService.getDatabaseURL(),
max: environmentService.getDatabaseMaxPool(),
}).on('error', (err) => {
console.error('Database error:', err.message);
}),
dialect: new PostgresJSDialect({
postgres: postgres(
normalizePostgresUrl(environmentService.getDatabaseURL()),
{
max: environmentService.getDatabaseMaxPool(),
onnotice: () => {},
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
},
),
}),
plugins: [new CamelCasePlugin()],
log: (event: LogEvent) => {
if (environmentService.getNodeEnv() !== 'development') return;
const logger = new Logger(DatabaseModule.name);
if (event.level) {
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
//if (event.query.parameters.length > 0) {
// logger.debug('parameters: ' + event.query.parameters);
//}
}
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
}
},
}),
+6 -12
View File
@@ -1,25 +1,19 @@
import * as path from 'path';
import { promises as fs } from 'fs';
import pg from 'pg';
import {
Kysely,
Migrator,
PostgresDialect,
FileMigrationProvider,
} from 'kysely';
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import { run } from 'kysely-migration-cli';
import * as dotenv from 'dotenv';
import { envPath } from '../common/helpers/utils';
import { envPath, normalizePostgresUrl } from '../common/helpers';
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
dotenv.config({ path: envPath });
const migrationFolder = path.join(__dirname, './migrations');
const db = new Kysely<any>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}) as any,
dialect: new PostgresJSDialect({
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
}),
});
@@ -0,0 +1,9 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').dropColumn('settings').execute();
}
@@ -0,0 +1,15 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.dropColumn('contributor_ids')
.execute();
}
@@ -0,0 +1,348 @@
// adapted from https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT
import {
OrderByDirection,
OrderByModifiers,
ReferenceExpression,
SelectQueryBuilder,
StringReference,
} from 'kysely';
type SortField<DB, TB extends keyof DB, O> =
| {
expression:
| (StringReference<DB, TB> & keyof O & string)
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
key?: keyof O & string;
}
| {
expression: ReferenceExpression<DB, TB>;
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
key: keyof O & string;
};
type ExtractSortFieldKey<
DB,
TB extends keyof DB,
O,
T extends SortField<DB, TB, O>,
> = T['key'] extends keyof O & string
? T['key']
: T['expression'] extends keyof O & string
? T['expression']
: T['expression'] extends `${string}.${infer K}`
? K extends keyof O & string
? K
: never
: never;
type Fields<DB, TB extends keyof DB, O> = ReadonlyArray<
Readonly<SortField<DB, TB, O>>
>;
type FieldNames<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
[TIndex in keyof T]: ExtractSortFieldKey<DB, TB, O, T[TIndex]>;
};
type EncodeCursorValues<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = {
[TIndex in keyof T]: [
ExtractSortFieldKey<DB, TB, O, T[TIndex]>,
O[ExtractSortFieldKey<DB, TB, O, T[TIndex]>],
];
};
export type CursorEncoder<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = (values: EncodeCursorValues<DB, TB, O, T>) => string;
type DecodedCursor<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: string;
};
export type CursorDecoder<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = (
cursor: string,
fields: FieldNames<DB, TB, O, T>,
) => DecodedCursor<DB, TB, O, T>;
type ParsedCursorValues<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = {
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: O[TField];
};
export type CursorParser<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = (cursor: DecodedCursor<DB, TB, O, T>) => ParsedCursorValues<DB, TB, O, T>;
type CursorPaginationResultRow<
TRow,
TCursorKey extends string | boolean | undefined,
> = TRow & {
[K in TCursorKey extends undefined
? never
: TCursorKey extends false
? never
: TCursorKey extends true
? '$cursor'
: TCursorKey]: string;
};
type CursorPaginationMeta = {
limit: number;
hasNextPage: boolean;
hasPrevPage: boolean;
nextCursor: string | null;
prevCursor: string | null;
};
export type CursorPaginationResult<
TRow,
TCursorKey extends string | boolean | undefined = undefined,
> = {
meta: CursorPaginationMeta;
items: CursorPaginationResultRow<TRow, TCursorKey>[];
};
export async function executeWithCursorPagination<
DB,
TB extends keyof DB,
O,
const TFields extends Fields<DB, TB, O>,
TCursorKey extends string | boolean | undefined = undefined,
>(
qb: SelectQueryBuilder<DB, TB, O>,
opts: {
perPage: number;
cursor?: string;
beforeCursor?: string;
cursorPerRow?: TCursorKey;
fields: TFields;
encodeCursor?: CursorEncoder<DB, TB, O, TFields>;
decodeCursor?: CursorDecoder<DB, TB, O, TFields>;
parseCursor:
| CursorParser<DB, TB, O, TFields>
| { parse: CursorParser<DB, TB, O, TFields> };
},
): Promise<CursorPaginationResult<O, TCursorKey>> {
const encodeCursor = opts.encodeCursor ?? defaultEncodeCursor;
const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor;
const parseCursor =
typeof opts.parseCursor === 'function'
? opts.parseCursor
: opts.parseCursor.parse;
const fields = opts.fields.map((field) => {
let key = field.key;
if (!key && typeof field.expression === 'string') {
const expressionParts = field.expression.split('.');
key = (expressionParts[1] ?? expressionParts[0]) as
| (keyof O & string)
| undefined;
}
if (!key) throw new Error('missing key');
return { ...field, key };
});
function generateCursor(row: O): string {
const cursorFieldValues = fields.map(({ key }) => [
key,
row[key],
]) as EncodeCursorValues<DB, TB, O, TFields>;
return encodeCursor(cursorFieldValues);
}
const fieldNames = fields.map((field) => field.key) as FieldNames<
DB,
TB,
O,
TFields
>;
function applyCursor(
qb: SelectQueryBuilder<DB, TB, O>,
encoded: string,
defaultDirection: OrderByDirection,
) {
const decoded = decodeCursor(encoded, fieldNames);
const cursor = parseCursor(decoded);
return qb.where(({ and, or, eb }) => {
let expression;
for (let i = fields.length - 1; i >= 0; --i) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const field = fields[i]!;
const comparison = field.direction === defaultDirection ? '>' : '<';
const value = cursor[field.key as keyof typeof cursor];
const conditions = [eb(field.expression, comparison, value)];
if (expression) {
conditions.push(and([eb(field.expression, '=', value), expression]));
}
expression = or(conditions);
}
if (!expression) {
throw new Error('Error building cursor expression');
}
return expression;
});
}
if (opts.cursor) qb = applyCursor(qb, opts.cursor, 'asc');
if (opts.beforeCursor) qb = applyCursor(qb, opts.beforeCursor, 'desc');
const reversed = !!opts.beforeCursor && !opts.cursor;
for (const { expression, direction, orderModifier } of fields) {
qb = qb.orderBy(
expression,
orderModifier ??
(reversed ? (direction === 'asc' ? 'desc' : 'asc') : direction),
);
}
const rows = await qb.limit(opts.perPage + 1).execute();
const hasNextPage = rows.length > opts.perPage;
// If we fetched an extra row to determine if we have a next page, that
// shouldn't be in the returned results
if (rows.length > opts.perPage) rows.pop();
if (reversed) rows.reverse();
const startRow = rows[0];
const endRow = rows[rows.length - 1];
const hasPrevPage = !!opts.cursor;
const prevCursor = hasPrevPage && startRow ? generateCursor(startRow) : null;
const nextCursor = hasNextPage && endRow ? generateCursor(endRow) : null;
return {
items: rows.map((row) => {
if (opts.cursorPerRow) {
const cursorKey =
typeof opts.cursorPerRow === 'string' ? opts.cursorPerRow : '$cursor';
(row as any)[cursorKey] = generateCursor(row);
}
return row as CursorPaginationResultRow<O, TCursorKey>;
}),
meta: {
limit: opts.perPage,
hasNextPage,
hasPrevPage,
nextCursor,
prevCursor,
},
};
}
export function defaultEncodeCursor<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
>(values: EncodeCursorValues<DB, TB, O, T>) {
const cursor = new URLSearchParams();
for (const [key, value] of values) {
switch (typeof value) {
case 'string':
cursor.set(key, value);
break;
case 'number':
case 'bigint':
cursor.set(key, value.toString(10));
break;
case 'object': {
if (value instanceof Date) {
cursor.set(key, value.toISOString());
break;
}
}
// eslint-disable-next-line no-fallthrough
default:
throw new Error(`Unable to encode '${key.toString()}'`);
}
}
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
}
export function defaultDecodeCursor<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
>(
cursor: string,
fields: FieldNames<DB, TB, O, T>,
): DecodedCursor<DB, TB, O, T> {
let parsed;
try {
parsed = [
...new URLSearchParams(
Buffer.from(cursor, 'base64url').toString('utf8'),
).entries(),
];
} catch {
throw new Error('Unparsable cursor');
}
if (parsed.length !== fields.length) {
throw new Error('Unexpected number of fields');
}
for (let i = 0; i < fields.length; i++) {
const field = parsed[i];
const expectedName = fields[i];
if (!field) {
throw new Error('Unable to find field');
}
if (field[0] !== expectedName) {
throw new Error('Unexpected field name');
}
}
return Object.fromEntries(parsed) as DecodedCursor<DB, TB, O, T>;
}
@@ -9,11 +9,6 @@ import {
} from 'class-validator';
export class PaginationOptions {
@IsOptional()
@IsNumber()
@Min(1)
page = 1;
@IsOptional()
@IsNumber()
@IsPositive()
@@ -21,6 +16,14 @@ export class PaginationOptions {
@Max(100)
limit = 20;
@IsOptional()
@IsString()
cursor?: string;
@IsOptional()
@IsString()
beforeCursor?: string;
@IsOptional()
@IsString()
query: string;
@@ -8,7 +8,7 @@ import {
UpdatableComment,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@@ -37,15 +37,15 @@ export class CommentRepo {
.selectAll('comments')
.select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb))
.where('pageId', '=', pageId)
.orderBy('createdAt', 'asc');
.where('pageId', '=', pageId);
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return result;
}
async updateComment(
@@ -9,7 +9,7 @@ import { dbOrTx, executeTx } from '@docmost/db/utils';
import { sql } from 'kysely';
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -52,8 +52,7 @@ export class GroupUserRepo {
.selectFrom('groupUsers')
.innerJoin('users', 'users.id', 'groupUsers.userId')
.selectAll('users')
.where('groupId', '=', groupId)
.orderBy('createdAt', 'asc');
.where('groupId', '=', groupId);
if (pagination.query) {
query = query.where((eb) =>
@@ -61,9 +60,12 @@ export class GroupUserRepo {
);
}
const result = await executeWithPagination(query, {
page: pagination.page,
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'users.id', direction: 'asc', key: 'id' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
result.items.map((user) => {
@@ -10,8 +10,8 @@ import {
import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class GroupRepo {
@@ -104,17 +104,19 @@ export class GroupRepo {
}
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let query = this.db
let baseQuery = this.db
.selectFrom('groups')
.selectAll('groups')
.select((eb) => this.withMemberCount(eb))
.where('workspaceId', '=', workspaceId)
.orderBy('memberCount', 'desc')
.orderBy('createdAt', 'asc');
.where('workspaceId', '=', workspaceId);
if (pagination.query) {
query = query.where((eb) =>
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
baseQuery = baseQuery.where((eb) =>
eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
@@ -122,12 +124,24 @@ export class GroupRepo {
);
}
const result = executeWithPagination(query, {
page: pagination.page,
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{
expression: 'sub.memberCount',
direction: 'desc',
key: 'memberCount',
},
{ expression: 'sub.id', direction: 'asc', key: 'id' },
],
parseCursor: (cursor) => ({
memberCount: parseInt(cursor.memberCount, 10),
id: cursor.id,
}),
});
return result;
}
withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) {
@@ -8,25 +8,44 @@ import {
PageHistory,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
@Injectable()
export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof PageHistory> = [
'id',
'pageId',
'slugId',
'title',
'icon',
'coverPhoto',
'lastUpdatedById',
'contributorIds',
'spaceId',
'workspaceId',
'createdAt',
];
async findById(
pageHistoryId: string,
trx?: KyselyTransaction,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
): Promise<PageHistory> {
const db = dbOrTx(this.db, trx);
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('id', '=', pageHistoryId)
.executeTakeFirst();
}
@@ -43,7 +62,10 @@ export class PageHistoryRepo {
.executeTakeFirst();
}
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> {
async saveHistory(
page: Page,
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
await this.insertPageHistory(
{
pageId: page.id,
@@ -53,35 +75,44 @@ export class PageHistoryRepo {
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
},
trx,
opts?.trx,
);
}
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.where('pageId', '=', pageId)
.orderBy('createdAt', 'desc');
.select((eb) => this.withContributors(eb))
.where('pageId', '=', pageId);
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return result;
}
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
const db = dbOrTx(this.db, trx);
async findPageLastHistory(
pageId: string,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId)
.limit(1)
.orderBy('createdAt', 'desc')
@@ -96,4 +127,17 @@ export class PageHistoryRepo {
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef(
'users.id',
'=',
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
),
).as('contributors');
}
}
@@ -8,7 +8,7 @@ import {
UpdatablePage,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
@@ -281,15 +281,21 @@ export class PageRepo {
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
.where('deletedAt', 'is', null);
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
return result;
}
async getRecentPages(userId: string, pagination: PaginationOptions) {
@@ -298,12 +304,20 @@ export class PageRepo {
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
.where('deletedAt', 'is', null);
return executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
@@ -331,15 +345,21 @@ export class PageRepo {
),
),
]),
)
.orderBy('deletedAt', 'desc');
);
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'deletedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
deletedAt: new Date(cursor.deletedAt),
id: cursor.id,
}),
});
return result;
}
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
@@ -422,6 +442,8 @@ export class PageRepo {
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId)
@@ -438,6 +460,8 @@ export class PageRepo {
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
'p.createdAt',
'p.updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
@@ -8,7 +8,7 @@ import {
UpdatableShare,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
@@ -136,6 +136,20 @@ export class ShareRepo {
await query.execute();
}
async deleteBySpaceId(spaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
}
async getShares(userId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('shares')
@@ -143,12 +157,20 @@ export class ShareRepo {
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.orderBy('updatedAt', 'desc');
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId));
return executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
@@ -10,7 +10,7 @@ import {
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { MemberInfo, UserSpaceRole } from './types';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@@ -98,7 +98,7 @@ export class SpaceMemberRepo {
spaceId: string,
pagination: PaginationOptions,
) {
let query = this.db
let baseQuery = this.db
.selectFrom('spaceMembers')
.leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
@@ -114,12 +114,11 @@ export class SpaceMemberRepo {
'spaceMembers.createdAt',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('spaceId', '=', spaceId)
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc')
.orderBy('spaceMembers.createdAt', 'asc');
.select(sql<number>`case when groups.id is not null then 1 else 0 end`.as('isGroup'))
.where('spaceId', '=', spaceId);
if (pagination.query) {
query = query.where((eb) =>
baseQuery = baseQuery.where((eb) =>
eb(
sql`f_unaccent(users.name)`,
'ilike',
@@ -138,9 +137,20 @@ export class SpaceMemberRepo {
);
}
const result = await executeWithPagination(query, {
page: pagination.page,
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
{ expression: 'sub.createdAt', direction: 'asc', key: 'createdAt' },
],
parseCursor: (cursor) => ({
isGroup: parseInt(cursor.isGroup, 10),
createdAt: new Date(cursor.createdAt),
}),
});
let memberInfo: MemberInfo;
@@ -235,8 +245,7 @@ export class SpaceMemberRepo {
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
.orderBy('createdAt', 'asc');
.where('id', 'in', this.getUserSpaceIdsQuery(userId));
if (pagination.query) {
query = query.where((eb) =>
@@ -252,9 +261,12 @@ export class SpaceMemberRepo {
);
}
return executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
}
@@ -9,7 +9,7 @@ import {
} from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { DB } from '@docmost/db/types/db';
import { validate as isValidUUID } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -89,6 +89,26 @@ export class SpaceRepo {
.executeTakeFirst();
}
async updateSharingSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
@@ -110,8 +130,7 @@ export class SpaceRepo {
.selectFrom('spaces')
.selectAll('spaces')
.select((eb) => [this.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
.where('workspaceId', '=', workspaceId);
if (pagination.query) {
query = query.where((eb) =>
@@ -127,12 +146,13 @@ export class SpaceRepo {
);
}
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return result;
}
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
@@ -10,7 +10,7 @@ import {
User,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@@ -145,8 +145,7 @@ export class UserRepo {
.selectFrom('users')
.select(this.baseFields)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'asc');
.where('deletedAt', 'is', null);
if (pagination.query) {
query = query.where((eb) =>
@@ -162,12 +161,13 @@ export class UserRepo {
);
}
const result = executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return result;
}
async updatePreference(
@@ -167,7 +167,7 @@ export class WorkspaceRepo {
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
@@ -185,7 +185,25 @@ export class WorkspaceRepo {
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
+2
View File
@@ -199,6 +199,7 @@ export interface GroupUsers {
export interface PageHistory {
content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
icon: string | null;
@@ -273,6 +274,7 @@ export interface Spaces {
id: Generated<string>;
logo: string | null;
name: string | null;
settings: Json | null;
slug: string;
updatedAt: Generated<Timestamp>;
visibility: Generated<string>;
@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { validate } from './environment.validation';
import { envPath } from '../../common/helpers';
import { DomainService } from './domain.service';
import { LicenseCheckService } from './license-check.service';
@Global()
@Module({
@@ -15,7 +16,7 @@ import { DomainService } from './domain.service';
validate,
}),
],
providers: [EnvironmentService, DomainService],
exports: [EnvironmentService, DomainService],
providers: [EnvironmentService, DomainService, LicenseCheckService],
exports: [EnvironmentService, DomainService, LicenseCheckService],
})
export class EnvironmentModule {}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service';
@Injectable()
export class LicenseCheckService {
constructor(
private moduleRef: ModuleRef,
private environmentService: EnvironmentService,
) {}
isValidEELicense(licenseKey: string): boolean {
if (this.environmentService.isCloud()) {
return true;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.isValidEELicense(licenseKey);
} catch {
return false;
}
}
}
@@ -55,7 +55,7 @@ export class ExportController {
throw new ForbiddenException();
}
const zipFileBuffer = await this.exportService.exportPages(
const zipFileStream = await this.exportService.exportPages(
dto.pageId,
dto.format,
dto.includeAttachments,
@@ -70,7 +70,7 @@ export class ExportController {
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.send(zipFileBuffer);
res.send(zipFileStream);
}
@UseGuards(JwtAuthGuard)
@@ -100,6 +100,6 @@ export class ExportController {
'"',
});
res.send(exportFile.fileBuffer);
res.send(exportFile.fileStream);
}
}
@@ -5,7 +5,6 @@ import {
NotFoundException,
} from '@nestjs/common';
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely';
@@ -21,16 +20,23 @@ import {
replaceInternalLinks,
updateAttachmentUrlsToLocalPaths,
} from './utils';
import {
ExportMetadata,
ExportPageMetadata,
} from '../../common/helpers/types/export-metadata.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const packageJson = require('../../../package.json');
import { EnvironmentService } from '../environment/environment.service';
import {
getAttachmentIds,
getProsemirrorContent,
} from '../../common/helpers/prosemirror/utils';
import { htmlToMarkdown } from '@docmost/editor-ext';
@Injectable()
export class ExportService {
@@ -83,7 +89,7 @@ export class ExportService {
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
'',
);
return turndown(newPageHtml);
return htmlToMarkdown(newPageHtml);
}
return;
@@ -155,12 +161,17 @@ export class ExportService {
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.content',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
'pages.createdAt',
'pages.updatedAt',
])
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.execute();
const tree = buildTree(pages as Page[]);
@@ -177,7 +188,7 @@ export class ExportService {
const fileName = `${space.name}-space-export.zip`;
return {
fileBuffer: zipFile,
fileStream: zipFile,
fileName,
};
}
@@ -189,10 +200,12 @@ export class ExportService {
includeAttachments: boolean,
): Promise<void> {
const slugIdToPath: Record<string, string> = {};
const pageIdToFilePath: Record<string, string> = {};
const pagesMetadata: Record<string, ExportPageMetadata> = {};
computeLocalPath(tree, format, null, '', slugIdToPath);
const stack: { folder: JSZip; parentPageId: string }[] = [
const stack: { folder: JSZip; parentPageId: string | null }[] = [
{ folder: zip, parentPageId: null },
];
@@ -232,12 +245,35 @@ export class ExportService {
`${pageTitle}${getExportExtension(format)}`,
pageExportContent,
);
pageIdToFilePath[page.id] = currentPagePath;
const parentPath = parentPageId ? pageIdToFilePath[parentPageId] : null;
pagesMetadata[currentPagePath] = {
pageId: page.id,
slugId: page.slugId,
icon: page.icon ?? null,
position: page.position,
parentPath,
createdAt: page.createdAt?.toISOString() ?? new Date().toISOString(),
updatedAt: page.updatedAt?.toISOString() ?? new Date().toISOString(),
};
if (childPages.length > 0) {
const pageFolder = folder.folder(pageTitle);
stack.push({ folder: pageFolder, parentPageId: page.id });
}
}
}
const metadata: ExportMetadata = {
exportedAt: new Date().toISOString(),
source: 'docmost',
version: packageJson.version,
pages: pagesMetadata,
};
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
}
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
@@ -1,165 +0,0 @@
import * as TurndownService from '@joplin/turndown';
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
import * as path from 'path';
export function turndown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
hr: '---',
bulletListMarker: '-',
});
const tables = TurndownPluginGfm.tables;
const strikethrough = TurndownPluginGfm.strikethrough;
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
turndownService.use([
tables,
strikethrough,
highlightedCodeBlock,
taskList,
callout,
preserveDetail,
listParagraph,
mathInline,
mathBlock,
iframeEmbed,
video,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
}
function listParagraph(turndownService: TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
replacement: (content: any, node: HTMLInputElement) => {
if (node.parentElement?.nodeName === 'LI') {
return content;
}
return `\n\n${content}\n\n`;
},
});
}
function callout(turndownService: TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const calloutType = node.getAttribute('data-callout-type');
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
},
});
}
function taskList(turndownService: TurndownService) {
turndownService.addRule('taskListItem', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'taskItem' &&
node.parentNode.nodeName === 'UL'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const checkbox = node.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
// Process content like regular list items
content = content
.replace(/^\n+/, '') // remove leading newlines
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces
// Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
},
});
}
function preserveDetail(turndownService: TurndownService) {
turndownService.addRule('preserveDetail', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'DETAILS';
},
replacement: function (content: any, node: HTMLInputElement) {
const summary = node.querySelector(':scope > summary');
let detailSummary = '';
if (summary) {
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
}
const detailsContent = Array.from(node.childNodes)
.filter((child) => child.nodeName !== 'SUMMARY')
.map((child) =>
child.nodeType === 1
? turndownService.turndown((child as HTMLElement).outerHTML)
: child.textContent,
)
.join('');
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
},
});
}
function mathInline(turndownService: TurndownService) {
turndownService.addRule('mathInline', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'SPAN' &&
node.getAttribute('data-type') === 'mathInline'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return `$${content}$`;
},
});
}
function mathBlock(turndownService: TurndownService) {
turndownService.addRule('mathBlock', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' &&
node.getAttribute('data-type') === 'mathBlock'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return `\n$$\n${content}\n$$\n`;
},
});
}
function iframeEmbed(turndownService: TurndownService) {
turndownService.addRule('iframeEmbed', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'IFRAME';
},
replacement: function (content: any, node: HTMLInputElement) {
const src = node.getAttribute('src');
return '[' + src + '](' + src + ')';
},
});
}
function video(turndownService: TurndownService) {
turndownService.addRule('video', {
filter: function (node: HTMLInputElement) {
return node.tagName === 'VIDEO';
},
replacement: function (content: any, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
const name = path.basename(src);
return '[' + name + '](' + src + ')';
},
});
}
+15 -5
View File
@@ -1,4 +1,5 @@
import { jsonToNode } from 'src/collaboration/collaboration.util';
import { Logger } from '@nestjs/common';
import { ExportFormat } from './dto/export-dto';
import { Node } from '@tiptap/pm/model';
import { validate as isValidUUID } from 'uuid';
@@ -88,7 +89,7 @@ export function replaceInternalLinks(
// if link and text are same, use page title
if (markLink === node.text) {
//@ts-expect-error
node.text = getInternalLinkPageName(relativePath);
node.text = getInternalLinkPageName(relativePath, currentPagePath);
}
}
}
@@ -99,10 +100,19 @@ export function replaceInternalLinks(
return doc.toJSON();
}
export function getInternalLinkPageName(path: string): string {
return decodeURIComponent(
path?.split('/').pop().split('.').slice(0, -1).join('.'),
);
export function getInternalLinkPageName(path: string, currentFilePath?: string): string {
const name = path?.split('/').pop().split('.').slice(0, -1).join('.');
try {
return decodeURIComponent(name);
} catch (err) {
if (currentFilePath) {
Logger.warn(
`URI malformed in page ${currentFilePath}: ${name}. Falling back to raw name.`,
'ExportUtils',
);
}
return name;
}
}
export function extractPageSlugId(input: string): string {
@@ -15,4 +15,5 @@ export type ImportPageNode = {
parentPageId: string | null;
fileExtension: string;
filePath: string;
icon?: string | null;
};
@@ -27,7 +27,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Controller('file-tasks')
export class FileTaskController {
@@ -56,12 +56,14 @@ export class FileTaskController {
const query = this.db
.selectFrom('fileTasks')
.selectAll()
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.orderBy('createdAt', 'desc');
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id));
return executeWithPagination(query, {
page: pagination.page,
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
@@ -44,7 +44,7 @@ export class ImportController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const validFileExtensions = ['.md', '.html'];
const validFileExtensions = ['.md', '.html', '.docx'];
const maxFileSize = bytes('10mb');
@@ -24,6 +24,8 @@ import { formatImportHtml } from '../utils/import-formatter';
import {
buildAttachmentCandidates,
collectMarkdownAndHtmlFiles,
encodeFilePath,
readDocmostMetadata,
stripNotionID,
} from '../utils/import.utils';
import { executeTx } from '@docmost/db/utils';
@@ -154,6 +156,7 @@ export class FileImportTaskService {
const { extractDir, fileTask } = opts;
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
const docmostMetadata = await readDocmostMetadata(extractDir);
const pagesMap = new Map<string, ImportPageNode>();
@@ -164,6 +167,9 @@ export class FileImportTaskService {
.join('/'); // normalize to forward-slashes
const ext = path.extname(relPath).toLowerCase();
const encodedPath = encodeFilePath(relPath);
const pageMetadata = docmostMetadata?.pages[encodedPath];
pagesMap.set(relPath, {
id: v7(),
slugId: generateSlugId(),
@@ -172,6 +178,7 @@ export class FileImportTaskService {
parentPageId: null,
fileExtension: ext,
filePath: relPath,
icon: pageMetadata?.icon ?? null,
});
}
@@ -224,6 +231,8 @@ export class FileImportTaskService {
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath);
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
@@ -232,6 +241,7 @@ export class FileImportTaskService {
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
});
}
});
@@ -266,11 +276,39 @@ export class FileImportTaskService {
siblingsMap.set(page.parentPageId, group);
});
const encodedPathsMap = new Map<string, string>();
if (docmostMetadata) {
pagesMap.forEach((_, filePath) => {
encodedPathsMap.set(filePath, encodeFilePath(filePath));
});
}
// Sort siblings by metadata position if available, otherwise alphabetically
const sortSiblings = (siblings: ImportPageNode[]) => {
if (docmostMetadata) {
siblings.sort((a, b) => {
const posA =
docmostMetadata.pages[encodedPathsMap.get(a.filePath)]?.position;
const posB =
docmostMetadata.pages[encodedPathsMap.get(b.filePath)]?.position;
if (posA && posB) {
// Use direct comparison to match PostgreSQL collation 'C' (byte order)
if (posA < posB) return -1;
if (posA > posB) return 1;
return 0;
}
return a.name.localeCompare(b.name);
});
} else {
siblings.sort((a, b) => a.name.localeCompare(b.name));
}
};
// get root pages
const rootSibs = siblingsMap.get(null);
if (rootSibs?.length) {
rootSibs.sort((a, b) => a.name.localeCompare(b.name));
sortSiblings(rootSibs);
// get first position key from the server
const nextPosition = await this.pageService.nextPagePosition(
@@ -292,7 +330,7 @@ export class FileImportTaskService {
siblingsMap.forEach((sibs, parentId) => {
if (parentId === null) return; // root already done
sibs.sort((a, b) => a.name.localeCompare(b.name));
sortSiblings(sibs);
let prevPos: string | null = null;
for (const page of sibs) {
@@ -426,7 +464,7 @@ export class FileImportTaskService {
id: page.id,
slugId: page.slugId,
title: title || page.name,
icon: pageIcon || null,
icon: page.icon || pageIcon || null,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson),
@@ -10,7 +10,11 @@ import {
} from '../../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId, sanitizeFileName } from '../../../common/helpers';
import {
generateSlugId,
sanitizeFileName,
createByteCountingStream,
} from '../../../common/helpers';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
@@ -25,6 +29,7 @@ import { StorageService } from '../../storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../queue/constants';
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class ImportService {
@@ -36,6 +41,7 @@ export class ImportService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue,
private moduleRef: ModuleRef,
) {}
async importPage(
@@ -55,11 +61,22 @@ export class ImportService {
let prosemirrorState = null;
let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
}
} catch (err) {
const message = 'Error processing file content';
@@ -83,6 +100,7 @@ export class ImportService {
const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({
...(pageId ? { id: pageId } : {}),
slugId: generateSlugId(),
title: pageTitle,
content: prosemirrorJson,
@@ -125,6 +143,42 @@ export class ImportService {
}
}
async processDocx(
fileBuffer: Buffer,
workspaceId: string,
spaceId: string,
pageId: string,
userId: string,
): Promise<any> {
let DocxImportModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
} catch (err) {
this.logger.error(
'DOCX import requested but EE module not bundled in this build',
);
throw new BadRequestException(
'This feature requires a valid enterprise license.',
);
}
const docxImportService = this.moduleRef.get(
DocxImportModule.DocxImportService,
{ strict: false },
);
const html = await docxImportService.convertDocxToHtml(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
return this.processHTML(html);
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) {
// this.logger.debug(`Converting prosemirror json state to ydoc`);
@@ -173,15 +227,24 @@ export class ImportService {
};
}
async getNewPagePosition(spaceId: string): Promise<string> {
const lastPage = await this.db
async getNewPagePosition(
spaceId: string,
parentPageId?: string,
): Promise<string> {
let query = this.db
.selectFrom('pages')
.select(['id', 'position'])
.where('spaceId', '=', spaceId)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1)
.where('parentPageId', 'is', null)
.executeTakeFirst();
.limit(1);
if (parentPageId) {
query = query.where('parentPageId', '=', parentPageId);
} else {
query = query.where('parentPageId', 'is', null);
}
const lastPage = await query.executeTakeFirst();
if (lastPage) {
return generateJitteredKeyBetween(lastPage.position, null);
@@ -198,20 +261,21 @@ export class ImportService {
workspaceId: string,
) {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase();
const fileName = sanitizeFileName(
path.basename(file.filename, fileExtension),
);
const fileSize = fileBuffer.length;
const fileNameWithExt = fileName + fileExtension;
const fileTaskId = uuid7();
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
// upload file
await this.storageService.upload(filePath, fileBuffer);
const { stream, getBytesRead } = createByteCountingStream(file.file);
await this.storageService.upload(filePath, stream);
const fileSize = getBytesRead();
const fileTask = await this.db
.insertInto('fileTasks')
@@ -1,4 +1,5 @@
import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
import { Logger } from '@nestjs/common';
import * as path from 'path';
import { v7 } from 'uuid';
import { InsertableBacklink } from '@docmost/db/types/entity.types';
@@ -280,8 +281,18 @@ export async function rewriteInternalLinksToMentionHtml(
const $a = $(el);
const raw = $a.attr('href')!;
if (raw.startsWith('http') || raw.startsWith('/api/')) return;
let decodedRaw = raw;
try {
decodedRaw = decodeURIComponent(raw);
} catch (err) {
Logger.warn(
`URI malformed in page ${currentFilePath}: ${raw}. Falling back to raw path.`,
'ImportFormatter',
);
}
const resolved = normalize(
path.join(path.dirname(currentFilePath), decodeURIComponent(raw)),
path.join(path.dirname(currentFilePath), decodedRaw),
);
const meta = filePathToPageMetaMap.get(resolved);
if (!meta) return;
@@ -1,5 +1,7 @@
import { Logger } from '@nestjs/common';
import { promises as fs } from 'fs';
import * as path from 'path';
import { ExportMetadata } from '../../../common/helpers/types/export-metadata.types';
export async function buildAttachmentCandidates(
extractDir: string,
@@ -30,8 +32,19 @@ export function resolveRelativeAttachmentPath(
pageDir: string,
attachmentCandidates: Map<string, string>,
): string | null {
const mainRel = decodeURIComponent(raw.replace(/^\.?\/+/, ''));
const fallback = path.normalize(path.join(pageDir, mainRel));
let mainRel = raw.replace(/^\.?\/+/, '');
try {
mainRel = decodeURIComponent(mainRel);
} catch (err) {
Logger.warn(
`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`,
'ImportUtils',
);
}
const fallback = path
.normalize(path.join(pageDir, mainRel))
.split(path.sep)
.join('/');
if (attachmentCandidates.has(mainRel)) {
return mainRel;
@@ -70,3 +83,26 @@ export function stripNotionID(fileName: string): string {
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
return fileName.replace(notionIdPattern, '').trim();
}
export function encodeFilePath(filePath: string): string {
return filePath
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
export async function readDocmostMetadata(
extractDir: string,
): Promise<ExportMetadata | null> {
const metadataPath = path.join(extractDir, 'docmost-metadata.json');
try {
const content = await fs.readFile(metadataPath, 'utf-8');
const metadata = JSON.parse(content) as ExportMetadata;
if (metadata.source === 'docmost' && metadata.pages) {
return metadata;
}
return null;
} catch {
return null;
}
}
@@ -6,6 +6,7 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
}
export enum QueueJob {
@@ -58,4 +59,6 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
PAGE_HISTORY = 'page-history',
}
@@ -9,4 +9,8 @@ export interface IPageBacklinkJob {
export interface IStripeSeatsSyncJob {
workspaceId: string;
}
export interface IPageHistoryJob {
pageId: string;
}
@@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
],
exports: [BullModule],
providers: [BacklinksProcessor],
@@ -20,9 +20,15 @@ export class LocalDriver implements StorageDriver {
return join(this.config.storagePath, filePath);
}
async upload(filePath: string, file: Buffer): Promise<void> {
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
try {
await fs.outputFile(this._fullPath(filePath), file);
const fullPath = this._fullPath(filePath);
if (file instanceof Buffer) {
await fs.outputFile(fullPath, file);
} else {
await fs.mkdir(dirname(fullPath), { recursive: true });
await pipeline(file, createWriteStream(fullPath));
}
} catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
@@ -42,7 +48,7 @@ export class LocalDriver implements StorageDriver {
try {
const fromFullPath = this._fullPath(fromFilePath);
const toFullPath = this._fullPath(toFilePath);
if (await this.exists(fromFilePath)) {
await fs.copy(fromFullPath, toFullPath);
}
@@ -67,6 +73,20 @@ export class LocalDriver implements StorageDriver {
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath), {
start: range.start,
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> {
try {
return await fs.pathExists(this._fullPath(filePath));
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client;
@@ -23,20 +24,23 @@ export class S3Driver implements StorageDriver {
this.s3Client = new S3Client(config as any);
}
async upload(filePath: string, file: Buffer): Promise<void> {
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
try {
const contentType = getMimeType(filePath);
const command = new PutObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Body: file,
ContentType: contentType,
// ACL: "public-read",
const upload = new Upload({
client: this.s3Client,
params: {
Bucket: this.config.bucket,
Key: filePath,
Body: file,
ContentType: contentType,
},
});
await this.s3Client.send(command);
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
}
@@ -71,6 +75,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
} finally {
if (shouldDestroyClient && clientToUse) {
@@ -125,6 +130,25 @@ export class S3Driver implements StorageDriver {
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Range: `bytes=${range.start}-${range.end}`,
});
const response = await this.s3Client.send(command);
return response.Body as Readable;
} catch (err) {
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
@@ -1,7 +1,7 @@
import { Readable } from 'stream';
export interface StorageDriver {
upload(filePath: string, file: Buffer): Promise<void>;
upload(filePath: string, file: Buffer | Readable): Promise<void>;
uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>;
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string;
@@ -8,9 +8,9 @@ export class StorageService {
private readonly logger = new Logger(StorageService.name);
constructor(
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
) {}
) { }
async upload(filePath: string, fileContent: Buffer | any) {
async upload(filePath: string, fileContent: Buffer | Readable) {
await this.storageDriver.upload(filePath, fileContent);
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
}
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath);
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
return this.storageDriver.readRangeStream(filePath, range);
}
async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath);
}
+10 -3
View File
@@ -5,9 +5,9 @@ import {
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
@@ -24,10 +24,16 @@ async function bootstrap() {
}),
{
rawBody: true,
logger: new InternalLogFilter(),
// disable Nest logger so pino handles all logs
// bufferLogs must be false else pino will fail
// to log OnApplicationBootstrap logs
logger: false,
bufferLogs: false,
},
);
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
});
@@ -98,7 +104,8 @@ async function bootstrap() {
});
const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0', () => {
const host = process.env.HOST || '0.0.0.0';
await app.listen(port, host, () => {
logger.log(
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
);
@@ -23,6 +23,9 @@ export class WsRedisIoAdapter extends IoAdapter {
const pubClient = new Redis(process.env.REDIS_URL, options);
const subClient = new Redis(process.env.REDIS_URL, options);
pubClient.on('error', (err) => () => {});
subClient.on('error', (err) => () => {});
this.adapterConstructor = createAdapter(pubClient, subClient);
}