- public attachment links

- WIP
This commit is contained in:
Philipinho
2025-04-10 17:57:02 +01:00
parent b5c90ec775
commit 4f52d32bef
20 changed files with 713 additions and 402 deletions

View File

@ -26,6 +26,7 @@ api.interceptors.response.use(
case 401: {
const url = new URL(error.request.responseURL)?.pathname;
if (url === "/api/auth/collab-token") return;
if (window.location.pathname.startsWith("/share/")) return;
// Handle unauthorized error
redirectToLogin();

View File

@ -37,7 +37,6 @@ export default function SharedPage() {
}
return (
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
@ -51,6 +50,5 @@ export default function SharedPage() {
/>
</Container>
</div>
)
);
}

View File

@ -1,5 +1,6 @@
import { Node } from '@tiptap/pm/model';
import { jsonToNode } from '../../../collaboration/collaboration.util';
import { validate as isValidUUID } from 'uuid';
export interface MentionNode {
id: string;
@ -56,3 +57,41 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
}
return pageMentionList as MentionNode[];
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
type: 'doc',
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
}
);
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
const attachmentIds = [];
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
if (!attachmentIds.includes(node.attrs.attachmentId)) {
attachmentIds.push(node.attrs.attachmentId);
}
}
}
});
return attachmentIds;
}

View File

@ -9,25 +9,26 @@ import {
NotFoundException,
Param,
Post,
Query,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {AttachmentService} from './services/attachment.service';
import {FastifyReply} from 'fastify';
import {FileInterceptor} from '../../common/interceptors/file.interceptor';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } 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 {StorageService} from '../../integrations/storage/storage.service';
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 { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
validAttachmentTypes,
} from './attachment.utils';
import {getMimeType} from '../../common/helpers';
import { getMimeType } from '../../common/helpers';
import {
AttachmentType,
inlineFileExtensions,
@ -43,10 +44,12 @@ import {
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {PageRepo} from '@docmost/db/repos/page/page.repo';
import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo';
import {validate as isValidUUID} from 'uuid';
import {EnvironmentService} from "../../integrations/environment/environment.service";
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
@Controller()
export class AttachmentController {
@ -60,8 +63,8 @@ export class AttachmentController {
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
) {
}
private readonly tokenService: TokenService,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ -78,7 +81,7 @@ export class AttachmentController {
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
@ -195,6 +198,66 @@ export class AttachmentController {
}
}
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
@Query('jwt') jwtToken?: string,
) {
let jwtPayload: JwtAttachmentPayload = null;
try {
jwtPayload = await this.tokenService.verifyJwt(
jwtToken,
JwtType.ATTACHMENT,
);
} catch (err) {
throw new BadRequestException(
'Expired or invalid attachment access token',
);
}
if (
!isValidUUID(fileId) ||
fileId !== jwtPayload.attachmentId ||
jwtPayload.workspaceId !== workspace.id
) {
throw new NotFoundException('File not found');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId ||
jwtPayload.pageId !== attachment.pageId
) {
throw new NotFoundException('File not found');
}
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);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@ -210,7 +273,7 @@ export class AttachmentController {
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {

View File

@ -5,9 +5,10 @@ import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [StorageModule, UserModule, WorkspaceModule],
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule],
controllers: [AttachmentController],
providers: [AttachmentService, AttachmentProcessor],
})

View File

@ -2,6 +2,7 @@ export enum JwtType {
ACCESS = 'access',
COLLAB = 'collab',
EXCHANGE = 'exchange',
ATTACHMENT = 'attachment',
}
export type JwtPayload = {
sub: string;
@ -21,3 +22,11 @@ export type JwtExchangePayload = {
workspaceId: string;
type: 'exchange';
};
export type JwtAttachmentPayload = {
attachmentId: string;
pageId: string;
workspaceId: string;
type: 'attachment';
};

View File

@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtAttachmentPayload,
JwtCollabPayload,
JwtExchangePayload,
JwtPayload,
@ -59,6 +60,21 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '10s' });
}
async generateAttachmentToken(opts: {
attachmentId: string;
pageId: string;
workspaceId: string;
}): Promise<string> {
const { attachmentId, pageId, workspaceId } = opts;
const payload: JwtAttachmentPayload = {
attachmentId: attachmentId,
pageId: pageId,
workspaceId: workspaceId,
type: JwtType.ATTACHMENT,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),

View File

@ -45,6 +45,7 @@ function buildSpaceAdminAbility() {
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
@ -55,6 +56,7 @@ function buildSpaceWriterAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
@ -65,5 +67,6 @@ function buildSpaceReaderAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
}

View File

@ -9,9 +9,11 @@ export enum SpaceCaslSubject {
Settings = 'settings',
Member = 'member',
Page = 'page',
Share = 'share',
}
export type ISpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page];
| [SpaceCaslAction, SpaceCaslSubject.Page]
| [SpaceCaslAction, SpaceCaslSubject.Share];

