274 lines
7.1 KiB
TypeScript
274 lines
7.1 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
import dayjs from 'dayjs'
|
|
import { render } from 'ejs'
|
|
import JSZip from 'jszip'
|
|
import { isNil } from 'lodash'
|
|
import { join } from 'path'
|
|
import { performance } from 'perf_hooks'
|
|
import { Readable } from 'stream'
|
|
import { URL } from 'url'
|
|
import xss from 'xss'
|
|
|
|
import {
|
|
Body,
|
|
CacheTTL,
|
|
Controller,
|
|
ForbiddenException,
|
|
Get,
|
|
Header,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
} from '@nestjs/common'
|
|
import { ApiProperty } from '@nestjs/swagger'
|
|
|
|
import { Auth } from '~/common/decorator/auth.decorator'
|
|
import { HttpCache } from '~/common/decorator/cache.decorator'
|
|
import { HTTPDecorators } from '~/common/decorator/http.decorator'
|
|
import { ApiName } from '~/common/decorator/openapi.decorator'
|
|
import { IsMaster } from '~/common/decorator/role.decorator'
|
|
import { ArticleTypeEnum } from '~/constants/article.constant'
|
|
import { MongoIdDto } from '~/shared/dto/id.dto'
|
|
import { getShortDateTime } from '~/utils'
|
|
|
|
import { CategoryModel } from '../category/category.model'
|
|
import { ConfigsService } from '../configs/configs.service'
|
|
import { NoteModel } from '../note/note.model'
|
|
import { PageModel } from '../page/page.model'
|
|
import { PostModel } from '../post/post.model'
|
|
import {
|
|
DataListDto,
|
|
ExportMarkdownQueryDto,
|
|
MarkdownPreviewDto,
|
|
} from './markdown.dto'
|
|
import { MarkdownYAMLProperty } from './markdown.interface'
|
|
import { MarkdownService } from './markdown.service'
|
|
|
|
@Controller('markdown')
|
|
@ApiName
|
|
export class MarkdownController {
|
|
constructor(
|
|
private readonly service: MarkdownService,
|
|
|
|
private readonly configs: ConfigsService,
|
|
) {}
|
|
|
|
@Post('/import')
|
|
@Auth()
|
|
@ApiProperty({ description: '导入 Markdown YAML 数据' })
|
|
async importArticle(@Body() body: DataListDto) {
|
|
const type = body.type
|
|
|
|
switch (type) {
|
|
case ArticleTypeEnum.Post: {
|
|
return await this.service.insertPostsToDb(body.data)
|
|
}
|
|
case ArticleTypeEnum.Note: {
|
|
return await this.service.insertNotesToDb(body.data)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Get('/export')
|
|
@Auth()
|
|
@ApiProperty({ description: '导出 Markdown YAML 数据' })
|
|
@HTTPDecorators.Bypass
|
|
@Header('Content-Type', 'application/zip')
|
|
async exportArticleToMarkdown(@Query() query: ExportMarkdownQueryDto) {
|
|
const { show_title: showTitle, slug, yaml } = query
|
|
const allArticles = await this.service.extractAllArticle()
|
|
const { notes, pages, posts } = allArticles
|
|
|
|
const convertor = <
|
|
T extends {
|
|
text: string
|
|
created?: Date
|
|
modified?: Date | null
|
|
title: string
|
|
slug?: string
|
|
},
|
|
>(
|
|
item: T,
|
|
extraMetaData: Record<string, any> = {},
|
|
): MarkdownYAMLProperty => {
|
|
const meta = {
|
|
created: item.created!,
|
|
modified: item.modified,
|
|
title: item.title,
|
|
slug: item.slug || item.title,
|
|
...extraMetaData,
|
|
}
|
|
return {
|
|
meta,
|
|
text: this.service.markdownBuilder(
|
|
{ meta, text: item.text },
|
|
yaml,
|
|
showTitle,
|
|
),
|
|
}
|
|
}
|
|
// posts
|
|
const convertPost = posts.map((post) =>
|
|
convertor(post!, {
|
|
categories: (post.category as CategoryModel).name,
|
|
type: 'post',
|
|
permalink: `posts/${post.slug}`,
|
|
}),
|
|
)
|
|
const convertNote = notes.map((note) =>
|
|
convertor(note!, {
|
|
mood: note.mood,
|
|
weather: note.weather,
|
|
id: note.nid,
|
|
permalink: `notes/${note.nid}`,
|
|
type: 'note',
|
|
slug: note.nid.toString(),
|
|
}),
|
|
)
|
|
const convertPage = pages.map((page) =>
|
|
convertor(page!, {
|
|
subtitle: page.subtitle,
|
|
type: 'page',
|
|
permalink: page.slug,
|
|
}),
|
|
)
|
|
|
|
// zip
|
|
const map = {
|
|
posts: convertPost,
|
|
pages: convertPage,
|
|
notes: convertNote,
|
|
}
|
|
|
|
const rtzip = new JSZip()
|
|
|
|
await Promise.all(
|
|
Object.entries(map).map(async ([key, arr]) => {
|
|
const zip = await this.service.generateArchive({
|
|
documents: arr,
|
|
options: {
|
|
slug,
|
|
},
|
|
})
|
|
|
|
zip.forEach(async (relativePath, file) => {
|
|
rtzip.file(join(key, relativePath), file.nodeStream())
|
|
})
|
|
}),
|
|
)
|
|
|
|
const readable = new Readable()
|
|
readable.push(await rtzip.generateAsync({ type: 'nodebuffer' }))
|
|
readable.push(null)
|
|
|
|
return readable
|
|
}
|
|
|
|
@Get('/render/:id')
|
|
@Header('content-type', 'text/html')
|
|
@HTTPDecorators.Bypass
|
|
@CacheTTL(60 * 60)
|
|
async renderArticle(
|
|
@Param() params: MongoIdDto,
|
|
@Query('theme') theme: string,
|
|
@IsMaster() isMaster: boolean,
|
|
) {
|
|
const { id } = params
|
|
const now = performance.now()
|
|
const [
|
|
{ html: markdown, document, type },
|
|
{
|
|
url: { webUrl },
|
|
},
|
|
{ name: username },
|
|
] = await Promise.all([
|
|
this.service.renderArticle(id),
|
|
this.configs.waitForConfigReady(),
|
|
this.configs.getMaster(),
|
|
])
|
|
|
|
if (!isMaster) {
|
|
if (
|
|
('hide' in document && document.hide) ||
|
|
('password' in document && !isNil(document.password))
|
|
) {
|
|
throw new ForbiddenException('该文章已隐藏或加密')
|
|
}
|
|
}
|
|
|
|
const relativePath = (() => {
|
|
switch (type.toLowerCase()) {
|
|
case 'post':
|
|
return `/posts/${((document as PostModel).category as any).slug}/${
|
|
(document as PostModel).slug
|
|
}`
|
|
case 'note':
|
|
return `/notes/${(document as NoteModel).nid}`
|
|
case 'page':
|
|
return `/${(document as PageModel).slug}`
|
|
}
|
|
})()
|
|
|
|
const url = new URL(relativePath!, webUrl)
|
|
|
|
const structure = await this.service.getRenderedMarkdownHtmlStructure(
|
|
markdown,
|
|
document.title,
|
|
theme,
|
|
)
|
|
|
|
const html = render(await this.service.getMarkdownEjsRenderTemplate(), {
|
|
...structure,
|
|
|
|
title: document.title,
|
|
footer: `<p>本文渲染于 ${getShortDateTime(
|
|
new Date(),
|
|
)},由 marked.js 解析生成,用时 ${(performance.now() - now).toFixed(
|
|
2,
|
|
)}ms</p>
|
|
<p>作者:${username},撰写于${dayjs(document.created).format('llll')}</p>
|
|
<p>原文地址:<a href="${url}">${decodeURIComponent(
|
|
url.toString(),
|
|
)}</a></p>
|
|
`,
|
|
})
|
|
|
|
return html.trim()
|
|
}
|
|
|
|
/**
|
|
* 后台预览 Markdown 可用接口, 传入 `title` 和 `md`
|
|
*/
|
|
@Post('/render')
|
|
@HttpCache.disable
|
|
@Auth()
|
|
@HTTPDecorators.Bypass
|
|
@Header('content-type', 'text/html')
|
|
async markdownPreview(
|
|
@Body() body: MarkdownPreviewDto,
|
|
@Query('theme') theme: string,
|
|
) {
|
|
const { md, title } = body
|
|
const html = this.service.renderMarkdownContent(md)
|
|
const structure = await this.service.getRenderedMarkdownHtmlStructure(
|
|
html,
|
|
title,
|
|
theme,
|
|
)
|
|
return render(await this.service.getMarkdownEjsRenderTemplate(), {
|
|
...structure,
|
|
|
|
title: xss(title),
|
|
}).trim()
|
|
}
|
|
|
|
@Get('/render/structure/:id')
|
|
@CacheTTL(60 * 60)
|
|
async getRenderedMarkdownHtmlStructure(@Param() params: MongoIdDto) {
|
|
const { id } = params
|
|
const { html, document } = await this.service.renderArticle(id)
|
|
|
|
return this.service.getRenderedMarkdownHtmlStructure(html, document.title)
|
|
}
|
|
}
|