mirror of
https://github.com/docmost/docmost.git
synced 2026-08-20 21:01:37 +10:00
Merge branch 'main' into confluence-importer
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -109,6 +110,7 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -165,6 +165,21 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
if (page) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: 'page.updated',
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUpdatedById: context?.user?.id,
|
||||
lastUpdatedBy: context?.user
|
||||
? {
|
||||
id: context.user?.id,
|
||||
name: context.user?.name,
|
||||
avatarUrl: context.user?.avatarUrl,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,29 @@ export enum EventName {
|
||||
WORKSPACE_CREATED = 'workspace.created',
|
||||
WORKSPACE_UPDATED = 'workspace.updated',
|
||||
WORKSPACE_DELETED = 'workspace.deleted',
|
||||
|
||||
BASE_CREATED = 'base.created',
|
||||
BASE_UPDATED = 'base.updated',
|
||||
BASE_DELETED = 'base.deleted',
|
||||
|
||||
BASE_ROW_CREATED = 'base.row.created',
|
||||
BASE_ROW_UPDATED = 'base.row.updated',
|
||||
BASE_ROW_DELETED = 'base.row.deleted',
|
||||
BASE_ROWS_DELETED = 'base.rows.deleted',
|
||||
BASE_ROW_RESTORED = 'base.row.restored',
|
||||
BASE_ROW_REORDERED = 'base.row.reordered',
|
||||
|
||||
BASE_PROPERTY_CREATED = 'base.property.created',
|
||||
BASE_PROPERTY_UPDATED = 'base.property.updated',
|
||||
BASE_PROPERTY_DELETED = 'base.property.deleted',
|
||||
BASE_PROPERTY_REORDERED = 'base.property.reordered',
|
||||
|
||||
BASE_VIEW_CREATED = 'base.view.created',
|
||||
BASE_VIEW_UPDATED = 'base.view.updated',
|
||||
BASE_VIEW_DELETED = 'base.view.deleted',
|
||||
|
||||
BASE_SCHEMA_BUMPED = 'base.schema.bumped',
|
||||
BASE_ROWS_UPDATED = 'base.rows.updated',
|
||||
BASE_FORMULA_RECOMPUTE_STARTED = 'base.formula.recompute.started',
|
||||
BASE_FORMULA_RECOMPUTE_COMPLETED = 'base.formula.recompute.completed',
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export const Feature = {
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -10,6 +10,9 @@ export const LOCAL_STORAGE_PATH = path.resolve(
|
||||
LOCAL_STORAGE_DIR,
|
||||
);
|
||||
|
||||
export function getPageTitle(title: string | null | undefined): string {
|
||||
return title || 'untitled';
|
||||
export function getPageTitle(
|
||||
title: string | null | undefined,
|
||||
isBase?: boolean,
|
||||
): string {
|
||||
return title || (isBase ? 'Untitled base' : 'untitled');
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './utils';
|
||||
export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
export * from './security-headers';
|
||||
|
||||
@@ -5,4 +5,9 @@ export const nanoIdGen = customAlphabet(alphabet, 10);
|
||||
|
||||
const slugIdAlphabet =
|
||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||
|
||||
const baseIdSuffix = customAlphabet(alphabet, 9);
|
||||
|
||||
export const generateBasePropertyId = (): string => `prp${baseIdSuffix()}`;
|
||||
export const generateBaseChoiceId = (): string => `opt${baseIdSuffix()}`;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SecurityHeader = { name: string; value: string };
|
||||
|
||||
export function resolveFrameHeader(
|
||||
iframeEmbedAllowed: boolean,
|
||||
allowedOrigins: string[],
|
||||
): SecurityHeader | null {
|
||||
if (!iframeEmbedAllowed) {
|
||||
return { name: 'X-Frame-Options', value: 'SAMEORIGIN' };
|
||||
}
|
||||
|
||||
if (allowedOrigins.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Content-Security-Policy',
|
||||
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,11 @@ export enum UserRole {
|
||||
MEMBER = 'member',
|
||||
}
|
||||
|
||||
export enum InviteUserRole {
|
||||
ADMIN = 'admin', // can have owner permissions but cannot delete workspace
|
||||
MEMBER = 'member',
|
||||
}
|
||||
|
||||
export enum SpaceRole {
|
||||
ADMIN = 'admin', // can manage space settings, members, and delete space
|
||||
WRITER = 'writer', // can read and write pages in space
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AttachmentService {
|
||||
|
||||
async uploadFile(opts: {
|
||||
filePromise: Promise<MultipartFile>;
|
||||
pageId: string;
|
||||
pageId?: string;
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
@@ -92,6 +92,8 @@ export class PageService {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
createPageDto: CreatePageDto,
|
||||
trx?: KyselyTransaction,
|
||||
isBase: boolean = false,
|
||||
): Promise<Page> {
|
||||
let parentPageId = undefined;
|
||||
|
||||
@@ -140,21 +142,34 @@ export class PageService {
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
isBase,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
});
|
||||
}, trx);
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
pageId: page.id,
|
||||
spaceId: createPageDto.spaceId,
|
||||
if (trx) {
|
||||
// Add the watcher inside the caller's transaction so the async worker
|
||||
// never inserts against an uncommitted page (FK violation on bases).
|
||||
await this.watcherService.addPageWatchers(
|
||||
[userId],
|
||||
page.id,
|
||||
createPageDto.spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
trx,
|
||||
);
|
||||
} else {
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
pageId: page.id,
|
||||
spaceId: createPageDto.spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
@@ -289,6 +304,7 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isBase',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
@@ -310,6 +326,7 @@ export class PageService {
|
||||
expression: 'position',
|
||||
direction: 'asc',
|
||||
orderModifier: (ob) => ob.collate('C').asc(),
|
||||
cursorExpression: sql`position collate "C"`,
|
||||
},
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
@@ -481,7 +498,7 @@ export class PageService {
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIdsToMove,
|
||||
pageIds: pageIdsToMove,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
});
|
||||
}
|
||||
@@ -831,6 +848,7 @@ export class PageService {
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'isBase',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
@@ -846,6 +864,7 @@ export class PageService {
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.isBase',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { StorageService } from '../../../../integrations/storage/storage.service';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
|
||||
describe('TransclusionService.syncPageTransclusions', () => {
|
||||
let service: TransclusionService;
|
||||
let repo: jest.Mocked<PageTransclusionsRepo>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRepo: jest.Mocked<Partial<PageTransclusionsRepo>> = {
|
||||
findByPageId: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteByPageAndTransclusionIds: jest.fn(),
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransclusionService,
|
||||
{ provide: PageTransclusionsRepo, useValue: mockRepo },
|
||||
{ provide: PageTransclusionReferencesRepo, useValue: {} },
|
||||
{ provide: PageRepo, useValue: {} },
|
||||
{ provide: PagePermissionRepo, useValue: {} },
|
||||
{ provide: AttachmentRepo, useValue: {} },
|
||||
{ provide: StorageService, useValue: {} },
|
||||
{ provide: PageAccessService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(TransclusionService);
|
||||
repo = module.get(PageTransclusionsRepo);
|
||||
});
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const workspaceId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
it('inserts new transclusions that did not exist before', async () => {
|
||||
repo.findByPageId.mockResolvedValue([]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'a' },
|
||||
content: [{ type: 'paragraph' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
|
||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||
expect(repo.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageId,
|
||||
transclusionId: 'a',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates transclusions whose content changed', async () => {
|
||||
repo.findByPageId.mockResolvedValue([
|
||||
{
|
||||
id: 'row1',
|
||||
pageId,
|
||||
transclusionId: 'a',
|
||||
content: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const newContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] },
|
||||
],
|
||||
};
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'a' },
|
||||
content: newContent.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'a',
|
||||
expect.objectContaining({ content: newContent }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips update when content is unchanged', async () => {
|
||||
const sameContent = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
};
|
||||
repo.findByPageId.mockResolvedValue([
|
||||
{
|
||||
id: 'row1',
|
||||
pageId,
|
||||
transclusionId: 'a',
|
||||
content: sameContent,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'a' },
|
||||
content: sameContent.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes transclusions that no longer appear in the doc', async () => {
|
||||
repo.findByPageId.mockResolvedValue([
|
||||
{
|
||||
id: 'r',
|
||||
pageId,
|
||||
transclusionId: 'gone',
|
||||
content: { type: 'doc', content: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
|
||||
expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
['gone'],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty doc → noop', async () => {
|
||||
repo.findByPageId.mockResolvedValue([]);
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, null);
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.syncPageReferences', () => {
|
||||
let service: TransclusionService;
|
||||
let refRepo: jest.Mocked<PageTransclusionReferencesRepo>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockTransclusionsRepo: Partial<PageTransclusionsRepo> = {};
|
||||
const mockRefRepo: jest.Mocked<Partial<PageTransclusionReferencesRepo>> = {
|
||||
findByReferencePageId: jest.fn(),
|
||||
insertMany: jest.fn(),
|
||||
deleteByReferenceAndKeys: jest.fn(),
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransclusionService,
|
||||
{ provide: PageTransclusionsRepo, useValue: mockTransclusionsRepo },
|
||||
{ provide: PageTransclusionReferencesRepo, useValue: mockRefRepo },
|
||||
{ provide: PageRepo, useValue: {} },
|
||||
{ provide: PagePermissionRepo, useValue: {} },
|
||||
{ provide: AttachmentRepo, useValue: {} },
|
||||
{ provide: StorageService, useValue: {} },
|
||||
{ provide: PageAccessService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(TransclusionService);
|
||||
refRepo = module.get(PageTransclusionReferencesRepo);
|
||||
});
|
||||
|
||||
const referencePageId = '00000000-0000-0000-0000-000000000001';
|
||||
const workspaceId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
it('inserts new loose references, no deletes when none existed', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 2, deleted: 0 });
|
||||
expect(refRepo.insertMany).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
workspaceId,
|
||||
referencePageId,
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
},
|
||||
{
|
||||
workspaceId,
|
||||
referencePageId,
|
||||
sourcePageId: 'p2',
|
||||
transclusionId: 'e2',
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores references nested inside a source (schema-forbidden)', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 's1' },
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes references that no longer appear', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
referencePageId,
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
createdAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
||||
expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith(
|
||||
referencePageId,
|
||||
[
|
||||
{
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when desired matches existing exactly', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([
|
||||
{
|
||||
id: 'r',
|
||||
referencePageId,
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
createdAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import {
|
||||
@@ -36,10 +38,12 @@ export class TransclusionService {
|
||||
private readonly logger = new Logger(TransclusionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
|
||||
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@@ -213,6 +217,40 @@ export class TransclusionService {
|
||||
return { inserted: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve viewer access for source page IDs supplied by an authenticated
|
||||
* caller. Restricts candidates to pages the viewer can see at the space
|
||||
* level before applying page-level restrictions, so a workspace member
|
||||
* cannot read a sync block from a private space they don't belong to via
|
||||
* an unrestricted source page.
|
||||
*/
|
||||
private async filterViewerAccessiblePageIds(
|
||||
pageIds: string[],
|
||||
viewerUserId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string[]> {
|
||||
if (pageIds.length === 0) return [];
|
||||
|
||||
const spaceVisible = await this.db
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('id', 'in', pageIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(viewerUserId),
|
||||
)
|
||||
.execute();
|
||||
if (spaceVisible.length === 0) return [];
|
||||
|
||||
return this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: spaceVisible.map((r) => r.id),
|
||||
userId: viewerUserId,
|
||||
});
|
||||
}
|
||||
|
||||
async lookup(
|
||||
references: Array<{ sourcePageId: string; transclusionId: string }>,
|
||||
viewerUserId: string,
|
||||
@@ -224,10 +262,11 @@ export class TransclusionService {
|
||||
new Set(references.map((r) => r.sourcePageId)),
|
||||
);
|
||||
const accessibleSet = new Set(
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidatePageIds,
|
||||
userId: viewerUserId,
|
||||
}),
|
||||
await this.filterViewerAccessiblePageIds(
|
||||
candidatePageIds,
|
||||
viewerUserId,
|
||||
workspaceId,
|
||||
),
|
||||
);
|
||||
|
||||
return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
|
||||
@@ -336,10 +375,11 @@ export class TransclusionService {
|
||||
new Set([sourcePageId, ...referencePageIds]),
|
||||
);
|
||||
const accessibleSet = new Set(
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidatePageIds,
|
||||
userId: viewerUserId,
|
||||
}),
|
||||
await this.filterViewerAccessiblePageIds(
|
||||
candidatePageIds,
|
||||
viewerUserId,
|
||||
workspaceId,
|
||||
),
|
||||
);
|
||||
|
||||
const accessibleIds = candidatePageIds.filter((id) =>
|
||||
|
||||
@@ -20,8 +20,9 @@ export class CreateSpaceDto {
|
||||
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@Matches(/^[a-zA-Z0-9-]+$/, {
|
||||
message: 'slug can only contain letters, numbers, and hyphens',
|
||||
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
||||
message:
|
||||
'Space slug must start with a letter or number and may contain hyphens and underscores',
|
||||
})
|
||||
slug: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export class SpaceService {
|
||||
workspaceId: string,
|
||||
createSpaceDto: CreateSpaceDto,
|
||||
trx?: KyselyTransaction,
|
||||
options?: { isPersonal?: boolean },
|
||||
): Promise<Space> {
|
||||
let space = null;
|
||||
|
||||
@@ -59,6 +60,7 @@ export class SpaceService {
|
||||
workspaceId,
|
||||
createSpaceDto,
|
||||
trx,
|
||||
options,
|
||||
);
|
||||
|
||||
await this.spaceMemberService.addUserToSpace(
|
||||
@@ -81,6 +83,7 @@ export class SpaceService {
|
||||
after: {
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
...(space.isPersonal ? { isPersonal: true } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -93,6 +96,7 @@ export class SpaceService {
|
||||
workspaceId: string,
|
||||
createSpaceDto: CreateSpaceDto,
|
||||
trx?: KyselyTransaction,
|
||||
options?: { isPersonal?: boolean },
|
||||
): Promise<Space> {
|
||||
const slugExists = await this.spaceRepo.slugExists(
|
||||
createSpaceDto.slug,
|
||||
@@ -112,6 +116,7 @@ export class SpaceService {
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
slug: createSpaceDto.slug,
|
||||
isPersonal: options?.isPersonal ?? false,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
import { InviteUserRole } from '../../../common/helpers/types/permission';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class InviteUserDto {
|
||||
@@ -32,7 +32,7 @@ export class InviteUserDto {
|
||||
@IsUUID('all', { each: true })
|
||||
groupIds: string[];
|
||||
|
||||
@IsEnum(UserRole)
|
||||
@IsEnum(InviteUserRole)
|
||||
role: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowMemberTemplates: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowPersonalSpaces: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { isAdminActingOnOwner } from '../workspace.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@@ -119,6 +121,10 @@ export class WorkspaceInvitationService {
|
||||
): Promise<void> {
|
||||
const { emails, role, groupIds } = inviteUserDto;
|
||||
|
||||
if (isAdminActingOnOwner(authUser.role, role)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
let invites: WorkspaceInvitation[] = [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { addDays } from 'date-fns';
|
||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||
import { isAdminActingOnOwner } from '../workspace.util';
|
||||
import { v4 } from 'uuid';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
@@ -332,7 +333,8 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -360,6 +362,18 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||
if (
|
||||
!this.licenseCheckService.hasFeature(
|
||||
ws.licenseKey,
|
||||
Feature.PERSONAL_SPACES,
|
||||
ws.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
@@ -499,6 +513,20 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
|
||||
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
|
||||
before.allowPersonalSpaces = prev;
|
||||
after.allowPersonalSpaces = updateWorkspaceDto.allowPersonalSpaces;
|
||||
}
|
||||
await this.workspaceRepo.updateSpaceSettings(
|
||||
workspaceId,
|
||||
'allowPersonal',
|
||||
updateWorkspaceDto.allowPersonalSpaces,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
@@ -506,6 +534,7 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
delete updateWorkspaceDto.allowMemberTemplates;
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
delete updateWorkspaceDto.allowPersonalSpaces;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
updateWorkspaceDto,
|
||||
@@ -590,8 +619,8 @@ export class WorkspaceService {
|
||||
|
||||
// prevent ADMIN from managing OWNER role
|
||||
if (
|
||||
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
|
||||
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
|
||||
isAdminActingOnOwner(authUser.role, newRole) ||
|
||||
isAdminActingOnOwner(authUser.role, user.role)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@@ -695,7 +724,7 @@ export class WorkspaceService {
|
||||
throw new BadRequestException('You cannot deactivate yourself');
|
||||
}
|
||||
|
||||
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException(
|
||||
'You cannot deactivate a user with owner role',
|
||||
);
|
||||
@@ -753,7 +782,7 @@ export class WorkspaceService {
|
||||
throw new BadRequestException('User is not deactivated');
|
||||
}
|
||||
|
||||
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException(
|
||||
'You cannot activate a user with owner role',
|
||||
);
|
||||
@@ -805,7 +834,7 @@ export class WorkspaceService {
|
||||
throw new BadRequestException('You cannot delete yourself');
|
||||
}
|
||||
|
||||
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException('You cannot delete a user with owner role');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { UserRole } from '../../common/helpers/types/permission';
|
||||
|
||||
export function isAdminActingOnOwner(
|
||||
authUserRole: string,
|
||||
targetRole: string,
|
||||
): boolean {
|
||||
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.addColumn('is_base', 'boolean', (col) =>
|
||||
col.ifNotExists().notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('base_schema_version', 'integer', (col) =>
|
||||
col.ifNotExists().notNull().defaultTo(0),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_is_base
|
||||
ON pages (space_id, position COLLATE "C")
|
||||
WHERE is_base = true AND deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.createTable('base_properties')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type_options', 'jsonb')
|
||||
.addColumn('pending_type', 'varchar')
|
||||
.addColumn('pending_type_options', 'jsonb')
|
||||
.addColumn('pending_token', 'uuid')
|
||||
.addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
|
||||
.addColumn('schema_version', 'integer', (col) => col.notNull().defaultTo(1))
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.addPrimaryKeyConstraint('base_properties_pkey', ['page_id', 'id'])
|
||||
.execute();
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_base_properties_page_id ON base_properties (page_id)`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_properties_page_alive
|
||||
ON base_properties (page_id, position COLLATE "C", id)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Match the service-layer name check (name.trim().toLowerCase()) so
|
||||
// whitespace-padded duplicates also collide. Formulas look properties up by
|
||||
// name, so the names have to stay unique.
|
||||
await sql`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS base_properties_page_name_alive_unique
|
||||
ON base_properties (page_id, lower(trim(name)))
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.createTable('base_rows')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('cells', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('last_updated_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_page_alive
|
||||
ON base_rows (page_id, position COLLATE "C", id)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_page_updated
|
||||
ON base_rows (page_id, updated_at DESC)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_page_created
|
||||
ON base_rows (page_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.createTable('base_views')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('table'))
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('config', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_base_views_page_id ON base_views (page_id)`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
// Cell extraction helpers for filters and sorts. Return NULL for absent or
|
||||
// non-castable values.
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_text(cells jsonb, prop text)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$ SELECT cells->>prop::text $$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_numeric(cells jsonb, prop text)
|
||||
RETURNS numeric LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT CASE jsonb_typeof(cells->prop::text)
|
||||
WHEN 'number' THEN (cells->>prop::text)::numeric
|
||||
WHEN 'string' THEN
|
||||
CASE
|
||||
WHEN (cells->>prop::text) ~
|
||||
'^[[:space:]]*[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?[[:space:]]*$'
|
||||
THEN (cells->>prop::text)::numeric
|
||||
END
|
||||
END
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
// A DATE cell stores an arbitrary string (cell schema is z.string()), so the
|
||||
// cast can fail on values no regex can pre-validate (e.g. '2024-13-45'). This
|
||||
// helper uses plpgsql with an EXCEPTION handler to return NULL on failure.
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_timestamptz(cells jsonb, prop text)
|
||||
RETURNS timestamptz LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
BEGIN RETURN (cells->>prop::text)::timestamptz;
|
||||
EXCEPTION WHEN others THEN RETURN NULL; END;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_bool(cells jsonb, prop text)
|
||||
RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT CASE jsonb_typeof(cells->prop::text)
|
||||
WHEN 'boolean' THEN (cells->>prop::text)::boolean
|
||||
WHEN 'string' THEN
|
||||
CASE
|
||||
WHEN lower(btrim(cells->>prop::text)) IN
|
||||
('true','t','yes','y','on','1','false','f','no','n','off','0')
|
||||
THEN (cells->>prop::text)::boolean
|
||||
END
|
||||
END
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_array(cells jsonb, prop text)
|
||||
RETURNS jsonb LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$ SELECT cells->prop::text $$
|
||||
`.execute(db);
|
||||
|
||||
// A null patch value deletes the key rather than storing a JSON null.
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION jsonb_set_many(target jsonb, patches jsonb)
|
||||
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
||||
AS $$
|
||||
DECLARE k text; v jsonb; result jsonb := coalesce(target, '{}'::jsonb);
|
||||
BEGIN
|
||||
IF patches IS NULL OR jsonb_typeof(patches) <> 'object' THEN
|
||||
RETURN result;
|
||||
END IF;
|
||||
FOR k, v IN SELECT * FROM jsonb_each(patches) LOOP
|
||||
IF v = 'null'::jsonb THEN
|
||||
result := result - k;
|
||||
ELSE
|
||||
result := jsonb_set(result, ARRAY[k], v, true);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN result;
|
||||
END;
|
||||
$$
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('base_views').execute();
|
||||
await db.schema.dropTable('base_rows').execute();
|
||||
await db.schema.dropTable('base_properties').execute();
|
||||
|
||||
await sql`DROP FUNCTION jsonb_set_many(jsonb, jsonb)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_array(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_bool(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_timestamptz(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_numeric(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_text(jsonb, text)`.execute(db);
|
||||
|
||||
await sql`DROP INDEX idx_pages_is_base`.execute(db);
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.dropColumn('base_schema_version')
|
||||
.dropColumn('is_base')
|
||||
.execute();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('spaces')
|
||||
.addColumn('is_personal', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE UNIQUE INDEX spaces_personal_creator_unique
|
||||
ON spaces (creator_id)
|
||||
WHERE is_personal = true AND deleted_at IS NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.dropIndex('spaces_personal_creator_unique')
|
||||
.ifExists()
|
||||
.execute();
|
||||
await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
|
||||
}
|
||||
@@ -14,12 +14,14 @@ type SortField<DB, TB extends keyof DB, O> =
|
||||
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
|
||||
direction: OrderByDirection;
|
||||
orderModifier?: OrderByModifiers;
|
||||
cursorExpression?: ReferenceExpression<DB, TB>;
|
||||
key?: keyof O & string;
|
||||
}
|
||||
| {
|
||||
expression: ReferenceExpression<DB, TB>;
|
||||
direction: OrderByDirection;
|
||||
orderModifier?: OrderByModifiers;
|
||||
cursorExpression?: ReferenceExpression<DB, TB>;
|
||||
key: keyof O & string;
|
||||
};
|
||||
|
||||
@@ -202,11 +204,12 @@ export async function executeWithCursorPagination<
|
||||
|
||||
const comparison = field.direction === defaultDirection ? '>' : '<';
|
||||
const value = cursor[field.key as keyof typeof cursor];
|
||||
const compareExpr = field.cursorExpression ?? field.expression;
|
||||
|
||||
const conditions = [eb(field.expression, comparison, value)];
|
||||
const conditions = [eb(compareExpr, comparison, value)];
|
||||
|
||||
if (expression) {
|
||||
conditions.push(and([eb(field.expression, '=', value), expression]));
|
||||
conditions.push(and([eb(compareExpr, '=', value), expression]));
|
||||
}
|
||||
|
||||
expression = or(conditions);
|
||||
|
||||
@@ -236,6 +236,7 @@ export class FavoriteRepo {
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.isBase',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
|
||||
@@ -38,6 +38,7 @@ export class PageRepo {
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'isLocked',
|
||||
'isBase',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
|
||||
@@ -57,6 +57,22 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findPersonalSpace(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Space | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('creatorId', '=', userId)
|
||||
.where('isPersonal', '=', true)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async slugExists(
|
||||
slug: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -63,11 +63,9 @@ export class TemplateRepo {
|
||||
|
||||
if (opts?.spaceId) {
|
||||
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
||||
query = query.where('spaceId', 'is', null);
|
||||
query = query.where(sql<boolean>`false`);
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
|
||||
);
|
||||
query = query.where('spaceId', '=', opts.spaceId);
|
||||
}
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
|
||||
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateSpaceSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('spaces', COALESCE(settings->'spaces', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+50
@@ -126,6 +126,50 @@ export interface Backlinks {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BaseProperties {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isPrimary: Generated<boolean>;
|
||||
name: string;
|
||||
pageId: string;
|
||||
pendingType: string | null;
|
||||
pendingTypeOptions: Json | null;
|
||||
pendingToken: string | null;
|
||||
position: string;
|
||||
schemaVersion: Generated<number>;
|
||||
type: string;
|
||||
typeOptions: Json | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BaseRows {
|
||||
cells: Generated<Json>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
lastUpdatedById: string | null;
|
||||
pageId: string;
|
||||
position: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BaseViews {
|
||||
config: Generated<Json>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
pageId: string;
|
||||
position: string;
|
||||
type: Generated<string>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Billing {
|
||||
amount: Int8 | null;
|
||||
billingScheme: string | null;
|
||||
@@ -275,6 +319,8 @@ export interface Pages {
|
||||
deletedById: string | null;
|
||||
icon: string | null;
|
||||
id: Generated<string>;
|
||||
isBase: Generated<boolean>;
|
||||
baseSchemaVersion: Generated<number>;
|
||||
isLocked: Generated<boolean>;
|
||||
lastUpdatedById: string | null;
|
||||
parentPageId: string | null;
|
||||
@@ -322,6 +368,7 @@ export interface Spaces {
|
||||
deletedAt: Timestamp | null;
|
||||
description: string | null;
|
||||
id: Generated<string>;
|
||||
isPersonal: Generated<boolean>;
|
||||
logo: string | null;
|
||||
name: string | null;
|
||||
settings: Json | null;
|
||||
@@ -598,6 +645,9 @@ export interface DB {
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
baseProperties: BaseProperties;
|
||||
baseRows: BaseRows;
|
||||
baseViews: BaseViews;
|
||||
billing: Billing;
|
||||
comments: Comments;
|
||||
favorites: Favorites;
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
AiChats,
|
||||
AiChatMessages,
|
||||
Attachments,
|
||||
BaseProperties,
|
||||
BaseRows,
|
||||
BaseViews,
|
||||
Comments,
|
||||
Groups,
|
||||
Labels,
|
||||
@@ -238,3 +241,18 @@ export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
||||
export type Template = Selectable<Templates>;
|
||||
export type InsertableTemplate = Insertable<Templates>;
|
||||
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
||||
|
||||
// Base Property
|
||||
export type BaseProperty = Selectable<BaseProperties>;
|
||||
export type InsertableBaseProperty = Insertable<BaseProperties>;
|
||||
export type UpdatableBaseProperty = Updateable<Omit<BaseProperties, 'id'>>;
|
||||
|
||||
// Base Row
|
||||
export type BaseRow = Selectable<BaseRows>;
|
||||
export type InsertableBaseRow = Insertable<BaseRows>;
|
||||
export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
|
||||
|
||||
// Base View
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
export type InsertableBaseView = Insertable<BaseViews>;
|
||||
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 87ae8ac999...5e9a9b4bcb
@@ -122,6 +122,26 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('AWS_S3_URL');
|
||||
}
|
||||
|
||||
getAzureStorageAccountName(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
|
||||
}
|
||||
|
||||
getAzureStorageContainer(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
|
||||
}
|
||||
|
||||
getAzureStorageAccountKey(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
|
||||
}
|
||||
|
||||
getAzureStorageEndpoint(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
|
||||
}
|
||||
|
||||
getAzureStorageUrl(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_URL');
|
||||
}
|
||||
|
||||
getMailDriver(): string {
|
||||
return this.configService.get<string>('MAIL_DRIVER', 'log');
|
||||
}
|
||||
@@ -332,4 +352,19 @@ export class EnvironmentService {
|
||||
.toLowerCase();
|
||||
return disabled === 'true';
|
||||
}
|
||||
|
||||
isIframeEmbedAllowed(): boolean {
|
||||
const allowed = this.configService
|
||||
.get<string>('IFRAME_EMBED_ALLOWED', 'false')
|
||||
.toLowerCase();
|
||||
return allowed === 'true';
|
||||
}
|
||||
|
||||
getIframeAllowedOrigins(): string[] {
|
||||
const raw = this.configService.get<string>('IFRAME_ALLOWED_ORIGINS', '');
|
||||
return raw
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class EnvironmentVariables {
|
||||
MAIL_DRIVER: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['local', 's3'])
|
||||
@IsIn(['local', 's3', 'azure'])
|
||||
STORAGE_DRIVER: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -82,8 +82,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job) {
|
||||
const fileTaskId = job.data?.fileTaskId;
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
|
||||
fileTaskId
|
||||
? `Error processing ${job.name} job. File Task ID: ${fileTaskId}. Reason: ${job.failedReason}`
|
||||
: `Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
@@ -97,8 +100,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job) {
|
||||
const fileTaskId = job.data?.fileTaskId;
|
||||
this.logger.log(
|
||||
`Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
|
||||
fileTaskId
|
||||
? `Completed ${job.name} job for File task ID ${fileTaskId}`
|
||||
: `Completed ${job.name} job`,
|
||||
);
|
||||
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum QueueName {
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
AUDIT_QUEUE = '{audit-queue}',
|
||||
BASE_QUEUE = '{base-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -84,4 +85,8 @@ export enum QueueJob {
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
|
||||
BASE_TYPE_CONVERSION = 'base-type-conversion',
|
||||
BASE_CELL_GC = 'base-cell-gc',
|
||||
BASE_FORMULA_RECOMPUTE = 'base-formula-recompute',
|
||||
}
|
||||
|
||||
@@ -113,3 +113,47 @@ export interface IApprovalRejectedNotificationJob {
|
||||
requestedById: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface IBaseTypeConversionJob {
|
||||
pageId: string;
|
||||
propertyId: string;
|
||||
workspaceId: string;
|
||||
fromType: string;
|
||||
toType: string;
|
||||
// Snapshots taken at enqueue time so the job stays correct even if the
|
||||
// property's current typeOptions drift while the job waits in the queue.
|
||||
fromTypeOptions: unknown;
|
||||
toTypeOptions: unknown;
|
||||
// When true, the job nulls the cell values for that property instead of
|
||||
// attempting a value conversion. Used for any conversion where the new
|
||||
// type has no meaningful representation of the old value (e.g. involving
|
||||
// a system type).
|
||||
clearMode: boolean;
|
||||
// Staging identity: guards redelivery and failure cleanup against a
|
||||
// same-type re-stage made after this job was enqueued.
|
||||
pendingToken: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface IBaseCellGcJob {
|
||||
pageId: string;
|
||||
propertyId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IBaseFormulaRecomputeJob {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
propertyIds: string[]; // formula properties to recompute
|
||||
reason:
|
||||
| 'formula_created'
|
||||
| 'formula_edited'
|
||||
| 'dep_type_changed'
|
||||
| 'dep_deleted'
|
||||
| 'bulk_import'
|
||||
| 'manual';
|
||||
actorId?: string | null;
|
||||
// When set, scope recompute to these row IDs instead of the whole base.
|
||||
// Used by the bulk-write path (> FORMULA_INLINE_ROW_THRESHOLD).
|
||||
rowIds?: string[];
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.BASE_QUEUE,
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
removeOnComplete: { count: 200 },
|
||||
removeOnFail: { count: 100 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [GeneralQueueProcessor],
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Readable } from 'stream';
|
||||
import {
|
||||
AzureStorageConfig,
|
||||
StorageDriver,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import {
|
||||
BlobSASPermissions,
|
||||
BlobServiceClient,
|
||||
BlockBlobClient,
|
||||
ContainerClient,
|
||||
generateBlobSASQueryParameters,
|
||||
SASProtocol,
|
||||
StorageSharedKeyCredential,
|
||||
} from '@azure/storage-blob';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { getMimeType } from '../../../common/helpers';
|
||||
|
||||
export class AzureDriver implements StorageDriver {
|
||||
private readonly config: AzureStorageConfig;
|
||||
private readonly blobServiceClient: BlobServiceClient;
|
||||
private readonly containerClient: ContainerClient;
|
||||
private readonly sharedKeyCredential: StorageSharedKeyCredential;
|
||||
private readonly accountUrl: string;
|
||||
|
||||
constructor(config: AzureStorageConfig) {
|
||||
this.config = config;
|
||||
|
||||
if (!config.accountName) {
|
||||
throw new Error('AzureDriver: accountName is required');
|
||||
}
|
||||
if (!config.container) {
|
||||
throw new Error('AzureDriver: container is required');
|
||||
}
|
||||
if (!config.accountKey) {
|
||||
throw new Error('AzureDriver: accountKey is required');
|
||||
}
|
||||
|
||||
this.accountUrl =
|
||||
config.endpoint ??
|
||||
`https://${config.accountName}.blob.core.windows.net`;
|
||||
|
||||
this.sharedKeyCredential = new StorageSharedKeyCredential(
|
||||
config.accountName,
|
||||
config.accountKey,
|
||||
);
|
||||
|
||||
this.blobServiceClient = this.createBlobServiceClient();
|
||||
this.containerClient = this.blobServiceClient.getContainerClient(
|
||||
config.container,
|
||||
);
|
||||
}
|
||||
|
||||
private blockBlob(filePath: string): BlockBlobClient {
|
||||
return this.containerClient.getBlockBlobClient(filePath);
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
const stream: Readable = Buffer.isBuffer(file) ? Readable.from(file) : file;
|
||||
await this.uploadStream(filePath, stream);
|
||||
}
|
||||
|
||||
async uploadStream(
|
||||
filePath: string,
|
||||
file: Readable,
|
||||
options?: { recreateClient?: boolean },
|
||||
): Promise<void> {
|
||||
const clientToUse = options?.recreateClient
|
||||
? this.createBlobServiceClient()
|
||||
.getContainerClient(this.config.container)
|
||||
.getBlockBlobClient(filePath)
|
||||
: this.blockBlob(filePath);
|
||||
|
||||
try {
|
||||
const contentType = getMimeType(filePath);
|
||||
await clientToUse.uploadStream(file, undefined, undefined, {
|
||||
blobHTTPHeaders: { blobContentType: contentType },
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error(err);
|
||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
|
||||
try {
|
||||
if (!(await this.exists(fromFilePath))) {
|
||||
return;
|
||||
}
|
||||
const sourceUrl = await this.getSignedUrl(fromFilePath, 60);
|
||||
const dest = this.blockBlob(toFilePath);
|
||||
await dest.syncCopyFromURL(sourceUrl);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to copy file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async read(filePath: string): Promise<Buffer> {
|
||||
try {
|
||||
return await this.blockBlob(filePath).downloadToBuffer();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readStream(filePath: string): Promise<Readable> {
|
||||
try {
|
||||
const response = await this.blockBlob(filePath).download();
|
||||
return response.readableStreamBody as Readable;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
try {
|
||||
const count = range.end - range.start + 1;
|
||||
const response = await this.blockBlob(filePath).download(
|
||||
range.start,
|
||||
count,
|
||||
);
|
||||
return response.readableStreamBody as Readable;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.blockBlob(filePath).exists();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to check existence in Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getUrl(filePath: string): string {
|
||||
const base = this.config.baseUrl ?? this.accountUrl;
|
||||
return `${base}/${this.config.container}/${filePath}`;
|
||||
}
|
||||
|
||||
async getSignedUrl(filePath: string, expiresIn: number): Promise<string> {
|
||||
const expiresOn = new Date(Date.now() + expiresIn * 1000);
|
||||
const sas = generateBlobSASQueryParameters(
|
||||
{
|
||||
containerName: this.config.container,
|
||||
blobName: filePath,
|
||||
permissions: BlobSASPermissions.parse('r'),
|
||||
expiresOn,
|
||||
protocol: SASProtocol.HttpsAndHttp,
|
||||
},
|
||||
this.sharedKeyCredential,
|
||||
).toString();
|
||||
return `${this.accountUrl}/${this.config.container}/${filePath}?${sas}`;
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
try {
|
||||
await this.blockBlob(filePath).delete();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Error deleting file ${filePath} from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDriver(): BlobServiceClient {
|
||||
return this.blobServiceClient;
|
||||
}
|
||||
|
||||
getDriverName(): string {
|
||||
return StorageOption.AZURE;
|
||||
}
|
||||
|
||||
getConfig(): Record<string, any> {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private createBlobServiceClient(): BlobServiceClient {
|
||||
return new BlobServiceClient(this.accountUrl, this.sharedKeyCredential);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LocalDriver } from './local.driver';
|
||||
export { S3Driver } from './s3.driver';
|
||||
export { AzureDriver } from './azure.driver';
|
||||
|
||||
@@ -3,11 +3,13 @@ import { S3ClientConfig } from '@aws-sdk/client-s3';
|
||||
export enum StorageOption {
|
||||
LOCAL = 'local',
|
||||
S3 = 's3',
|
||||
AZURE = 'azure',
|
||||
}
|
||||
|
||||
export type StorageConfig =
|
||||
| { driver: StorageOption.LOCAL; config: LocalStorageConfig }
|
||||
| { driver: StorageOption.S3; config: S3StorageConfig };
|
||||
| { driver: StorageOption.S3; config: S3StorageConfig }
|
||||
| { driver: StorageOption.AZURE; config: AzureStorageConfig };
|
||||
|
||||
export interface LocalStorageConfig {
|
||||
storagePath: string;
|
||||
@@ -20,6 +22,14 @@ export interface S3StorageConfig
|
||||
baseUrl?: string; // Optional CDN URL for assets
|
||||
}
|
||||
|
||||
export interface AzureStorageConfig {
|
||||
accountName: string;
|
||||
container: string;
|
||||
accountKey: string;
|
||||
endpoint?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface StorageOptions {
|
||||
disk: StorageConfig;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
} from '../constants/storage.constants';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import {
|
||||
AzureStorageConfig,
|
||||
LocalStorageConfig,
|
||||
S3StorageConfig,
|
||||
StorageConfig,
|
||||
StorageDriver,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import { LocalDriver, S3Driver } from '../drivers';
|
||||
import { AzureDriver, LocalDriver, S3Driver } from '../drivers';
|
||||
import * as process from 'node:process';
|
||||
import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
|
||||
import path from 'path';
|
||||
@@ -21,6 +22,8 @@ function createStorageDriver(disk: StorageConfig): StorageDriver {
|
||||
return new LocalDriver(disk.config as LocalStorageConfig);
|
||||
case StorageOption.S3:
|
||||
return new S3Driver(disk.config as S3StorageConfig);
|
||||
case StorageOption.AZURE:
|
||||
return new AzureDriver(disk.config as AzureStorageConfig);
|
||||
default:
|
||||
throw new Error(`Unknown storage driver`);
|
||||
}
|
||||
@@ -70,6 +73,18 @@ export const storageDriverConfigProvider = {
|
||||
|
||||
return s3Config; }
|
||||
|
||||
case StorageOption.AZURE:
|
||||
return {
|
||||
driver,
|
||||
config: {
|
||||
accountName: environmentService.getAzureStorageAccountName(),
|
||||
container: environmentService.getAzureStorageContainer(),
|
||||
accountKey: environmentService.getAzureStorageAccountKey(),
|
||||
endpoint: environmentService.getAzureStorageEndpoint() || undefined,
|
||||
baseUrl: environmentService.getAzureStorageUrl() || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown storage driver: ${driver}`);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyIp from 'fastify-ip';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||
import { resolveFrameHeader } from './common/helpers';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -50,6 +52,28 @@ async function bootstrap() {
|
||||
await app.register(fastifyMultipart);
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const environmentService = app.get(EnvironmentService);
|
||||
const frameHeader = resolveFrameHeader(
|
||||
environmentService.isIframeEmbedAllowed(),
|
||||
environmentService.getIframeAllowedOrigins(),
|
||||
);
|
||||
if (frameHeader) {
|
||||
// Skipped routes:
|
||||
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
||||
// /share/ 0 public share pages are safe to embed
|
||||
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addHook('onSend', (req, reply, payload, done) => {
|
||||
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
|
||||
return done(null, payload);
|
||||
}
|
||||
reply.header(frameHeader.name, frameHeader.value);
|
||||
done(null, payload);
|
||||
});
|
||||
}
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRealtimeBridge {
|
||||
private readonly logger = new Logger(BaseRealtimeBridge.name);
|
||||
private resolved = false;
|
||||
private svc: any = null;
|
||||
|
||||
constructor(private readonly moduleRef: ModuleRef) {}
|
||||
|
||||
protected loadServiceClass(): any {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('../ee/base/realtime/base-ws.service').BaseWsService;
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
'Base realtime requested but enterprise module not bundled in this build',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolve(): any {
|
||||
if (this.resolved) return this.svc;
|
||||
this.resolved = true;
|
||||
const ServiceClass = this.loadServiceClass();
|
||||
if (!ServiceClass) return null;
|
||||
this.svc = this.moduleRef.get(ServiceClass, { strict: false });
|
||||
return this.svc;
|
||||
}
|
||||
|
||||
setServer(server: Server): void {
|
||||
this.resolve()?.setServer(server);
|
||||
}
|
||||
|
||||
isBaseEvent(data: any): boolean {
|
||||
return this.resolve()?.isBaseEvent(data) ?? false;
|
||||
}
|
||||
|
||||
async handleInbound(client: Socket, data: any): Promise<void> {
|
||||
await this.resolve()?.handleInbound(client, data);
|
||||
}
|
||||
|
||||
async handleDisconnect(client: Socket): Promise<void> {
|
||||
await this.resolve()?.handleDisconnect(client);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
@@ -13,6 +14,7 @@ import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { WsService } from './ws.service';
|
||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
||||
import { BaseRealtimeBridge } from './base-realtime.bridge';
|
||||
import * as cookie from 'cookie';
|
||||
|
||||
@WebSocketGateway({
|
||||
@@ -20,7 +22,11 @@ import * as cookie from 'cookie';
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class WsGateway
|
||||
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
||||
implements
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
OnModuleDestroy
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
@@ -29,10 +35,12 @@ export class WsGateway
|
||||
private tokenService: TokenService,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private wsService: WsService,
|
||||
private baseRealtime: BaseRealtimeBridge,
|
||||
) {}
|
||||
|
||||
afterInit(server: Server): void {
|
||||
this.wsService.setServer(server);
|
||||
this.baseRealtime.setServer(server);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||
@@ -47,6 +55,7 @@ export class WsGateway
|
||||
const workspaceId = token.workspaceId;
|
||||
|
||||
client.data.userId = userId;
|
||||
client.data.workspaceId = workspaceId;
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
|
||||
@@ -61,10 +70,19 @@ export class WsGateway
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(client: Socket): Promise<void> {
|
||||
await this.baseRealtime.handleDisconnect(client);
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(client: Socket, data: any): Promise<void> {
|
||||
if (this.wsService.isTreeEvent(data)) {
|
||||
await this.wsService.handleTreeEvent(client, data);
|
||||
return;
|
||||
}
|
||||
if (this.baseRealtime.isBaseEvent(data)) {
|
||||
await this.baseRealtime.handleInbound(client, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { WsGateway } from './ws.gateway';
|
||||
import { WsService } from './ws.service';
|
||||
import { WsTreeService } from './ws-tree.service';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { BaseRealtimeBridge } from './base-realtime.bridge';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
providers: [WsGateway, WsService, WsTreeService],
|
||||
providers: [WsGateway, WsService, WsTreeService, BaseRealtimeBridge],
|
||||
exports: [WsGateway, WsService, WsTreeService],
|
||||
})
|
||||
export class WsModule {}
|
||||
|
||||
@@ -16,3 +16,14 @@ export const TREE_EVENTS = new Set([
|
||||
'deleteTreeNode',
|
||||
'refetchRootTreeNodeEvent',
|
||||
]);
|
||||
|
||||
export function getBaseRoomName(pageId: string): string {
|
||||
return `base-${pageId}`;
|
||||
}
|
||||
|
||||
export const BASE_INBOUND_EVENTS = new Set([
|
||||
'base:subscribe',
|
||||
'base:unsubscribe',
|
||||
'base:presence',
|
||||
'base:presence:leave',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user