View File

@ -1,8 +1,7 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateShareDto } from './create-share.dto';
import { IsString } from 'class-validator';
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateShareDto extends PartialType(CreateShareDto) {
//@IsString()
//pageId: string;
export class UpdateShareDto {
@IsString()
@IsNotEmpty()
shareId: string;
}

View File

@ -23,6 +23,8 @@ import { ShareIdDto, ShareInfoDto } from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@UseGuards(JwtAuthGuard)
@Controller('shares')
@ -30,14 +32,27 @@ export class ShareController {
constructor(
private readonly shareService: ShareService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getShares(
@AuthUser() user: User,
@Body() pagination: PaginationOptions,
) {
return this.shareRepo.getShares(user.id, pagination);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/info')
async getPage(@Body() dto: ShareInfoDto) {
return this.shareService.getShare(dto);
async getShare(
@Body() dto: ShareInfoDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.shareService.getShare(dto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@ -47,15 +62,14 @@ export class ShareController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(createShareDto.pageId);
if (!page) {
if (!page || workspace.id !== page.workspaceId) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
@ -63,43 +77,41 @@ export class ShareController {
pageId: page.id,
authUserId: user.id,
workspaceId: workspace.id,
spaceId: page.spaceId,
});
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() updatePageDto: UpdateShareDto, @AuthUser() user: User) {
/* const page = await this.pageRepo.findById(updatePageDto.pageId);
async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) {
const share = await this.shareRepo.findById(updateShareDto.shareId);
if (!page) {
throw new NotFoundException('Page not found');
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
//return this.shareService.update(page, updatePageDto, user.id);
*/
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
/* const page = await this.pageRepo.findById(pageIdDto.pageId);
const share = await this.shareRepo.findById(shareIdDto.shareId);
if (!page) {
throw new NotFoundException('Page not found');
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
*/
// await this.shareService.forceDelete(pageIdDto.pageId);
await this.shareRepo.deleteShare(share.id);
}
}

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [TokenModule],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],

View File

@ -1,48 +1,61 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ShareInfoDto } from './dto/share.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId } from '../../common/helpers';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { TokenService } from '../auth/services/token.service';
import { jsonToNode } from '../../collaboration/collaboration.util';
import {
getAttachmentIds,
isAttachmentNode,
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
@Injectable()
export class ShareService {
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
async createShare(opts: {
authUserId: string;
workspaceId: string;
pageId: string;
spaceId: string;
}) {
const { authUserId, workspaceId, pageId } = opts;
const slugId = generateSlugId(); // or custom slug
const share = this.db
.insertInto('shares')
.values({ slugId: slugId, pageId, creatorId: authUserId, workspaceId })
.returningAll()
.executeTakeFirst();
const { authUserId, workspaceId, pageId, spaceId } = opts;
let share = null;
try {
const slugId = generateSlugId();
share = await this.shareRepo.insertShare({
slugId,
pageId,
workspaceId,
creatorId: authUserId,
spaceId: spaceId,
});
} catch (err) {
throw new BadRequestException('Failed to share page');
}
return share;
}
async getShare(dto: ShareInfoDto) {
// for now only single page share
async getShare(dto: ShareInfoDto, workspaceId: string) {
const share = await this.shareRepo.findById(dto.shareId);
// if only share Id is provided, return
// if share id is pass with page id, what to do?
// if uuid is used, use Id
const share = await this.db
.selectFrom('shares')
.selectAll()
.where('slugId', '=', dto.shareId)
.executeTakeFirst();
if (!share) {
if (!share || share.workspaceId !== workspaceId) {
throw new NotFoundException('Share not found');
}
@ -51,10 +64,7 @@ export class ShareService {
includeCreator: true,
});
// cleanup json content
// remove comments mark
// make sure attachments work (videos, images, excalidraw, drawio)
// figure out internal links?
page.content = await this.updatePublicAttachments(page);
if (!page) {
throw new NotFoundException('Page not found');
@ -62,4 +72,35 @@ export class ShareService {
return page;
}
async updatePublicAttachments(page: Page): Promise<any> {
const attachmentIds = getAttachmentIds(page.content);
const attachmentMap = new Map<string, string>();
await Promise.all(
attachmentIds.map(async (attachmentId: string) => {
const token = await this.tokenService.generateAttachmentToken({
attachmentId,
pageId: page.id,
workspaceId: page.workspaceId,
});
attachmentMap.set(attachmentId, token);
}),
);
const doc = jsonToNode(page.content as any);
doc?.descendants((node: Node) => {
if (!isAttachmentNode(node.type.name)) return;
const attachmentId = node.attrs.attachmentId;
const token = attachmentMap.get(attachmentId);
if (!token) return;
updateAttachmentAttr(node, 'src', token);
updateAttachmentAttr(node, 'url', token);
});
return doc.toJSON();
}
}

View File

@ -0,0 +1,22 @@
import { Node } from '@tiptap/pm/model';
export function updateAttachmentAttr(
node: Node,
attr: 'src' | 'url',
token: string,
) {
const attrVal = node.attrs[attr];
if (
attrVal &&
(attrVal.startsWith('/files') || attrVal.startsWith('/api/files'))
) {
// @ts-ignore
node.attrs[attr] = updateAttachmentUrl(attrVal, token);
}
}
function updateAttachmentUrl(src: string, jwtToken: string) {
const updatedSrc = src.replace('/files/', '/files/public/');
const separator = updatedSrc.includes('?') ? '&' : '?';
return `${updatedSrc}${separator}jwt=${jwtToken}`;
}

View File

@ -24,6 +24,7 @@ import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
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';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
exports: [
WorkspaceRepo,
@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
})
export class DatabaseModule

View File

@ -7,15 +7,14 @@ export async function up(db: Kysely<any>): Promise<void> {
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('slug_id', 'varchar', (col) => col.notNull())
.addColumn('page_id', 'varchar', (col) => col.notNull())
.addColumn('include_sub_pages', 'varchar', (col) => col)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
// pageSlug
//.addColumn('space_id', 'uuid', (col) =>
// col.references('spaces.id').onDelete('cascade').notNull(),
// )
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)

View File

@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertableShare,
Share,
UpdatableShare,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class ShareRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private spaceMemberRepo: SpaceMemberRepo,
) {}
private baseFields: Array<keyof Share> = [
'id',
'slugId',
'pageId',
'includeSubPages',
'creatorId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
shareId: string,
opts?: {
includeCreator?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
): Promise<Share> {
const db = dbOrTx(this.db, opts?.trx);
let query = db.selectFrom('shares').select(this.baseFields);
if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb));
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
if (isValidUUID(shareId)) {
query = query.where('id', '=', shareId);
} else {
query = query.where('slugId', '=', shareId);
}
return query.executeTakeFirst();
}
async updateShare(
updatableShare: UpdatableShare,
shareId: string,
trx?: KyselyTransaction,
) {
return dbOrTx(this.db, trx)
.updateTable('shares')
.set({ ...updatableShare, updatedAt: new Date() })
.where(!isValidUUID(shareId) ? 'slugId' : 'id', '=', shareId)
.executeTakeFirst();
}
async insertShare(
insertableShare: InsertableShare,
trx?: KyselyTransaction,
): Promise<Share> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('shares')
.values(insertableShare)
.returning(this.baseFields)
.executeTakeFirst();
}
async deleteShare(shareId: string): Promise<void> {
let query = this.db.deleteFrom('shares');
if (isValidUUID(shareId)) {
query = query.where('id', '=', shareId);
} else {
query = query.where('slugId', '=', shareId);
}
await query.execute();
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withSpace(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
.whereRef('spaces.id', '=', 'shares.spaceId'),
).as('space');
}
withCreator(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'shares.creatorId'),
).as('creator');
}
}

View File

@ -188,9 +188,10 @@ export interface Shares {
creatorId: string | null;
deletedAt: Timestamp | null;
id: Generated<string>;
includeSubPages: string | null;
pageId: string;
includeSubPages: Generated<boolean | null>;
pageId: string | null;
slugId: string;
spaceId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}

View File

@ -15,10 +15,8 @@ import { StorageService } from '../storage/storage.service';
import {
buildTree,
computeLocalPath,
getAttachmentIds,
getExportExtension,
getPageTitle,
getProsemirrorContent,
PageExportTree,
replaceInternalLinks,
updateAttachmentUrls,
@ -29,6 +27,7 @@ import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import { EnvironmentService } from '../environment/environment.service';
import { getAttachmentIds, getProsemirrorContent } from '../../common/helpers/prosemirror/utils';
@Injectable()
export class ExportService {
@ -77,7 +76,10 @@ export class ExportService {
}
if (format === ExportFormat.Markdown) {
const newPageHtml = pageHtml.replace(/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gmi, '');
const newPageHtml = pageHtml.replace(
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
'',
);
return turndown(newPageHtml);
}
@ -260,14 +262,7 @@ export class ExportService {
const pages = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'creatorId',
'spaceId',
'workspaceId',
])
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where('id', 'in', pageMentionIds)
.where('workspaceId', '=', workspaceId)

View File

@ -4,6 +4,7 @@ import { Node } from '@tiptap/pm/model';
import { validate as isValidUUID } from 'uuid';
import * as path from 'path';
import { Page } from '@docmost/db/types/entity.types';
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
export type PageExportTree = Record<string, Page[]>;
@ -25,43 +26,6 @@ export function getPageTitle(title: string) {
return title ? title : 'untitled';
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
type: 'doc',
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
}
);
}
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
const attachmentIds = [];
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
if (!attachmentIds.includes(node.attrs.attachmentId)) {
attachmentIds.push(node.attrs.attachmentId);
}
}
}
});
return attachmentIds;
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export function updateAttachmentUrls(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);