├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── Dockerfile ├── Jenkinsfile ├── README.md ├── bin ├── deploy-frontend.sh ├── init-db └── login.sh ├── commitlint.config.js ├── database ├── notices.schema.json ├── passages.schema.json ├── viewers.schema.json └── views.schema.json ├── frontend ├── .gitignore ├── bin │ └── sitemap ├── components │ ├── CustomFooter │ │ └── index.jsx │ ├── Icon │ │ └── index.jsx │ ├── Navigation │ │ └── index.jsx │ ├── SearchInput │ │ └── index.jsx │ └── SeoHead │ │ └── index.jsx ├── helpers │ ├── cache.js │ ├── dom.js │ ├── markdown │ │ ├── highlight.js │ │ ├── index.js │ │ ├── plugin.js │ │ └── toc.js │ ├── tcb.js │ └── utils.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── [psgID].jsx │ ├── _app.js │ ├── _document.js │ ├── archives │ │ └── [page].jsx │ └── index.jsx ├── providers │ └── passage.js ├── public │ ├── favicon.ico │ └── vercel.svg ├── requests │ ├── notice.js │ ├── search.js │ └── times.js └── styles │ ├── [article].scss │ ├── [page].scss │ ├── components │ └── search-input.scss │ ├── home.scss │ ├── index.scss │ └── package.scss ├── nest-cli.json ├── package.json ├── src ├── apis │ ├── notice │ │ ├── notice.controller.ts │ │ ├── notice.dto.ts │ │ ├── notice.interface.ts │ │ └── notice.service.ts │ ├── passage │ │ ├── passage.controller.ts │ │ ├── passage.dto.ts │ │ ├── passage.interface.ts │ │ └── passage.service.ts │ ├── search │ │ ├── search.controller.ts │ │ ├── search.dto.ts │ │ ├── search.interface.ts │ │ └── search.service.ts │ ├── seo │ │ └── seo.controller.ts │ └── times │ │ ├── times.controller.ts │ │ └── times.service.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── constants │ ├── cache.ts │ ├── collection.ts │ ├── index.ts │ ├── notes.ts │ └── server.ts ├── env.ts ├── filters │ └── all-exception.filter.ts ├── interceptors │ └── response.interceptor.ts ├── main.ts ├── middlewares │ └── cls.middleware.ts ├── services │ ├── async-limit.service.ts │ ├── env.service.ts │ ├── local-cache.service.ts │ ├── logger.service.ts │ └── tcb.service.ts ├── typings │ └── index.d.ts └── utils │ └── index.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | node_modules 4 | 5 | /bin 6 | /frontend 7 | /database 8 | 9 | /service/dist 10 | /service/test -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [!{node_modules}/**] 4 | end_of_line = lf 5 | charset = utf-8 6 | 7 | [{*.js,*.ts,*.html,*.gql,*.graqhql}] 8 | indent_style = space 9 | indent_size = 4 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 2 | # 敏感环境变量 3 | # 4 | 5 | # 云环境ID 6 | TCB_ENV_ID="" 7 | 8 | # 腾讯云密钥对 9 | TCLOUD_SECRET_ID="" 10 | TCLOUD_SECRET_KEY="" 11 | 12 | # Cookies密钥 13 | COOKIES_SECRET="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | 3 | node_modules/ 4 | 5 | .vscode/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/explicit-module-boundary-types': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # ZIP 37 | /*.zip 38 | 39 | # npm 40 | /package-lock.json 41 | 42 | # env files 43 | /.env 44 | 45 | # cloudbase error file 46 | cloudbase-error.log 47 | 48 | # notes folder 49 | notes 50 | 51 | # temp folder 52 | tmp -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | 28 | # 子目录的node依赖 29 | **/node_modules 30 | 31 | # 全局资源 32 | /public 33 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "useTabs": false, 5 | "tabWidth": 4, 6 | "semi": true, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.endOfLine": "lf" 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # image继承(官方推荐) 2 | # https://hub.docker.com/_/node 3 | FROM node:12-slim 4 | 5 | # 拷贝当前所有内容 6 | COPY . /usr/src/cloudpress 7 | 8 | WORKDIR /usr/src/cloudpress/service 9 | 10 | # 编译过程进行安装 11 | RUN npm install --registry=https://registry.npm.taobao.org 12 | 13 | RUN npm run build 14 | 15 | # 暴露 80 端口,允许外界连接此借口 16 | EXPOSE 80 17 | 18 | # 启动container后,自动运行的命令 19 | # RUN可以有多个,CMD只能有一个 20 | CMD npm run start:prod -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | stages { 4 | stage('检出') { 5 | steps { 6 | checkout([ 7 | $class: 'GitSCM', 8 | branches: [[name: env.GIT_BUILD_REF]], 9 | userRemoteConfigs: [[ 10 | url: env.GIT_REPO_URL, 11 | credentialsId: env.CREDENTIALS_ID 12 | ]]]) 13 | } 14 | } 15 | stage('并行阶段 1') { 16 | parallel { 17 | stage('依赖npm依赖') { 18 | steps { 19 | sh '''cd frontend 20 | npm install --registry=https://registry.npm.taobao.org''' 21 | } 22 | } 23 | stage('安装cloudbase') { 24 | steps { 25 | sh 'npm install --registry=https://registry.npm.taobao.org -g @cloudbase/cli ' 26 | } 27 | } 28 | } 29 | } 30 | stage('cloudbase身份认证') { 31 | steps { 32 | sh 'npm run login' 33 | } 34 | } 35 | stage('编译发布') { 36 | steps { 37 | sh 'npm run deploy:frontend' 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudPress: 基于云开发的开源博客系统 2 | 3 | ## 快速开始 4 | 5 | 1. **创建腾讯云账号**: 前往[腾讯云官网](https://cloud.tencent.com/) 6 | 7 | 2. **开通 CloudBase**:前往[TCB 控制台](https://console.cloud.tencent.com/tcb/env/index) 8 | 9 | 3. **新建云开发环境**:根据需要选择付费策略,每个用户都可以创建「免费环境」 10 | 11 | 4. **开通内容管理**:进入新创建的云开发环境,开通「内容管理」,等待 5 分钟左右 12 | 13 | 5. **获取云环境 ID、访问密钥**:进入新创建的云开发环境,获取环境 ID;进入[访问密钥](https://console.cloud.tencent.com/cam/capi),获取`SecretId`和`SecretKey`。 14 | 15 | 6. **环境搭建** 16 | 17 | - 全局安装 CloudBase CLI 和 Next.js 18 | 19 | ```bash 20 | npm i -g @cloudbase/cli next 21 | ``` 22 | 23 | - 从 GIT 下载代码 24 | 25 | ```bash 26 | git clone https://github.com/dongyuanxin/cloudpress.git 27 | ``` 28 | 29 | 7. **设置配置文件** 30 | 31 | ```bash 32 | cd cloudpress/ 33 | touch .env 34 | ``` 35 | 36 | 配置文件格式: 37 | 38 | ```bash 39 | ENV_ID=你的云环境ID 40 | TCB_SECRET_ID=你的访问密钥SecretId 41 | TCB_SECRET_KEY=你的访问密钥SecretKey 42 | ``` 43 | 44 | 复制一份配置文件到`frontend/`: 45 | 46 | ```bash 47 | cp .env frontend/.env 48 | ``` 49 | 50 | 8. **初始化数据表** 51 | 52 | ```bash 53 | npm run login # CLI 登录 CloudBase 54 | npm run init:db # 初始化数据表结构,包括文章、通知、全局配置等 55 | ``` 56 | 57 | 9. **部署发布** 58 | 59 | ```bash 60 | npm run deploy:frontend 61 | ``` 62 | 63 | 10. **本地开发** 64 | 65 | ```bash 66 | cd ./frontend 67 | npm run dev 68 | ``` 69 | -------------------------------------------------------------------------------- /bin/deploy-frontend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f .env ];then 4 | export $(cat .env | sed 's/#.*//g' | xargs) 5 | else 6 | echo "未监测到.env, 开发模式请创建.env" 7 | fi 8 | 9 | cd frontend/ 10 | 11 | npm run build 12 | 13 | cloudbase hosting:deploy out -e $ENV_ID 14 | 15 | cd - -------------------------------------------------------------------------------- /bin/init-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs').promises 4 | const path = require('path') 5 | const dotenv = require('dotenv') 6 | const tcb = require('@cloudbase/node-sdk') 7 | 8 | const cmsContentsCollection = 'tcb-ext-cms-contents' 9 | const envFile = path.resolve(__dirname, '..', '.env') 10 | const schemasFolder = path.resolve(__dirname, '..', 'database') 11 | 12 | const config = dotenv.config({ path: envFile }).parsed 13 | 14 | const app = tcb.init({ 15 | secretId: config.TCB_SECRET_ID, 16 | secretKey: config.TCB_SECRET_KEY, 17 | env: config.ENV_ID 18 | }) 19 | const db = app.database() 20 | 21 | main() 22 | 23 | async function main() { 24 | // 检查CMS预设的集合是否存在 25 | const checkPromises = await Promise.all([ 26 | 'tcb-ext-cms-contents', 27 | 'tcb-ext-cms-users', 28 | 'tcb-ext-cms-webhooks' 29 | ].map(checkCollection)) 30 | 31 | if (!checkPromises.every(item => item)) { 32 | console.log('请前往腾讯云·云开发控制台,在「扩展能力」中开通「内容管理」') 33 | return 34 | } 35 | 36 | const schemas = await fs.readdir(schemasFolder) 37 | for (const schema of schemas) { 38 | const schemaFilePath = path.join(schemasFolder, schema) 39 | const stats = await fs.stat(schemaFilePath) 40 | if (stats.isFile() && schema.endsWith('.json')) { 41 | try { 42 | await createCollection(schemaFilePath) 43 | } catch (error) { 44 | console.log(error) 45 | } 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * 更新CMS结构记录,创建数据表 52 | * @param {string} filepath 53 | */ 54 | async function createCollection(filepath) { 55 | const schemaJson = require(filepath) 56 | const cmsCollection = db.collection(cmsContentsCollection) 57 | 58 | console.log(`>>> start create ${schemaJson.collectionName}`) 59 | 60 | const { total } = await cmsCollection.where({ 61 | collectionName: schemaJson.collectionName 62 | }) 63 | .count() 64 | 65 | if (total === 0) { 66 | // 按照 CMS 的字段定义格式,新建对应字段的记录信息 67 | const { id } = await cmsCollection.add({ 68 | ...schemaJson, 69 | createTime: new Date(), 70 | updateTime: new Date(), 71 | }) 72 | await cmsCollection.doc(id) 73 | .update({ 74 | id, 75 | updateTime: new Date() 76 | }) 77 | 78 | const isExists = await checkCollection(schemaJson.collectionName) 79 | if (!isExists) { 80 | await db.createCollection(schemaJson.collectionName) 81 | } 82 | 83 | console.log(`<<< ${schemaJson.collectionName} create success`) 84 | } else { 85 | console.log(`<<< ${schemaJson.collectionName} exists`) 86 | } 87 | } 88 | 89 | /** 90 | * 检查集合是否存在 91 | * @param {string} collectionName 92 | * @return {Promise} 93 | */ 94 | async function checkCollection(collectionName) { 95 | try { 96 | await db.collection(collectionName).count() 97 | return true 98 | } catch (error) { 99 | if (error.code === 'DATABASE_COLLECTION_NOT_EXIST') { 100 | return false 101 | } 102 | throw error 103 | } 104 | } -------------------------------------------------------------------------------- /bin/login.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f .env ];then 4 | export $(cat .env | sed 's/#.*//g' | xargs) 5 | else 6 | echo "未监测到.env, 开发模式请创建.env" 7 | fi 8 | 9 | echo N | cloudbase login --apiKeyId $TCB_SECRET_ID --apiKey $TCB_SECRET_KEY -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /database/notices.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectionName": "cloudpress-v1-notices", 3 | "description": "用于下方给使用者的通知", 4 | "fields": [ 5 | { 6 | "fieldLabel": "通知标题", 7 | "fieldName": "noticeTitle", 8 | "fieldType": "String", 9 | "hidden": false, 10 | "isRequired": true 11 | }, 12 | { 13 | "fieldLabel": "通知内容", 14 | "fieldName": "noticeContent", 15 | "fieldType": "String", 16 | "hidden": false, 17 | "isRequired": true 18 | }, 19 | { 20 | "fieldLabel": "通知时间", 21 | "fieldName": "noticeTime", 22 | "fieldType": "DateTime", 23 | "hidden": false, 24 | "isRequired": true 25 | } 26 | ], 27 | "label": "[v1]通知" 28 | } 29 | -------------------------------------------------------------------------------- /database/passages.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectionName": "cloudpress-v1-passages", 3 | "label": "[v1]文章", 4 | "fields": [ 5 | { 6 | "fieldName": "title", 7 | "fieldType": "String", 8 | "hidden": false, 9 | "isRequired": true, 10 | "fieldLabel": "标题" 11 | }, 12 | { 13 | "fieldType": "String", 14 | "helpText": "文章唯一标识,用于拼接路由", 15 | "hidden": false, 16 | "isRequired": true, 17 | "fieldLabel": "标识", 18 | "fieldName": "psgID" 19 | }, 20 | { 21 | "isRequired": true, 22 | "fieldLabel": "内容", 23 | "fieldName": "content", 24 | "fieldType": "Markdown", 25 | "helpText": "会选取前150个字符作为简介", 26 | "hidden": false 27 | }, 28 | { 29 | "isRequired": true, 30 | "fieldLabel": "标签", 31 | "fieldName": "tags", 32 | "fieldType": "Array", 33 | "helpText": "标签数组", 34 | "hidden": false 35 | }, 36 | { 37 | "fieldLabel": "封面", 38 | "fieldName": "cover", 39 | "fieldType": "Image", 40 | "helpText": "简洁模式下不会展示", 41 | "hidden": false, 42 | "isRequired": false 43 | }, 44 | { 45 | "fieldName": "publishTime", 46 | "fieldType": "DateTime", 47 | "helpText": "文章发布时间", 48 | "hidden": false, 49 | "isRequired": true, 50 | "fieldLabel": "发布日期" 51 | }, 52 | { 53 | "isRequired": true, 54 | "fieldLabel": "点赞数", 55 | "fieldName": "goodTimes", 56 | "fieldType": "Number", 57 | "helpText": "点赞次数", 58 | "hidden": false 59 | }, 60 | { 61 | "fieldType": "Boolean", 62 | "hidden": false, 63 | "fieldLabel": "是否上锁", 64 | "fieldName": "isLocked" 65 | }, 66 | { 67 | "isRequired": true, 68 | "fieldLabel": "是否发布", 69 | "fieldName": "isPublished", 70 | "fieldType": "Boolean", 71 | "hidden": false 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /database/viewers.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectionName": "cloudpress-v1-viewers", 3 | "label": "[v1]访问人数", 4 | "fields": [ 5 | { 6 | "fieldName": "hostname", 7 | "fieldType": "String", 8 | "hidden": false, 9 | "isRequired": true, 10 | "fieldLabel": "域名" 11 | }, 12 | { 13 | "isRequired": true, 14 | "fieldLabel": "总人数", 15 | "fieldName": "times", 16 | "fieldType": "Number", 17 | "hidden": false 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /database/views.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectionName": "cloudpress-v1-views", 3 | "label": "[v1]访问次数", 4 | "fields": [ 5 | { 6 | "fieldName": "hostname", 7 | "fieldType": "String", 8 | "hidden": false, 9 | "isRequired": true, 10 | "fieldLabel": "域名" 11 | }, 12 | { 13 | "fieldType": "String", 14 | "hidden": false, 15 | "isRequired": true, 16 | "fieldLabel": "访问路径", 17 | "fieldName": "path" 18 | }, 19 | { 20 | "isRequired": true, 21 | "fieldLabel": "总次数", 22 | "fieldName": "times", 23 | "fieldType": "Number", 24 | "hidden": false 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | -------------------------------------------------------------------------------- /frontend/bin/sitemap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require("path"); 4 | const sitemap = require("nextjs-sitemap-generator"); 5 | 6 | sitemap({ 7 | baseUrl: "https://xxoo521.com", 8 | pagesDirectory: path.join(__dirname, "..", "out"), 9 | targetDirectory: path.join(__dirname, "..", "out"), 10 | }); 11 | -------------------------------------------------------------------------------- /frontend/components/CustomFooter/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Row, Col, Layout, Divider } from "antd"; 3 | 4 | const { Footer } = Layout; 5 | 6 | const CustomFooter = ({ style }) => { 7 | const cols = [ 8 | { 9 | name: "帮助", 10 | items: [ 11 | { 12 | name: "源码地址", 13 | url: "https://github.com/dongyuanxin/cloudpress", 14 | }, 15 | { 16 | name: "意见反馈", 17 | url: "https://github.com/dongyuanxin/cloudpress/issues", 18 | }, 19 | { 20 | name: "常见问题", 21 | url: "https://github.com/dongyuanxin/cloudpress/issues", 22 | }, 23 | { 24 | name: "更新日志", 25 | url: 26 | "https://github.com/dongyuanxin/cloudpress/commits/master", 27 | }, 28 | ], 29 | }, 30 | { 31 | name: "技术栈", 32 | items: [ 33 | { 34 | name: "CloudBase", 35 | url: "https://www.cloudbase.net/", 36 | }, 37 | { 38 | name: "Next.js", 39 | url: "https://nextjs.org/", 40 | }, 41 | { 42 | name: "NestJS", 43 | url: "https://nestjs.com/", 44 | }, 45 | { 46 | name: "Ant Design", 47 | url: "https://ant.design/", 48 | }, 49 | ], 50 | }, 51 | { 52 | name: "其他平台", 53 | items: [ 54 | { 55 | name: "Github", 56 | url: "https://github.com/dongyuanxin", 57 | }, 58 | { 59 | name: "junjin.im", 60 | url: "https://juejin.im/user/5b91fcf06fb9a05d3c7fd4a5", 61 | }, 62 | { 63 | name: "SegmentFault", 64 | url: "https://segmentfault.com/u/godbmw", 65 | }, 66 | { 67 | name: "xin-tan.com", 68 | url: "https://xin-tan.com/", 69 | }, 70 | ], 71 | }, 72 | ]; 73 | 74 | const rendersCols = () => { 75 | const colSpan = Math.floor(24 / cols.length); 76 | 77 | const renderItems = (items = []) => { 78 | return items.map((item, index) => ( 79 | 80 | 85 | {item.name} 86 | 87 | 88 | )); 89 | }; 90 | 91 | return ( 92 | 93 | {cols.map((col, index) => ( 94 | 95 |

102 | {col.name} 103 |

104 | {renderItems(col.items)} 105 | 106 | ))} 107 |
108 | ); 109 | }; 110 | 111 | return ( 112 |
113 |
114 | {rendersCols()} 115 | 116 |
123 | 本站总访问量 124 | 128 |  ...  129 | 130 | 次,本站总访客数 131 | 135 |  ...  136 | 137 | 人 138 |
139 |
146 | 由 147 | 152 |  CLOUDPRESS  153 | 154 | 打造,在线预览 155 | 160 |  DEMO  161 | 162 |
163 |
164 |
165 | ); 166 | }; 167 | 168 | export default CustomFooter; 169 | -------------------------------------------------------------------------------- /frontend/components/Icon/index.jsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | 3 | export const HomeOutlined = dynamic( 4 | () => import("@ant-design/icons/HomeOutlined"), 5 | { ssr: false } 6 | ); 7 | 8 | export const CoffeeOutlined = dynamic( 9 | () => import("@ant-design/icons/CoffeeOutlined"), 10 | { ssr: false } 11 | ); 12 | 13 | export const AppstoreOutlined = dynamic( 14 | () => import("@ant-design/icons/AppstoreOutlined"), 15 | { ssr: false } 16 | ); 17 | 18 | export const CommentOutlined = dynamic( 19 | () => import("@ant-design/icons/CommentOutlined"), 20 | { ssr: false } 21 | ); 22 | 23 | export const FireOutlined = dynamic( 24 | () => import("@ant-design/icons/FireOutlined"), 25 | { ssr: false } 26 | ); 27 | 28 | export const BellOutlined = dynamic( 29 | () => import("@ant-design/icons/BellOutlined"), 30 | { ssr: false } 31 | ); 32 | 33 | export const GithubOutlined = dynamic( 34 | () => import("@ant-design/icons/GithubOutlined"), 35 | { ssr: false } 36 | ); 37 | 38 | export const SearchOutlined = dynamic( 39 | () => import("@ant-design/icons/SearchOutlined"), 40 | { ssr: false } 41 | ); 42 | 43 | export const ClockCircleOutlined = dynamic( 44 | () => import("@ant-design/icons/ClockCircleOutlined"), 45 | { ssr: false } 46 | ); 47 | 48 | export const EyeOutlined = dynamic( 49 | () => import("@ant-design/icons/EyeOutlined"), 50 | { ssr: false } 51 | ); 52 | 53 | export const MessageOutlined = dynamic( 54 | () => import("@ant-design/icons/MessageOutlined"), 55 | { ssr: false } 56 | ); 57 | -------------------------------------------------------------------------------- /frontend/components/Navigation/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { 3 | Layout, 4 | Menu, 5 | Row, 6 | Col, 7 | Tooltip, 8 | Button, 9 | Badge, 10 | message, 11 | Switch, 12 | Drawer, 13 | Timeline, 14 | } from "antd"; 15 | import { useRouter } from "next/router"; 16 | import { 17 | CoffeeOutlined, 18 | AppstoreOutlined, 19 | CommentOutlined, 20 | BellOutlined, 21 | GithubOutlined, 22 | ClockCircleOutlined, 23 | HomeOutlined, 24 | } from "./../../components/Icon/"; 25 | import SearchInput from "./../../components/SearchInput"; 26 | import { NoticeReq } from "./../../requests/notice"; 27 | import { TimesReq } from './../../requests/times' 28 | import { formatISOString } from "./../../helpers/utils"; 29 | import Darkmode from "darkmode-js"; 30 | 31 | const { Header } = Layout; 32 | 33 | const darkmodeOptions = { 34 | time: "0.5s", // default: '0.3s' 35 | saveInCookies: true, // default: true, 36 | label: "🌓", // default: '' 37 | autoMatchOsTheme: false, // default: true 38 | }; 39 | 40 | const Navigation = ({ style }) => { 41 | const router = useRouter(); 42 | const startTimeKey = "noticeStartTime"; 43 | const [isLightMode, setIsLightMode] = useState(true); 44 | const [notices, setNotices] = useState([]); 45 | const [unReadNoticeCount, setUnreadNoticeCount] = useState(0); 46 | const [noticeDrawerVisible, setNoticeDrawerVisible] = useState(false); 47 | const nav = [ 48 | { 49 | title: "首页", 50 | key: "/", 51 | icon: , 52 | disabled: false, 53 | link: "/", 54 | }, 55 | { 56 | title: "简记", 57 | key: "/archives/[page]", 58 | icon: , 59 | disabled: false, 60 | link: "/archives/1", 61 | }, 62 | { 63 | title: "小册", 64 | key: "xiaoce", 65 | icon: , 66 | disabled: true, 67 | }, 68 | { 69 | title: "留言", 70 | key: "liuyan", 71 | icon: , 72 | disabled: true, 73 | }, 74 | { 75 | title: "更新", 76 | key: "gengxin", 77 | icon: , 78 | disabled: true, 79 | }, 80 | ]; 81 | const refDarkmode = useRef(null); 82 | 83 | useEffect(() => { 84 | // 防止每次都渲染 Darkmode.js,它没做单例优化 85 | refDarkmode.current = new Darkmode(darkmodeOptions); 86 | setIsLightMode(localStorage.getItem("darkmode") !== "true"); 87 | 88 | TimesReq.view(router.pathname) 89 | getNotices(); 90 | }, []); 91 | 92 | // 获取未读消息 93 | const getNotices = async () => { 94 | let startTime = parseInt(localStorage.getItem(startTimeKey), 10); 95 | if (isNaN(startTime)) { 96 | // 默认拉取过去2个月的未读消息 97 | startTime = Date.now() - 1000 * 60 * 60 * 24 * 60; 98 | } 99 | 100 | const { notices } = await NoticeReq.getNotices({ 101 | size: 100, 102 | startTime, 103 | }); 104 | setNotices(notices); 105 | setUnreadNoticeCount(notices.length); 106 | }; 107 | 108 | // 渲染通知视图 109 | const renderNotices = () => { 110 | // 设置全部已读 111 | const readAllNotices = () => { 112 | // 全部已读后,不可回退 113 | if (unReadNoticeCount === 0) { 114 | return; 115 | } 116 | // 更新未读消息数量 117 | setUnreadNoticeCount(0); 118 | // 使用最新消息的时间作为开始时间戳 119 | localStorage.setItem( 120 | startTimeKey, 121 | new Date(notices[0].noticeTime).getTime() 122 | ); 123 | }; 124 | 125 | const noticeNodes = []; 126 | 127 | for (const notice of notices) { 128 | noticeNodes.push( 129 | 134 |

{notice.noticeTitle}

135 |

{notice.noticeContent}

136 |
137 | ); 138 | } 139 | noticeNodes.push( 140 | 141 |

没有更多了

142 |
143 | ); 144 | 145 | return ( 146 | 154 | } 155 | placement="right" 156 | closable={true} 157 | onClose={() => setNoticeDrawerVisible(false)} 158 | visible={noticeDrawerVisible} 159 | key="notice-drawer-right" 160 | width={350} 161 | > 162 | {noticeNodes} 163 | 164 | ); 165 | }; 166 | 167 | return ( 168 |
169 | {renderNotices()} 170 | 173 | 174 | 182 | {nav.map((item) => ( 183 | 187 | item.disabled && 188 | message.warning("业务需求多,下班熬夜肝") 189 | } 190 | > 191 | {item.link ? ( 192 | 193 | {item.title} 194 | 195 | ) : ( 196 | {item.title} 197 | )} 198 | 199 | ))} 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 211 | { 219 | setIsLightMode(checked); 220 | refDarkmode.current.toggle(); 221 | }} 222 | /> 223 | 246 | 250 | 266 | 267 | 268 | 269 | 270 | 271 |
272 | ); 273 | }; 274 | 275 | export default Navigation; 276 | -------------------------------------------------------------------------------- /frontend/components/SearchInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Input, Button } from "antd"; 3 | import { SearchOutlined } from "./../Icon/"; 4 | import { SearchReq } from "./../../requests/search"; 5 | import { findParentClass } from "./../../helpers/dom"; 6 | import { useRequest } from "@umijs/hooks"; 7 | 8 | const SearchInput = () => { 9 | const size = 10; 10 | const [results, setResults] = useState([]); 11 | const [page, setPage] = useState(1); 12 | const [value, setValue] = useState(""); 13 | const [loadContent, setLoadContent] = useState("已加载全部"); 14 | const [showResults, setShowResults] = useState(false); 15 | 16 | useEffect(() => { 17 | const onClick = (event) => { 18 | if (findParentClass(event.target, "cp-search-input")) { 19 | setShowResults(true); 20 | } else { 21 | setShowResults(false); 22 | } 23 | }; 24 | window.addEventListener("click", onClick); 25 | 26 | return () => { 27 | window.removeEventListener("click", onClick); 28 | }; 29 | }, []); 30 | 31 | const searchResult = async (value, page) => { 32 | if (typeof value !== "string" || value.trim().length <= 0) { 33 | return; 34 | } 35 | 36 | let keywords = value.split(" "); 37 | keywords = keywords.filter((item) => item.trim().length > 0); 38 | 39 | const result = await SearchReq.searchPassages({ 40 | page, 41 | size, 42 | keywords, 43 | }); 44 | 45 | if (page * size > result.count) { 46 | setLoadContent("已加载全部"); 47 | } else { 48 | setLoadContent("加载更多"); 49 | } 50 | if (page === 1) { 51 | setResults(result.passages); 52 | } else { 53 | setResults((current) => [...current, ...result.passages]); 54 | } 55 | setPage(page); 56 | }; 57 | 58 | const { run: searchResultDebounced } = useRequest(searchResult, { 59 | debounceInterval: 500, 60 | manual: false, 61 | }); 62 | 63 | const onChange = (event) => { 64 | const value = event.target.value; 65 | setValue(value); 66 | searchResultDebounced(value, 1); 67 | }; 68 | 69 | return ( 70 |
71 | } 75 | onChange={onChange} 76 | onFocus={() => setShowResults(true)} 77 | /> 78 |
82 | {results.map((result, index) => ( 83 |
87 | window.open(`/${result.psgID}`, "_blank") 88 | } 89 | > 90 |
91 | {result.title} 92 |
93 |

94 | {result.content} 95 |

96 |
97 | ))} 98 |
searchResultDebounced(value || "", page + 1)} 101 | > 102 | {loadContent} 103 |
104 |
105 |
106 | ); 107 | }; 108 | 109 | export default SearchInput; 110 | -------------------------------------------------------------------------------- /frontend/components/SeoHead/index.jsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | export default function SeoHead({ 4 | title = "心谭博客", 5 | keywords = "CloudPress,云开发,开源博客,前端知识图谱,算法题解,node开发,javascript编程,css3动画,react,编程分享", 6 | description = "专注前端与算法,目前已有前端面试、剑指OFFER·JS、数据结构等系列专题", 7 | author = "董沅鑫 心谭", 8 | children, 9 | }) { 10 | return ( 11 | 12 | {title} 13 | 14 | 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/helpers/cache.js: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | 3 | const cache = new Map(); 4 | const cacheWrapper = (func) => { 5 | if (typeof func !== "function") { 6 | throw new Error("Param error: func must be function"); 7 | } 8 | 9 | return async function () { 10 | if (!cache.has(func)) { 11 | cache.set(func, new Map()); 12 | } 13 | 14 | const paramsStr = JSON.stringify([...arguments]); 15 | const sha256 = crypto.createHash("sha256"); 16 | sha256.update(paramsStr); 17 | const key = sha256.digest("hex"); 18 | 19 | const funcMap = cache.get(func); 20 | if (funcMap.has(key)) { 21 | return funcMap.get(key); 22 | } 23 | 24 | const res = await func(...arguments); 25 | funcMap.set(key, res); 26 | return res; 27 | }; 28 | }; 29 | 30 | export { cacheWrapper }; 31 | -------------------------------------------------------------------------------- /frontend/helpers/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 查询dom的上级元素是否有className 3 | * @param {DOM} dom 4 | * @param {string} className 5 | */ 6 | export function findParentClass(dom, className) { 7 | if (!dom) { 8 | return false; 9 | } 10 | if (dom.className === className) { 11 | return true; 12 | } 13 | return findParentClass(dom.parentNode, className); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/helpers/markdown/highlight.js: -------------------------------------------------------------------------------- 1 | import hljs from "highlight.js"; 2 | import { md } from "./index"; 3 | 4 | export const highlight = (str, lang) => { 5 | if (lang && hljs.getLanguage(lang)) { 6 | try { 7 | return ( 8 | '
' +
 9 |                 hljs.highlight(lang, str, true).value +
10 |                 "
" 11 | ); 12 | } catch (_) {} 13 | } 14 | 15 | return ( 16 | '
' + md.utils.escapeHtml(str) + "
" 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/helpers/markdown/index.js: -------------------------------------------------------------------------------- 1 | import Markdownit from "markdown-it"; 2 | import { highlight } from "./highlight"; 3 | import { plugins } from "./plugin"; 4 | import { parseAnchors } from "./toc"; 5 | 6 | const md = Markdownit({ 7 | highlight, 8 | html: true, 9 | breaks: true, 10 | linkify: true, 11 | }); 12 | 13 | plugins.forEach((plugin) => md.use(...plugin)); 14 | 15 | export { md, parseAnchors }; 16 | -------------------------------------------------------------------------------- /frontend/helpers/markdown/plugin.js: -------------------------------------------------------------------------------- 1 | import container from "markdown-it-container"; 2 | import checkbox from "markdown-it-checkbox"; 3 | import comments from "markdown-it-inline-comments"; 4 | import anchor from "markdown-it-anchor"; 5 | import footnote from "markdown-it-footnote"; 6 | 7 | // 标题锚点 8 | const anchorPlugin = [ 9 | anchor, 10 | { 11 | permalink: true, 12 | permalinkSymbol: "#", 13 | permalinkBefore: true, 14 | }, 15 | ]; 16 | 17 | // markdown注释 18 | const commentsPlugin = [comments]; 19 | 20 | // 选择框 21 | const checkBoxPlugin = [checkbox]; 22 | 23 | // 脚注 24 | const footnotePlugin = [footnote]; 25 | 26 | // :::容器 27 | const containerPlugins = ["warning", "error", "tip"].map(createContainer); 28 | 29 | export const plugins = [ 30 | anchorPlugin, 31 | commentsPlugin, 32 | checkBoxPlugin, 33 | footnotePlugin, 34 | ...containerPlugins, 35 | ]; 36 | 37 | function createContainer(klass) { 38 | return [ 39 | container, 40 | klass, 41 | { 42 | render(tokens, idx) { 43 | const token = tokens[idx]; 44 | const info = token.info.trim().slice(klass.length).trim(); 45 | if (token.nesting === 1) { 46 | return `
47 |

48 | ${info} 49 |

\n`; 50 | } else { 51 | return `
\n`; 52 | } 53 | }, 54 | }, 55 | ]; 56 | } 57 | -------------------------------------------------------------------------------- /frontend/helpers/markdown/toc.js: -------------------------------------------------------------------------------- 1 | import jsdom from "jsdom"; 2 | 3 | const { JSDOM } = jsdom; 4 | 5 | /** 6 | * 根据html源码结构,获取标题和对应锚点 7 | * @param {string} html 8 | * @param {number[]} levels 支持的标题级别 9 | * @return {object[]} 10 | */ 11 | export function parseAnchors(html, levels = [2, 3, 4, 5]) { 12 | const tags = levels.map((level) => `h${level}`); 13 | const { 14 | window: { document }, 15 | } = new JSDOM(html); 16 | 17 | const titleDoms = document.body.querySelectorAll(tags.join(",")); 18 | const anchors = []; 19 | for (const dom of titleDoms) { 20 | const aTag = dom.querySelector("a"); 21 | anchors.push({ 22 | tag: dom.tagName.toLocaleLowerCase(), 23 | text: dom.textContent.slice(1).trim(), 24 | href: aTag.getAttribute("href"), 25 | }); 26 | } 27 | 28 | return anchors; 29 | } 30 | -------------------------------------------------------------------------------- /frontend/helpers/tcb.js: -------------------------------------------------------------------------------- 1 | import cloudbase from "@cloudbase/node-sdk"; 2 | 3 | let app, auth; 4 | 5 | /** 6 | * 获取 TCB 实例 7 | */ 8 | export async function getApp() { 9 | if (!app) { 10 | app = cloudbase.init({ 11 | env: process.env.ENV_ID, 12 | secretId: process.env.TCB_SECRET_ID, 13 | secretKey: process.env.TCB_SECRET_KEY, 14 | }); 15 | } 16 | 17 | return app; 18 | } 19 | 20 | /** 21 | * 获取 TCB 的 auth 对象 22 | */ 23 | export async function getAuth() { 24 | if (!auth) { 25 | const app = await getApp(); 26 | auth = app.auth(); 27 | } 28 | 29 | return auth; 30 | } 31 | 32 | /** 33 | * 统一云数据库抛错格式,方便排查 34 | * @param {string} propmt 提示信息 35 | * @param {string | number} errCode 错误码 36 | * @param {string} errMsg 错误信息 37 | */ 38 | export function throwDbError(propmt, errCode, errMsg) { 39 | let msg = propmt; 40 | if (errCode) { 41 | msg += `,错误码是:${errCode}`; 42 | } 43 | if (errMsg) { 44 | msg += `,错误信息是:${errMsg}`; 45 | } 46 | 47 | throw new Error(msg); 48 | } 49 | -------------------------------------------------------------------------------- /frontend/helpers/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取云存储的访问链接 3 | * @param {string} url 云存储的特定url 4 | */ 5 | export function getBucketUrl(url) { 6 | if (typeof url !== "string" || !url.startsWith("cloud://")) { 7 | return url; 8 | } 9 | 10 | const re = /cloud:\/\/.*?\.(.*?)\/(.*)/; 11 | const result = re.exec(url); 12 | return `https://${result[1]}.tcb.qcloud.la/${result[2]}`; 13 | } 14 | 15 | /** 16 | * 将 ISO 时间串格式化为 YYYY.MM.DD HH:mm 的格式 17 | * @param {string} str ISO 格式时间串 18 | */ 19 | export function formatISOString(str) { 20 | const addZeroPre = (number) => (number < 10 ? `0${number}` : `${number}`); 21 | 22 | const date = new Date(str); 23 | const [year, month, day, hour, minute] = [ 24 | date.getFullYear(), 25 | date.getMonth() + 1, 26 | date.getDate(), 27 | date.getHours(), 28 | date.getMinutes(), 29 | ].map(addZeroPre); 30 | 31 | return `${year}.${month}.${day} ${hour}:${minute}`; 32 | } 33 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | sassOptions: { 5 | includePaths: [path.join(__dirname, "styles")], 6 | }, 7 | exportTrailingSlash: true, 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build && next export && ./bin/sitemap", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@ant-design/icons": "4.2.1", 12 | "@cloudbase/node-sdk": "^2.1.1", 13 | "@umijs/hooks": "^1.9.3", 14 | "antd": "4.3.1", 15 | "axios": "^0.19.2", 16 | "darkmode-js": "^1.5.6", 17 | "highlight.js": "^10.1.2", 18 | "jsdom": "^16.2.2", 19 | "lodash": "^4.17.20", 20 | "markdown-it": "^11.0.0", 21 | "markdown-it-anchor": "^5.3.0", 22 | "markdown-it-checkbox": "^1.1.0", 23 | "markdown-it-container": "^3.0.0", 24 | "markdown-it-footnote": "^3.0.2", 25 | "markdown-it-inline-comments": "^1.0.1", 26 | "moment": "^2.27.0", 27 | "next": "11.1.3", 28 | "nextjs-sitemap-generator": "^1.0.0", 29 | "prettier": "^2.0.5", 30 | "react": "16.13.1", 31 | "react-dom": "16.13.1", 32 | "sass": "^1.26.8" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/pages/[psgID].jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import _ from "lodash"; 3 | import { Anchor, Row, Col } from "antd"; 4 | import { md, parseAnchors } from "../helpers/markdown"; 5 | import SeoHead from "./../components/SeoHead/"; 6 | import { PassageProvider } from "./../providers/passage"; 7 | 8 | const { Link } = Anchor; 9 | 10 | const BlogPage = ({ contentHtml, passage, description, anchors }) => { 11 | const renderToc = () => { 12 | const links = []; 13 | for (let i = 0; i < anchors.length; ++i) { 14 | const anchor = anchors[i]; 15 | links.push( 16 | 22 | ); 23 | } 24 | return links; 25 | }; 26 | 27 | return ( 28 | <> 29 | 33 | 34 | 35 |
36 |

{passage.title}

37 |
41 |
42 | 43 | 44 | {anchors.length && {renderToc()}} 45 | 46 |
47 | 48 | ); 49 | }; 50 | 51 | export default BlogPage; 52 | 53 | export async function getStaticPaths() { 54 | const psgIDs = await PassageProvider.describePsgIDs(); 55 | const paths = psgIDs.map((psgID) => ({ 56 | params: { 57 | psgID, 58 | }, 59 | })); 60 | 61 | return { 62 | paths, 63 | fallback: false, 64 | }; 65 | } 66 | 67 | export async function getStaticProps({ params }) { 68 | const passage = await PassageProvider.describePassage(params.psgID); 69 | const { content, description } = passage; 70 | const contentHtml = md.render(content); 71 | 72 | return { 73 | props: { 74 | contentHtml, 75 | description, 76 | passage: _.omit(passage, [ 77 | "filepath", 78 | "mtime", 79 | "content", 80 | ]), 81 | anchors: parseAnchors(contentHtml), 82 | }, 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /frontend/pages/_app.js: -------------------------------------------------------------------------------- 1 | import "antd/dist/antd.css"; 2 | import React, { useState } from "react"; 3 | import { Layout, BackTop } from "antd"; 4 | import CustomFooter from "./../components/CustomFooter/"; 5 | import Navigation from "./../components/Navigation/"; 6 | import Head from "next/head"; 7 | import "./../styles/index.scss"; 8 | import "highlight.js/styles/atom-one-light.css"; 9 | 10 | const { Content } = Layout; 11 | 12 | // if (process.browser) { 13 | // const targetProtocol = "https:"; 14 | // if ( 15 | // process.env.NODE_ENV !== "development" && 16 | // window.location.protocol !== targetProtocol 17 | // ) { 18 | // window.location.href = 19 | // targetProtocol + window.location.href.slice(targetProtocol.length); 20 | // } 21 | // } 22 | 23 | // This default export is required in a new `pages/_app.js` file. 24 | export default function MyApp({ Component, pageProps }) { 25 | return ( 26 | 27 | {/* todo: 移动端兼容 */} 28 | 29 | 33 | 39 | 40 | 41 | 49 | 50 | 51 | 54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /frontend/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx) { 5 | const initialProps = await Document.getInitialProps(ctx); 6 | return { ...initialProps }; 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 |
15 | 16 | 20 | 21 | 22 | ); 23 | } 24 | } 25 | 26 | export default MyDocument; 27 | -------------------------------------------------------------------------------- /frontend/pages/archives/[page].jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { message, Row, Col, Switch, Pagination } from "antd"; 3 | import _ from "lodash"; 4 | import SeoHead from "./../../components/SeoHead/"; 5 | import { PassageProvider } from "./../../providers/passage"; 6 | 7 | const PAGE_SIZE = 10; // 每页大小 8 | 9 | const ArchievePage = ({ passages, total, page }) => { 10 | const [easyMode, setEasyMode] = useState(true); 11 | 12 | const renderPassage = (passage) => { 13 | const { title, description, index, date, permalink } = passage; 14 | 15 | return ( 16 | 22 | 27 | 35 |
{index}
36 |
37 |

{title}

38 |

{description}

39 |
40 | 41 | 42 | 50 | {/* 51 | 1000+ 52 | 53 | 54 | 0 55 | */} 56 | 57 | {date} 58 | 59 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | const renderPagination = () => { 66 | function itemRender(current, type, originalElement) { 67 | if (type === "page") { 68 | return ( 69 | 70 | {current} 71 | 72 | ); 73 | } 74 | 75 | return originalElement; 76 | } 77 | 78 | return ( 79 | 85 | ); 86 | }; 87 | 88 | const toogleMode = (checked) => { 89 | if (!checked) { 90 | message.warn("业务需求多,下班熬夜肝"); 91 | return; 92 | } 93 | setEasyMode(checked); 94 | }; 95 | 96 | return ( 97 | <> 98 | passage.title) 102 | .join(",")} 103 | /> 104 |
105 |
106 | 全部文章 107 | all({total}) 108 |
109 |
110 | 114 |      115 | 116 | 简洁模式 117 | 118 |
119 |
120 | 121 |
{passages.map((passage) => renderPassage(passage))}
122 | 123 |
{renderPagination()}
124 | 125 | ); 126 | }; 127 | 128 | export default ArchievePage; 129 | 130 | export async function getStaticPaths() { 131 | const total = await PassageProvider.countPassages(); 132 | const pageNum = Math.ceil(total / PAGE_SIZE); 133 | const paths = []; 134 | for (let i = 0; i < pageNum; ++i) { 135 | paths.push({ 136 | params: { 137 | page: (i + 1).toString(), 138 | }, 139 | }); 140 | } 141 | return { 142 | paths, 143 | fallback: false, 144 | }; 145 | } 146 | 147 | export async function getStaticProps({ params }) { 148 | const total = await PassageProvider.countPassages(); 149 | const page = parseInt(params.page); 150 | const passages = await PassageProvider.describePassages(PAGE_SIZE, page) 151 | 152 | return { 153 | props: { 154 | params, 155 | total, 156 | page, 157 | passages: passages.map((passage, index) => { 158 | passage.index = (page - 1) * PAGE_SIZE + index + 1; 159 | return _.omit(passage, ["filepath", "content", "mtime"]); 160 | }), 161 | }, 162 | }; 163 | } 164 | -------------------------------------------------------------------------------- /frontend/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import SeoHead from "./../components/SeoHead/"; 2 | 3 | export default function Home() { 4 | return ( 5 | <> 6 | 10 |
11 |
12 |

CloudPress   基于云

13 |

✍️一款基于云开发的开源博客系统

14 |
15 | 27 | 32 |
33 |
34 |
35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/providers/passage.js: -------------------------------------------------------------------------------- 1 | import { getApp } from "./../helpers/tcb"; 2 | import { getBucketUrl } from "../helpers/utils"; 3 | import moment from "moment"; 4 | import axios from 'axios'; 5 | 6 | const baseUrl = 'http://127.0.0.1' 7 | 8 | export class PassageProvider { 9 | /** 10 | * 统计文章总数 11 | * @return {Promise} 12 | */ 13 | static async countPassages() { 14 | const config = { 15 | method: 'get', 16 | url: `${baseUrl}/passage/count-all-passages` 17 | } 18 | 19 | const axiosRes = await axios(config) 20 | return axiosRes.data.result; 21 | } 22 | 23 | /** 24 | * 分页获取文章数据 25 | * @param {number} limit 26 | * @param {number} page 27 | * @return {Promise} 28 | */ 29 | static async describePassages(limit = 10, page = 1) { 30 | const config = { 31 | method: 'get', 32 | url: `${baseUrl}/passage/describe-passages?limit=${limit}&page=${page}` 33 | } 34 | 35 | const axiosRes = await axios(config) 36 | return axiosRes.data.result; 37 | } 38 | 39 | /** 40 | * 获取对应的文章 41 | * @param {string} passageId 42 | * @return {Promise} 43 | */ 44 | static async describePassage(passageId) { 45 | const config = { 46 | method: 'get', 47 | url: `${baseUrl}/passage/describe-passage-by-id?passageId=${passageId}` 48 | } 49 | 50 | const axiosRes = await axios(config) 51 | return axiosRes.data.result; 52 | } 53 | 54 | /** 55 | * 获取文章的全部id 56 | * @return {Promise} 57 | */ 58 | static async describePsgIDs() { 59 | const config = { 60 | method: 'get', 61 | url: `${baseUrl}/passage/describe-all-passage-ids` 62 | } 63 | 64 | const axiosRes = await axios(config) 65 | return axiosRes.data.result; 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dongyuanxin/cloudpress/5e85e3643470cb63f29f31f2b7e985267f954514/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/requests/notice.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = '//127.0.0.1' 4 | 5 | export class NoticeReq { 6 | static async getNotices(params) { 7 | const { startTime, size } = params; 8 | 9 | const config = { 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | method: "get", 14 | url: `${baseUrl}/notice?startTime=${startTime}&size=${size}`, 15 | }; 16 | 17 | const axiosRes = await axios(config); 18 | return axiosRes.data.result; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/requests/search.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = '//127.0.0.1' 4 | 5 | export class SearchReq { 6 | static async searchPassages(params) { 7 | const config = { 8 | headers: { 9 | "Content-Type": "application/json", 10 | }, 11 | method: "post", 12 | url: `${baseUrl}/search/passages`, 13 | data: params, 14 | }; 15 | 16 | const axiosRes = await axios(config); 17 | return axiosRes.data.result; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/requests/times.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = '//127.0.0.1' 4 | 5 | export class TimesReq { 6 | static async view(url) { 7 | const localToken = localStorage.getItem('token') 8 | const config = { 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | method: "get", 13 | url: `${baseUrl}/times/view`, 14 | headers: { 15 | 'x-cloudpress-url': url || '/', 16 | 'x-cloudpress-token': localToken 17 | } 18 | }; 19 | 20 | const axiosRes = await axios(config); 21 | const { token } = axiosRes.data.result || {} 22 | if (token !== localToken) { 23 | localStorage.setItem('token', token) 24 | } 25 | return axiosRes.data.result; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/styles/[article].scss: -------------------------------------------------------------------------------- 1 | .page-article { 2 | padding: 50px 0; 3 | 4 | h1 { 5 | font-size: 32px; 6 | } 7 | 8 | .ant-affix .ant-anchor { 9 | margin-top: 50px; 10 | } 11 | 12 | .ant-anchor-link-title { 13 | white-space: normal; 14 | } 15 | 16 | // 不支持 h1 和 h6 锚点 17 | .anchor-h2 { 18 | padding-left: 16px; 19 | } 20 | 21 | .anchor-h3 { 22 | padding-left: 26px; 23 | } 24 | 25 | .anchor-h4 { 26 | padding-left: 36px; 27 | } 28 | 29 | .anchor-h5 { 30 | padding-left: 46px; 31 | } 32 | 33 | .markdown { 34 | line-height: 2; 35 | font-size: 16px; 36 | background: white; 37 | font-family: Source Sans Pro, Helvetica Neue, sans-serif; 38 | 39 | &-custom-block { 40 | padding: 16px 24px; 41 | border-left-width: 6px; 42 | border-left-style: solid; 43 | margin: 16px 0; 44 | border-top-left-radius: 5px; 45 | border-bottom-left-radius: 5px; 46 | 47 | &--tip { 48 | background-color: #e6f7ff; // level-1 49 | border-color: #69c0ff; // level-4 50 | color: #40a9ff; // level-5 51 | } 52 | 53 | &--warning { 54 | background-color: #fff7e6; 55 | border-color: #ffc069; 56 | color: #ffa940; 57 | } 58 | 59 | &--error { 60 | background-color: #fff1f0; 61 | border-color: #ff7875; 62 | color: #ff4d4f; 63 | } 64 | 65 | p:last-child { 66 | margin-bottom: 0; 67 | } 68 | 69 | &-title { 70 | font-weight: bolder; 71 | margin-bottom: 10px; 72 | 73 | &--tip { 74 | color: #1890ff; 75 | } 76 | 77 | &--warning { 78 | color: #fa8c16; 79 | } 80 | 81 | &--error { 82 | color: #f5222d; 83 | } 84 | } 85 | } 86 | 87 | code { 88 | padding: 2px 4px; 89 | font-size: 0.9em; 90 | color: #f5222d; 91 | border-radius: 4px; 92 | } 93 | 94 | pre.hljs { 95 | background: #f8fafc; 96 | border-radius: 10px; 97 | padding: 12px 24px; 98 | font-size: 14px; 99 | 100 | code { 101 | padding: 0; 102 | font-size: 1em; 103 | color: rgba(0, 0, 0, 0.6); 104 | background-color: transparent; 105 | border-radius: 0; 106 | } 107 | } 108 | 109 | h1, 110 | h2, 111 | h3, 112 | h4, 113 | h5, 114 | h6 { 115 | a { 116 | color: rgba(0, 0, 0, 0.85); 117 | } 118 | 119 | &:hover { 120 | a { 121 | color: #40a9ff; 122 | } 123 | } 124 | } 125 | 126 | h1 { 127 | font-size: 2em; 128 | } 129 | 130 | h2 { 131 | font-size: 1.6em; 132 | } 133 | 134 | h3 { 135 | font-size: 1.4em; 136 | } 137 | 138 | h4 { 139 | font-size: 1.2em; 140 | } 141 | 142 | h5 { 143 | font-size: 1.1em; 144 | } 145 | 146 | h6 { 147 | font-size: 1em; 148 | } 149 | 150 | sup { 151 | padding: 0 3px; 152 | a { 153 | font-size: 12px; 154 | font-weight: bold; 155 | font-style: italic; 156 | } 157 | } 158 | 159 | ul, 160 | ol { 161 | padding-left: 30px; 162 | 163 | li { 164 | p { 165 | margin-bottom: 0; 166 | } 167 | } 168 | } 169 | 170 | // li { 171 | // display: flex; 172 | // align-items: center; 173 | // } 174 | 175 | input[type="checkbox"] { 176 | margin-right: 8px; 177 | height: 14px; 178 | width: 14px; 179 | cursor: pointer; 180 | } 181 | 182 | blockquote { 183 | display: block; 184 | position: relative; 185 | border-radius: 2px; 186 | margin: 24px 0; 187 | padding: 12px 24px; 188 | background-color: #f3f5f7; 189 | color: #8c8c8c; 190 | font-weight: 500; 191 | font-style: italic; 192 | 193 | p { 194 | margin-bottom: 0; 195 | } 196 | } 197 | 198 | img { 199 | display: block; 200 | margin: 25px auto; 201 | max-width: 100%; 202 | height: auto; 203 | border: 0; 204 | border-radius: 5px; 205 | } 206 | 207 | table { 208 | border-collapse: collapse; 209 | margin: 16px 0; 210 | display: block; 211 | overflow-x: auto; 212 | box-sizing: border-box; 213 | border-spacing: 2px; 214 | border-color: grey; 215 | 216 | thead { 217 | display: table-header-group; 218 | vertical-align: middle; 219 | border-color: inherit; 220 | // background-color: #f8fafc; 221 | background-color: #f5f5f5; 222 | } 223 | 224 | tbody { 225 | tr:hover { 226 | background-color: #f8fafc; 227 | } 228 | } 229 | 230 | tr { 231 | border-top: 1px solid #dfe2e5; 232 | } 233 | 234 | td, 235 | th { 236 | border: 1px solid #dfe2e5; 237 | padding: 0.6em 1em; 238 | } 239 | } 240 | 241 | hr.footnotes-sep { 242 | margin: 48px 0 36px; 243 | border-color: #f0f0f0; 244 | position: relative; 245 | z-index: 0; 246 | 247 | &::before { 248 | content: "本文脚注"; 249 | position: absolute; 250 | top: 0; 251 | left: 16px; 252 | display: inline-block; 253 | transform: translateY(-50%); 254 | z-index: 1; 255 | padding: 0 16px; 256 | background: white; 257 | color: rgba(0, 0, 0, 0.85); 258 | font-weight: 500; 259 | font-size: 1.1em; 260 | font-style: italic; 261 | } 262 | } 263 | 264 | section.footnotes { 265 | font-weight: 500; 266 | color: #595959; 267 | font-style: italic; 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /frontend/styles/[page].scss: -------------------------------------------------------------------------------- 1 | .page-archive { 2 | &-head { 3 | display: flex; 4 | justify-content: space-between; 5 | align-items: center; 6 | margin: 30px 0; 7 | 8 | &-left { 9 | display: flex; 10 | justify-content: flex-start; 11 | align-items: center; 12 | 13 | span:nth-child(1) { 14 | font-size: 26px; 15 | display: inline-block; 16 | margin-right: 15px; 17 | } 18 | 19 | span:nth-child(2) { 20 | color: #8c8c8c; 21 | font-size: 16px; 22 | font-weight: 300; 23 | } 24 | } 25 | } 26 | 27 | &-psg { 28 | padding: 20px 25px; 29 | 30 | &-icon { 31 | color: white; 32 | background: #1890ff; 33 | width: 56px; 34 | height: 56px; 35 | border-radius: 5px; 36 | display: inline-flex; 37 | align-items: center; 38 | justify-content: center; 39 | font-size: 20px; 40 | font-weight: bolder; 41 | } 42 | 43 | &-title { 44 | overflow: hidden; 45 | display: inline-flex; 46 | margin-left: 15px; 47 | max-width: calc(100% - 56px - 15px); 48 | flex-direction: column; 49 | 50 | h2 { 51 | font-size: 18px; 52 | overflow: hidden; 53 | text-overflow: ellipsis; 54 | white-space: nowrap; 55 | } 56 | 57 | p { 58 | overflow: hidden; 59 | text-overflow: ellipsis; 60 | white-space: nowrap; 61 | margin-bottom: 0; 62 | color: #8c8c8c; 63 | } 64 | } 65 | 66 | &-info { 67 | font-size: 13px; 68 | color: gray; 69 | font-weight: lighter; 70 | margin: 0 14px; 71 | } 72 | } 73 | 74 | &-pagination { 75 | text-align: center; 76 | margin: 30px 0; 77 | 78 | .ant-pagination-prev, 79 | .ant-pagination-next { 80 | display: none; 81 | } 82 | 83 | ul { 84 | display: inline-block; 85 | } 86 | 87 | li { 88 | background-color: transparent; 89 | height: 50px; 90 | width: 50px; 91 | line-height: 50px; 92 | font-size: 17px; 93 | font-weight: lighter; 94 | border-radius: 50%; 95 | } 96 | } 97 | } 98 | 99 | // :hover 100 | .page-archive { 101 | &-psg:hover { 102 | background: white; 103 | cursor: pointer; 104 | 105 | .page-archive-psg-title h2 { 106 | color: #1890ff; 107 | } 108 | 109 | .page-archive-psg-info { 110 | color: #1890ff; 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /frontend/styles/components/search-input.scss: -------------------------------------------------------------------------------- 1 | .cp-search-input { 2 | position: relative; 3 | z-index: 100; 4 | 5 | &-items { 6 | position: absolute; 7 | padding: 5px 0 5px; 8 | max-height: 60vh; 9 | width: 200%; 10 | box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 11 | 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05); 12 | background: white; 13 | border-radius: 3px; 14 | max-height: 400px; 15 | overflow: auto; 16 | } 17 | 18 | &-btn { 19 | line-height: 1.5; 20 | text-align: center; 21 | font-size: 12px; 22 | color: #69c0ff; 23 | cursor: pointer; 24 | margin: 10px 0; 25 | } 26 | 27 | &-item { 28 | padding: 5px 10px; 29 | 30 | &:hover { 31 | background: #e6f7ff; 32 | cursor: pointer; 33 | } 34 | 35 | &-title { 36 | line-height: 1.3; 37 | margin-bottom: 5px; 38 | font-size: 14px; 39 | font-weight: bolder; 40 | color: #595959; 41 | overflow: hidden; 42 | white-space: nowrap; 43 | text-overflow: ellipsis; 44 | } 45 | 46 | &-description { 47 | line-height: 1.1; 48 | font-size: 12px; 49 | overflow: hidden; 50 | white-space: nowrap; 51 | text-overflow: ellipsis; 52 | width: 100%; 53 | color: #8c8c8c; 54 | margin-bottom: 8px; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/styles/home.scss: -------------------------------------------------------------------------------- 1 | .page-home { 2 | height: calc(100vh - 64px); 3 | position: relative; 4 | 5 | &-inner { 6 | position: absolute; 7 | left: 50%; 8 | top: 50%; 9 | transform: translate(-50%, -75%); 10 | width: 100%; 11 | } 12 | 13 | h1 { 14 | font-size: 46px; 15 | text-align: center; 16 | } 17 | 18 | p { 19 | font-size: 18px; 20 | text-align: center; 21 | margin: 20px 0 15px; 22 | color: rgba($color: #000000, $alpha: 0.85); 23 | font-weight: 300; 24 | } 25 | 26 | .page-home-btns { 27 | padding: 30px 0; 28 | text-align: center; 29 | } 30 | 31 | .page-home-btn { 32 | padding: 13px 23px; 33 | font-size: 16px; 34 | border-radius: 40px; 35 | display: inline-flex; 36 | align-items: center; 37 | justify-content: center; 38 | border: 2px solid #1890ff; 39 | margin: 10px 20px 0; 40 | 41 | &:hover { 42 | cursor: pointer; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "./home.scss"; 2 | 3 | @import "./[page].scss"; 4 | 5 | @import "./[article].scss"; 6 | 7 | @import "./package.scss"; 8 | 9 | @import "./components/search-input.scss"; 10 | -------------------------------------------------------------------------------- /frontend/styles/package.scss: -------------------------------------------------------------------------------- 1 | // darkmode.js 2 | 3 | .darkmode-layer, 4 | .darkmode-toggle { 5 | z-index: 500; 6 | } 7 | 8 | .darkmode-layer--button, 9 | .darkmode-toggle { 10 | width: 40px !important; 11 | height: 40px !important; 12 | } 13 | 14 | .darkmode-layer--simple { 15 | height: 100% !important; 16 | width: 100% !important; 17 | } 18 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@cloudbase/node-sdk": "^2.2.3", 25 | "@nestjs/common": "^7.0.0", 26 | "@nestjs/core": "^7.0.0", 27 | "@nestjs/platform-express": "^7.0.0", 28 | "class-transformer": "^0.2.3", 29 | "class-validator": "^0.12.2", 30 | "cookie-parser": "^1.4.5", 31 | "dotenv": "^8.2.0", 32 | "js-yaml": "^3.14.0", 33 | "jsonwebtoken": "^8.5.1", 34 | "lodash": "^4.17.20", 35 | "moment": "^2.29.1", 36 | "quick-lru": "^5.1.1", 37 | "reflect-metadata": "^0.1.13", 38 | "rimraf": "^3.0.2", 39 | "rxjs": "^6.5.4", 40 | "unique-string": "^2.0.0" 41 | }, 42 | "devDependencies": { 43 | "@commitlint/cli": "^11.0.0", 44 | "@commitlint/config-conventional": "^11.0.0", 45 | "@nestjs/cli": "^7.0.0", 46 | "@nestjs/schematics": "^7.0.0", 47 | "@nestjs/testing": "^7.0.0", 48 | "@types/express": "^4.17.3", 49 | "@types/jest": "25.2.3", 50 | "@types/lodash": "^4.14.165", 51 | "@types/node": "^13.9.1", 52 | "@types/supertest": "^2.0.8", 53 | "@typescript-eslint/eslint-plugin": "3.0.2", 54 | "@typescript-eslint/parser": "3.0.2", 55 | "commitizen": "^4.2.2", 56 | "cz-conventional-changelog": "^3.3.0", 57 | "eslint": "7.1.0", 58 | "eslint-config-prettier": "^6.10.0", 59 | "eslint-plugin-import": "^2.20.1", 60 | "husky": "^4.3.0", 61 | "jest": "26.0.1", 62 | "p-limit": "^3.1.0", 63 | "prettier": "^1.19.1", 64 | "supertest": "^4.0.2", 65 | "ts-jest": "26.1.0", 66 | "ts-loader": "^6.2.1", 67 | "ts-node": "^8.6.2", 68 | "tsconfig-paths": "^3.9.0", 69 | "typescript": "^3.7.4", 70 | "uuid": "^8.3.1" 71 | }, 72 | "jest": { 73 | "moduleFileExtensions": [ 74 | "js", 75 | "json", 76 | "ts" 77 | ], 78 | "rootDir": "src", 79 | "testRegex": ".spec.ts$", 80 | "transform": { 81 | "^.+\\.(t|j)s$": "ts-jest" 82 | }, 83 | "coverageDirectory": "../coverage", 84 | "testEnvironment": "node" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/apis/notice/notice.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Query, 5 | ValidationPipe, 6 | UsePipes, 7 | } from '@nestjs/common'; 8 | import { NoticeService } from './notice.service'; 9 | import { DescribeNoticesDto } from './notice.dto'; 10 | 11 | @Controller('notice') 12 | export class NoticeController { 13 | constructor(private readonly noticeService: NoticeService) {} 14 | 15 | @Get() 16 | async describeNotices( 17 | @Query(new ValidationPipe({ whitelist: true, transform: true })) 18 | query: DescribeNoticesDto, 19 | ) { 20 | const { startTime, size } = query; 21 | const noticesRet = await this.noticeService.describeNotices( 22 | startTime, 23 | size, 24 | ); 25 | return noticesRet; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/apis/notice/notice.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsInt, 3 | IsString, 4 | IsNumberString, 5 | IsNumber, 6 | IsOptional, 7 | Max, 8 | Min, 9 | } from 'class-validator'; 10 | import { Transform } from 'class-transformer'; 11 | 12 | export class DescribeNoticesDto { 13 | // 不论先后顺序,@Transform 都会先被调用 14 | @Transform(value => parseInt(value, 10)) 15 | @IsInt() 16 | @Min(1) 17 | readonly startTime: number; 18 | 19 | @Transform(value => { 20 | value = parseInt(value, 10); 21 | return value > 100 ? 100 : value; 22 | }) 23 | @IsOptional() 24 | @IsInt() 25 | @Max(100) 26 | @Min(1) 27 | readonly size = 5; 28 | } 29 | -------------------------------------------------------------------------------- /src/apis/notice/notice.interface.ts: -------------------------------------------------------------------------------- 1 | export interface NoticeSchema { 2 | noticeTitle: string; 3 | noticeContent: string; 4 | noticeTime: typeof Date; 5 | noticeExpiration?: number; 6 | } 7 | 8 | export interface DescribeNoticesReturn { 9 | notices: NoticeSchema[]; 10 | count: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/apis/notice/notice.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DescribeNoticesReturn } from './notice.interface'; 3 | import { TcbService } from './../../services/tcb.service'; 4 | import { COLLECTION_NOTICES } from './../../constants/' 5 | 6 | @Injectable() 7 | export class NoticeService { 8 | constructor(private tcbService: TcbService) { } 9 | 10 | async describeNotices( 11 | startTime: number, 12 | size: number, 13 | ): Promise { 14 | const db = this.tcbService.getDB(); 15 | const collection = this.tcbService.getCollection(COLLECTION_NOTICES); 16 | 17 | const { data } = await collection 18 | .where({ 19 | noticeTime: db.command.gt(new Date(startTime)), 20 | }) 21 | .orderBy('noticeTime', 'desc') 22 | .limit(size) 23 | .field({ 24 | _id: true, 25 | noticeTitle: true, 26 | noticeContent: true, 27 | noticeTime: true, 28 | }) 29 | .get(); 30 | 31 | return { 32 | notices: data, 33 | count: data.length, 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/apis/passage/passage.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Query, 5 | ValidationPipe, 6 | } from '@nestjs/common'; 7 | import { PassageService } from './passage.service'; 8 | import { DescribePassageByIdDto, DescribePassagesDto } from './passage.dto'; 9 | 10 | @Controller('passage') 11 | export class PassageController { 12 | constructor(private readonly passageService: PassageService) { 13 | this.passageService.load() 14 | } 15 | 16 | @Get('describe-passage-by-id') 17 | async describePassageById( 18 | @Query(new ValidationPipe({ whitelist: false })) 19 | query: DescribePassageByIdDto 20 | ) { 21 | const { passageId } = query 22 | return await this.passageService.describePassageById(passageId); 23 | } 24 | 25 | @Get('describe-passages') 26 | async describePassages( 27 | @Query(new ValidationPipe({ whitelist: true, transform: true })) 28 | query: DescribePassagesDto 29 | ) { 30 | const { limit, page } = query 31 | return await this.passageService.describePassages(limit, page) 32 | } 33 | 34 | @Get('count-all-passages') 35 | async countAllPassages() { 36 | return await this.passageService.countAllPassages() 37 | } 38 | 39 | @Get('describe-all-passage-ids') 40 | async describeAllPassageIds() { 41 | return await this.passageService.describeAllPassageIds() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/apis/passage/passage.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsInt, 3 | IsString, 4 | Max, 5 | Min, 6 | } from 'class-validator'; 7 | import { Transform } from 'class-transformer'; 8 | 9 | export class DescribePassageByIdDto { 10 | @IsString() 11 | readonly passageId: string; 12 | } 13 | 14 | export class DescribePassagesDto { 15 | @Transform(value => parseInt(value, 10)) 16 | @IsInt() 17 | @Min(1) 18 | @Max(100) 19 | readonly limit: number; 20 | 21 | @Transform(value => parseInt(value, 10)) 22 | @IsInt() 23 | @Min(1) 24 | readonly page: number; 25 | } 26 | -------------------------------------------------------------------------------- /src/apis/passage/passage.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PassageSchema { 2 | filepath: string 3 | filename: string 4 | title: string 5 | content: string 6 | description: string 7 | mtime: string 8 | date: string 9 | permalink: string 10 | } 11 | -------------------------------------------------------------------------------- /src/apis/passage/passage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope } from '@nestjs/common'; 2 | import { PassageSchema } from './passage.interface'; 3 | import { NOTES_FOLDER, COLLECTION_PASSAGES } from './../../constants' 4 | import * as fs from 'fs' 5 | import * as path from 'path' 6 | import * as moment from 'moment' 7 | import * as yaml from 'js-yaml' 8 | import * as prettier from 'prettier' 9 | import { LoggerService } from 'src/services/logger.service'; 10 | import { TcbService } from 'src/services/tcb.service'; 11 | import { AsyncLimitService } from 'src/services/async-limit.service'; 12 | 13 | import { EventEmitter } from 'events'; 14 | 15 | const fsPromises = fs.promises; 16 | let uploaded = false; 17 | 18 | @Injectable({ 19 | scope: Scope.DEFAULT 20 | }) 21 | export class PassageService extends EventEmitter { 22 | private readonly folderName: string 23 | private readonly mdRe: RegExp 24 | private readonly validNameRe: RegExp 25 | private readonly timeFormat: string 26 | private passages: PassageSchema[] 27 | 28 | constructor( 29 | private readonly loggerService: LoggerService, 30 | private readonly tcbService: TcbService, 31 | private readonly asyncLimitService: AsyncLimitService 32 | ) { 33 | super() 34 | this.folderName = NOTES_FOLDER 35 | this.mdRe = /(---\n((.|\n)*?)\n---\n)?((.|\n)*)/ 36 | this.validNameRe = /^\d+\./ 37 | this.timeFormat = 'YYYY-MM-DD HH:mm:ss' 38 | this.passages = [] 39 | 40 | this.asyncLimitService.init('passage', 10) 41 | 42 | this.on('upload', this.onUpload) 43 | } 44 | 45 | public async load(asc: boolean = false) { 46 | if (!fs.existsSync(this.folderName)) { 47 | throw new Error(`Load passage fail: ${this.folderName} is invalid`) 48 | } 49 | 50 | this.loggerService.info({ content: 'Start load passage', logType: 'LoadPassageStart' }) 51 | this.passages = [] 52 | 53 | await this._load(this.folderName) 54 | this.emit('upload') 55 | 56 | if (asc) { 57 | this.passages.sort((a, b) => { 58 | if (a.date < b.date) return -1 59 | else if (a.date > b.date) return 1 60 | return 0 61 | }) 62 | } else { 63 | this.passages.sort((a, b) => { 64 | if (a.date < b.date) return 1 65 | else if (a.date > b.date) return -1 66 | return 0 67 | }) 68 | } 69 | this.loggerService.info({ content: `Finish load ${this.passages.length} valid passages`, logType: 'LoadPassageSuccess' }) 70 | return this.passages 71 | } 72 | 73 | public describePassageById(psgId: string): PassageSchema { 74 | return this.passages.find(item => item.permalink === psgId) 75 | } 76 | 77 | public describePassages(limit: number = 10, page: number = 1): PassageSchema[] { 78 | return this.passages.slice((page - 1) * limit, page * limit) 79 | } 80 | 81 | public countAllPassages(): number { 82 | return this.passages.length 83 | } 84 | 85 | public describeAllPassageIds(): string[] { 86 | return this.passages.map(item => item.permalink) 87 | } 88 | 89 | private async _load(parentPath: string) { 90 | const folders = await fsPromises.readdir(parentPath); 91 | 92 | for (const folderName of folders) { 93 | if (!this.isValidName(folderName)) { 94 | continue 95 | } 96 | 97 | const folderPath = path.resolve(parentPath, folderName) 98 | const stat = await fsPromises.stat(folderPath) 99 | 100 | if (stat.isFile() && folderName.endsWith('.md')) { 101 | try { 102 | this.passages.push(await this.parseFile(folderPath)) 103 | } catch (error) { 104 | this.loggerService.error({ 105 | content: `Warning: ${folderPath} parse failed.`, 106 | errMsg: error.message 107 | }) 108 | } 109 | } else if (stat.isDirectory()) { 110 | await this._load(folderPath) 111 | } 112 | } 113 | } 114 | 115 | private async onUpload() { 116 | if (uploaded) { 117 | return this.loggerService.info({ 118 | logType: 'UploadRepeated', 119 | content: 'Please close and rerun server' 120 | }) 121 | } 122 | 123 | const promises = [] 124 | const { pLimit } = this.asyncLimitService.get('passage') 125 | for (const passage of this.passages) { 126 | promises.push(pLimit(() => this.updatePassage(passage))) 127 | } 128 | await Promise.all(promises) 129 | this.loggerService.info({ 130 | logType: 'UploadSuccess' 131 | }) 132 | } 133 | 134 | /** 135 | * permalink 是唯一索引 136 | */ 137 | private async updatePassage(passage: PassageSchema) { 138 | const collection = this.tcbService.getCollection(COLLECTION_PASSAGES) 139 | const res1 = await collection.where({ permalink: passage.permalink }).get() 140 | if (res1.data.length) { 141 | await collection.doc(res1.data[0]._id).update(passage) 142 | } else { 143 | await collection.add(passage) 144 | } 145 | this.loggerService.info({ 146 | logType: 'UpdatePassageSuccess', 147 | content: JSON.stringify({ 148 | title: passage.title, 149 | id: passage.permalink 150 | }) 151 | }) 152 | } 153 | 154 | private isValidName(name: string): boolean { 155 | if (name.toLocaleLowerCase() === 'readme.md') { 156 | return true 157 | } 158 | return this.validNameRe.test(name); 159 | } 160 | 161 | private async parseFile(filepath: string): Promise { 162 | const content = await fsPromises.readFile(filepath, { 163 | encoding: 'utf8' 164 | }) 165 | const [, , yamlContent, , mdContent] = this.mdRe.exec(content) 166 | const yamlInfo = yaml.safeLoad(yamlContent) 167 | 168 | if (yamlInfo && yamlInfo.permalink) { 169 | let mtimeStr = this.formatDate(yamlInfo.date) 170 | let formatMdContent = prettier.format(mdContent, { parser: 'markdown' }) 171 | let filename = this.parseName(filepath) 172 | return { 173 | filename, 174 | filepath, 175 | title: yamlInfo.title || filename, 176 | content: formatMdContent, 177 | description: formatMdContent 178 | .replace(/\n/g, "") 179 | .trim() 180 | .slice(0, 155) + ".....", 181 | mtime: mtimeStr, 182 | date: mtimeStr.slice(0, 10), 183 | permalink: yamlInfo.permalink 184 | } 185 | } 186 | throw new Error(`${filepath}'s frontmatter is invalid`) 187 | } 188 | 189 | private parseName(filepath: string): string { 190 | const info = path.parse(filepath) 191 | if (info.name.toLocaleLowerCase() !== 'readme') { 192 | return info.name 193 | } else { 194 | // /workhome/notes/patha/pathb/06.云开发.md 195 | // => 云开发 196 | return info.dir.split(path.sep).pop().replace(/^\d*?\./, '') 197 | } 198 | } 199 | 200 | private formatDate(timeStr): string { 201 | if (!timeStr) { 202 | return moment().format(this.timeFormat) 203 | } 204 | 205 | const instance = moment(timeStr, true) 206 | if (!instance.isValid()) { 207 | throw new Error(`frontmatter.date is valid`) 208 | } 209 | 210 | const res = instance.format(this.timeFormat) 211 | if (res.toLowerCase().includes('invalid')) { 212 | return moment().format(this.timeFormat) 213 | } else { 214 | return res 215 | } 216 | } 217 | } -------------------------------------------------------------------------------- /src/apis/search/search.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, ValidationPipe, Body, Post } from '@nestjs/common'; 2 | import { SearchService } from './search.service'; 3 | import { SearchPassagesDto } from './search.dto'; 4 | import { SearchPassagesReturn } from './search.interface'; 5 | 6 | @Controller('search') 7 | export class SearchController { 8 | constructor(private readonly searchService: SearchService) {} 9 | 10 | @Post('/passages') 11 | async searchPassages( 12 | @Body(new ValidationPipe({ whitelist: true, transform: true })) 13 | body: SearchPassagesDto, 14 | ): Promise { 15 | const { page, size, keywords } = body; 16 | keywords.forEach((_, index) => { 17 | keywords[index] = keywords[index].trim(); 18 | }); 19 | 20 | if (keywords.length === 0) { 21 | return { 22 | count: 0, 23 | passages: [], 24 | }; 25 | } 26 | 27 | return await this.searchService.searchPassages(page, size, keywords); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/apis/search/search.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsInt, 3 | IsString, 4 | IsNumberString, 5 | IsNumber, 6 | IsOptional, 7 | Max, 8 | Min, 9 | IsArray, 10 | } from 'class-validator'; 11 | import { Transform } from 'class-transformer'; 12 | 13 | export class SearchPassagesDto { 14 | @IsInt() 15 | @Min(1) 16 | readonly page: number; 17 | 18 | @IsInt() 19 | @Min(1) 20 | readonly size: number; 21 | 22 | @Transform(value => { 23 | return Array.isArray(value) ? value : []; 24 | }) 25 | @IsOptional() 26 | @IsArray() 27 | @IsString({ each: true }) 28 | readonly keywords: string[]; 29 | } 30 | -------------------------------------------------------------------------------- /src/apis/search/search.interface.ts: -------------------------------------------------------------------------------- 1 | interface SearchPassageSchema { 2 | title: string; 3 | psgID: string; 4 | content: string; 5 | publishTime: string; 6 | goodTimes: number; 7 | } 8 | 9 | export interface SearchPassagesReturn { 10 | passages: SearchPassageSchema[]; 11 | count: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/apis/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { TcbService } from './../../services/tcb.service'; 3 | import { COLLECTION_PASSAGES } from './../../constants/'; 4 | import { SearchPassagesReturn } from './search.interface'; 5 | 6 | @Injectable() 7 | export class SearchService { 8 | constructor(private tcbService: TcbService) { } 9 | 10 | async searchPassages( 11 | page: number, 12 | size: number, 13 | keywords: string[], 14 | ): Promise { 15 | const db = this.tcbService.getDB(); 16 | const collection = this.tcbService.getCollection(COLLECTION_PASSAGES); 17 | const _ = db.command; 18 | 19 | const rule = new RegExp(keywords.join('|')); 20 | 21 | const query = _.and([ 22 | // todo 23 | // { 24 | // isPublished: true, 25 | // }, 26 | _.or([{ content: rule }, { title: rule }]), 27 | ]); 28 | 29 | const { total } = await collection.where(query).count(); 30 | const { data } = await collection 31 | .where(query) 32 | .skip((page - 1) * size) 33 | .limit(size) 34 | .field({ 35 | _id: false, 36 | title: true, 37 | psgID: true, 38 | content: true, 39 | publishTime: true, 40 | goodTimes: true, 41 | }) 42 | .get(); 43 | 44 | return { 45 | count: total, 46 | passages: data, 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/apis/seo/seo.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller('seo') 4 | export class SeoController { } 5 | -------------------------------------------------------------------------------- /src/apis/times/times.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Headers, BadRequestException } from '@nestjs/common' 2 | import { TimesService } from './times.service'; 3 | import * as jwt from 'jsonwebtoken' 4 | import { EnvService } from 'src/services/env.service'; 5 | import { IncomingHttpHeaders } from 'http'; 6 | 7 | @Controller('times') 8 | export class TimesController { 9 | private readonly viewerTimesCookieTtl: string 10 | 11 | constructor( 12 | private readonly timesService: TimesService, 13 | private readonly envService: EnvService 14 | ) { 15 | this.viewerTimesCookieTtl = '86400000' 16 | } 17 | 18 | @Get('view') 19 | async logView(@Headers() headers: IncomingHttpHeaders) { 20 | if (!headers.host || !headers['x-cloudpress-url']) { 21 | throw new BadRequestException('来源非法') 22 | } 23 | 24 | const url = new URL(`${headers.origin}${headers['x-cloudpress-url']}`) 25 | const secret = this.envService.getEnvironmentVariable('TOKEN_SECRET') 26 | 27 | let token = headers['x-cloudpress-token'] || '' 28 | let data: { 29 | domain: string 30 | } = {} as any 31 | let newViewer; 32 | try { 33 | jwt.verify(token, secret) 34 | data = jwt.decode(token, secret) 35 | newViewer = false; 36 | } catch (err) { 37 | data = { domain: url.host } 38 | token = jwt.sign(data, secret, { 39 | expiresIn: this.viewerTimesCookieTtl 40 | }) 41 | newViewer = true; 42 | } 43 | 44 | const { totalViewers, totalViews } = await this.timesService.describeAndUpdateTotal(url.host, newViewer) 45 | const pageView = await this.timesService.describeAndUpdatePageView(url.host, url.pathname) 46 | return { 47 | pageView, 48 | totalViewers, 49 | totalViews, 50 | token 51 | } 52 | } 53 | 54 | // 注:在 controller 中使用 @Res,需要自己调用 res.end(),否则服务会 handling 55 | // doc:https://docs.nestjs.cn/7/controllers 56 | // @Get('viewer') 57 | // async logViewer( 58 | // @Req() req: Request, 59 | // @Res() res: Response 60 | // ) { 61 | // const { requestHostname, requestId } = asyncLocalStorage.getStore() 62 | // const resJson = { 63 | // requestId 64 | // } 65 | 66 | // try { 67 | // const viewerTimesCookie: string = req.cookies[this.viewerTimesCookieKey] || '' 68 | // const checkRes = this.timesService.checkToken(viewerTimesCookie, requestHostname) 69 | // if (checkRes.valid) { 70 | // resJson.result = checkRes.times 71 | // resJson.code = 0 72 | // } else { 73 | // const times = await this.timesService.logViewer(requestHostname) 74 | // res.cookie( 75 | // this.viewerTimesCookieKey, 76 | // this.timesService.getToken(requestHostname, times), 77 | // { 78 | // maxAge: this.viewerTimesCookieTtl, 79 | // httpOnly: true 80 | // } 81 | // ) 82 | // resJson.result = times 83 | // resJson.code = 0 84 | // } 85 | // } catch (error) { 86 | // resJson.code = 500 87 | // resJson.errMsg = error.message 88 | // } 89 | 90 | // res.json(resJson).end() 91 | // } 92 | } -------------------------------------------------------------------------------- /src/apis/times/times.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { TcbService } from 'src/services/tcb.service'; 3 | import { COLLECTION_PAGE_VIEWS, COLLECTION_SITES } from '../../constants'; 4 | import { sha256 } from '../../utils'; 5 | import { EnvService } from './../../services/env.service'; 6 | import * as _ from 'lodash' 7 | 8 | @Injectable() 9 | export class TimesService { 10 | constructor(private readonly tcbService: TcbService, 11 | private readonly envService: EnvService 12 | ) { } 13 | 14 | public async describeAndUpdatePageView( 15 | hostname: string, 16 | path: string 17 | ): Promise { 18 | const collection = this.tcbService.getCollection(COLLECTION_PAGE_VIEWS) 19 | const _ = this.tcbService.getDB().command 20 | 21 | const timesTotal = await this.checkBeforeDescribeAndUpdatePageView(hostname, path) 22 | // 异步更新,减少阻塞时间 23 | collection.where({ 24 | hostname, 25 | path 26 | }) 27 | .update({ 28 | times: _.inc(1) 29 | }) 30 | return timesTotal + 1 31 | } 32 | 33 | /** 34 | * 将hostname+path作为唯一索引,有3种情况: 35 | * 1. 数据库中有一条数据,那么不用做任何处理 36 | * 2. 数据库中有超过一条数据,可能由于脏写造成,此时计算总次数,删除原有数据,重新创建 37 | * 3. 数据库中没有数据,那么创建数据。并发写可能出现脏写,导致多条记录。 38 | * 不过会在下一次触发logView的时候检查并且修复(进入2的逻辑),保证最终一致性 39 | */ 40 | private async checkBeforeDescribeAndUpdatePageView(hostname: string, path: string): Promise { 41 | const collection = this.tcbService.getCollection(COLLECTION_PAGE_VIEWS) 42 | 43 | const { data } = await collection.where({ 44 | hostname, 45 | path 46 | }).get() 47 | const total = data.length 48 | 49 | if (total === 1) { 50 | return data[0].times 51 | } 52 | 53 | let timesTotal = 0 54 | if (total > 1) { 55 | for (const item of data) { 56 | timesTotal += (item.times) || 1 57 | } 58 | 59 | await collection.where({ 60 | hostname, 61 | path 62 | }).remove() 63 | } 64 | 65 | await collection.add({ 66 | hostname, 67 | path, 68 | times: timesTotal 69 | }) 70 | 71 | return timesTotal 72 | } 73 | 74 | /** 75 | * hostname是物理索引(唯一) 76 | * 可能同时创建导致只有一个成功,但数据已经在其它客户端创建成功,重新获取一遍即可 77 | */ 78 | public async describeAndUpdateTotal(hostname: string, newViewer?: boolean): Promise<{ 79 | totalViewers: number, 80 | totalViews: number 81 | }> { 82 | const collection = this.tcbService.getCollection(COLLECTION_SITES) 83 | const _ = this.tcbService.getDB().command 84 | 85 | let { data } = await collection.where({ hostname }).get() 86 | if (data.length === 0) { 87 | try { 88 | // 创建成功 89 | await collection.add({ hostname, totalViewers: 1, totalViews: 1 }) 90 | return { totalViewers: 1, totalViews: 1 } 91 | } catch (err) { 92 | // 创建失败可能是由于并发创建导致唯一索引键相同而冲突 93 | // 此时数据已经在其它客户端创建成功,尝试重新获取一遍即可 94 | let retryRes = await collection.where({ hostname }).get() 95 | data = retryRes.data 96 | } 97 | } 98 | 99 | const updates: any = { totalViews: _.inc(1) } 100 | if (newViewer) { 101 | updates.totalViewers = _.inc(1) 102 | } 103 | collection.where({ hostname }).update(updates) 104 | 105 | return { 106 | totalViewers: data[0].totalViewers, 107 | totalViews: data[0].totalViews + 1 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) { } 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { SeoController } from './apis/seo/seo.controller'; 5 | import { NoticeController } from './apis/notice/notice.controller'; 6 | import { NoticeService } from './apis/notice/notice.service'; 7 | import { ClsMiddleware } from './middlewares/cls.middleware'; 8 | import { APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; 9 | import { ResponseInterceptor } from './interceptors/response.interceptor'; 10 | import { AllExceptionFilter } from './filters/all-exception.filter'; 11 | import { SearchController } from './apis/search/search.controller'; 12 | import { SearchService } from './apis/search/search.service'; 13 | import { LoggerService } from './services/logger.service'; 14 | import { PassageController } from './apis/passage/passage.controller'; 15 | import { TimesController } from './apis/times/times.controller' 16 | import { PassageService } from './apis/passage/passage.service'; 17 | import { LocalCacheService } from './services/local-cache.service'; 18 | import { EnvService } from './services/env.service'; 19 | import { TcbService } from './services/tcb.service'; 20 | import { AsyncLimitService } from './services/async-limit.service'; 21 | import { TimesService } from './apis/times/times.service' 22 | 23 | @Module({ 24 | controllers: [ 25 | AppController, 26 | SeoController, 27 | NoticeController, 28 | SearchController, 29 | PassageController, 30 | TimesController 31 | ], 32 | providers: [ 33 | AppService, 34 | NoticeService, 35 | { 36 | provide: APP_INTERCEPTOR, 37 | useClass: ResponseInterceptor, 38 | }, 39 | { 40 | provide: APP_FILTER, 41 | useClass: AllExceptionFilter, 42 | }, 43 | SearchService, 44 | LoggerService, 45 | PassageService, 46 | LocalCacheService, 47 | EnvService, 48 | TcbService, 49 | AsyncLimitService, 50 | TimesService 51 | ], 52 | }) 53 | export class AppModule implements NestModule { 54 | configure(consumer: MiddlewareConsumer) { 55 | const globalMiddlewares = [ClsMiddleware]; 56 | consumer.apply(...globalMiddlewares).forRoutes('*'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello Cloudpress! View demo at: https://xxoo521.com/'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/constants/cache.ts: -------------------------------------------------------------------------------- 1 | // 本地缓存有效期 2 | export const CACHE_LOCAL_TTL = 60 * 1000; 3 | 4 | // 本地缓存LRU最大容量 5 | export const CAHCE_LOCAL_LRU_MAX_SIZE = 1000; 6 | -------------------------------------------------------------------------------- /src/constants/collection.ts: -------------------------------------------------------------------------------- 1 | // 存放通知的集合名 2 | export const COLLECTION_NOTICES = 'cloudpress-v1-notices'; 3 | 4 | // 存放文档的集合名 5 | // 索引:permalink 字段 6 | export const COLLECTION_PASSAGES = 'cloudpress-v1-passages'; 7 | 8 | // 存放访问次数的集合名 9 | export const COLLECTION_PAGE_VIEWS = 'cloudpress-v1-pvs'; 10 | 11 | // 存放访问人数的集合名 12 | // 索引:hostname 字段 13 | export const COLLECTION_SITES = 'cloudpress-v1-sites'; 14 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collection'; 2 | 3 | export * from './server'; 4 | 5 | export * from './notes'; 6 | 7 | export * from './cache'; 8 | -------------------------------------------------------------------------------- /src/constants/notes.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | // 文章数据默认路径(建议使用绝对路径) 4 | export const NOTES_FOLDER = path.join(process.cwd(), 'notes'); 5 | -------------------------------------------------------------------------------- /src/constants/server.ts: -------------------------------------------------------------------------------- 1 | // 服务监听端口 2 | export const SERVER_PORT = 80; 3 | 4 | // 跨域访问白名单 5 | export const SERVER_ALLOWED_HOSTS: string[] = ['*']; 6 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as dotenv from 'dotenv'; 4 | 5 | interface ConfigSchema { 6 | [key: string]: any; 7 | } 8 | 9 | export class Config { 10 | private readonly mode: string; 11 | private cache: ConfigSchema; 12 | 13 | constructor(mode: string) { 14 | this.mode = mode; 15 | this.cache = null; 16 | } 17 | 18 | public loadConfig(): void { 19 | // 避免重复加载 20 | if (this.cache) { 21 | return; 22 | } 23 | 24 | // 如果是生产环境,一些所需的信息会通过CI工具注入到process.env中 25 | if (this.mode === 'production') { 26 | this.cache = process.env; 27 | return; 28 | } 29 | 30 | // 本地开发环境,需要读取项目目录中的 .env 文件 31 | const filepath = path.join(__dirname, '..', '.env'); 32 | if (!fs.existsSync(filepath)) { 33 | throw new Error(`Please create configuration file: ${filepath}`); 34 | } 35 | 36 | const localEnv = dotenv.config({ path: filepath }).parsed; 37 | this.cache = { 38 | ...process.env, 39 | ...localEnv, 40 | }; 41 | } 42 | 43 | public read(key: string): string { 44 | return this.cache[key]; 45 | } 46 | 47 | public put(key: string, value: any): void { 48 | this.cache[key] = value; 49 | } 50 | } 51 | 52 | export const configInstance = new Config(process.env.NODE_ENV); 53 | -------------------------------------------------------------------------------- /src/filters/all-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | HttpStatus, 7 | Injectable 8 | } from '@nestjs/common'; 9 | import { Response } from 'express'; 10 | import { inspect } from 'util'; 11 | import { asyncLocalStorage } from './../utils/'; 12 | import { LoggerService } from 'src/services/logger.service'; 13 | 14 | interface HttpExceptionResponseSchema { 15 | message: string; 16 | statusCode: number; 17 | error: string; 18 | } 19 | 20 | function isError(error: any): error is Error { 21 | return error instanceof Error; 22 | } 23 | 24 | function isHttpExceptionResponse( 25 | response: any, 26 | ): response is HttpExceptionResponseSchema { 27 | return typeof response.message === 'string'; 28 | } 29 | 30 | @Injectable() 31 | @Catch() 32 | export class AllExceptionFilter implements ExceptionFilter { 33 | constructor(private readonly loggerService: LoggerService) { } 34 | 35 | catch(exception: T, host: ArgumentsHost) { 36 | const ctx = host.switchToHttp(); 37 | const res: Response = ctx.getResponse(); 38 | 39 | const errStatus = 40 | exception instanceof HttpException 41 | ? exception.getStatus() 42 | : HttpStatus.INTERNAL_SERVER_ERROR; 43 | 44 | let errMsg: string; 45 | let errStack: string; 46 | if (exception instanceof HttpException) { 47 | // 如果是 NestJS 内置错误 48 | const exceptionResponse = exception.getResponse(); 49 | errStack = exception.stack; 50 | if (isHttpExceptionResponse(exceptionResponse)) { 51 | // 代码中直接抛出内置错误,并且没有改写错误信息 52 | errMsg = exceptionResponse.message; 53 | } else { 54 | // 代码中直接抛出内置错误,并且改写了错误信息 55 | errMsg = inspect(exceptionResponse); 56 | } 57 | } else if (isError(exception)) { 58 | // 如果是代码中直接抛出了异常 59 | errMsg = exception.message; 60 | errStack = exception.stack; 61 | } else { 62 | // 未知问题 63 | errMsg = 'UNKNOWN_ERROR'; 64 | errStack = ''; 65 | } 66 | 67 | this.loggerService.error({ 68 | logType: 'ErrorResponse', 69 | errMsg, 70 | errStatus, 71 | errStack, 72 | }); 73 | 74 | const resJson: IResponse = { 75 | code: errStatus, 76 | requestId: asyncLocalStorage.getStore()?.requestId, 77 | errMsg, 78 | }; 79 | res.status(200).json(resJson); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { map } from 'rxjs/operators'; 9 | import { LoggerService } from 'src/services/logger.service'; 10 | import { asyncLocalStorage } from './../utils/'; 11 | 12 | 13 | @Injectable() 14 | export class ResponseInterceptor implements NestInterceptor { 15 | constructor(private readonly loggerService: LoggerService) { } 16 | 17 | intercept(context: ExecutionContext, next: CallHandler): Observable { 18 | return next.handle().pipe( 19 | map(result => { 20 | this.loggerService.info({ 21 | logType: 'SuccessResponse' 22 | }); 23 | 24 | return { 25 | code: 0, 26 | result: result || null, 27 | // 抛错时,可能异步调用上下文没有创建,此时拿不到requestId 28 | requestId: asyncLocalStorage.getStore()?.requestId 29 | }; 30 | }), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as url from 'url'; 4 | import * as cookieParser from 'cookie-parser' 5 | import { SERVER_PORT, SERVER_ALLOWED_HOSTS } from './constants/' 6 | 7 | const allowAllHosts = SERVER_ALLOWED_HOSTS.includes('*'); 8 | 9 | async function bootstrap() { 10 | const app = await NestFactory.create(AppModule); 11 | app.enableCors({ 12 | origin: (req, callback) => { 13 | const { hostname } = url.parse(req || ''); 14 | callback(null, allowAllHosts || SERVER_ALLOWED_HOSTS.includes(hostname)); 15 | }, 16 | }); 17 | app.use(cookieParser()); 18 | await app.listen(SERVER_PORT); 19 | } 20 | bootstrap(); 21 | -------------------------------------------------------------------------------- /src/middlewares/cls.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { uuidV4, asyncLocalStorage } from './../utils/'; 3 | import { Request, Response } from 'express'; 4 | import { LoggerService } from 'src/services/logger.service'; 5 | 6 | @Injectable() 7 | export class ClsMiddleware implements NestMiddleware { 8 | constructor(private readonly logger: LoggerService) { } 9 | 10 | use(req: Request, res: Response, next: () => void) { 11 | const cls: ICls = { 12 | requestId: uuidV4(), 13 | requestTime: Date.now(), 14 | requestMethod: req.method, 15 | requestPath: req.originalUrl, 16 | requestHostname: req.hostname, 17 | }; 18 | 19 | asyncLocalStorage.run(cls, () => { 20 | this.logger.info({ logType: 'IncomingRequest' }); 21 | next(); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/services/async-limit.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope } from '@nestjs/common'; 2 | import { asyncLocalStorage } from './../utils/'; 3 | import * as pLimit from 'p-limit' 4 | 5 | interface AsyncLimitSchema { 6 | readonly limitSize: number 7 | readonly pLimit: pLimit.Limit 8 | } 9 | 10 | @Injectable({ 11 | scope: Scope.TRANSIENT 12 | }) 13 | export class AsyncLimitService { 14 | private readonly cache: Record 15 | constructor() { 16 | this.cache = {} 17 | } 18 | 19 | public init(key: string, limitSize?: number): AsyncLimitSchema { 20 | if (key in this.cache) { 21 | return this.cache[key] 22 | } 23 | limitSize = limitSize || 10 24 | this.cache[key] = { 25 | limitSize, 26 | pLimit: pLimit(limitSize) 27 | } 28 | } 29 | 30 | public get(key: string): AsyncLimitSchema { 31 | if (key in this.cache) { 32 | return this.cache[key] 33 | } 34 | throw new Error("Please init limit controler for this key " + key) 35 | } 36 | } -------------------------------------------------------------------------------- /src/services/env.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as dotenv from 'dotenv'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import { LoggerService } from './logger.service'; 6 | 7 | const ENV_KEYS = { 8 | NODE_ENV: 'NODE_ENV', 9 | TCB_ENV_ID: 'TCB_ENV_ID', 10 | TCLOUD_SECRET_ID: 'TCLOUD_SECRET_ID', 11 | TCLOUD_SECRET_KEY: 'TCLOUD_SECRET_KEY', 12 | TOKEN_SECRET: 'TOKEN_SECRET' 13 | } as const; 14 | 15 | export type ENV_KEYS_TYPE = keyof typeof ENV_KEYS; 16 | 17 | @Injectable() 18 | export class EnvService { 19 | constructor(private readonly loggerService: LoggerService) { 20 | this.loadEnv(); 21 | } 22 | 23 | public getEnvironmentVariable(key: ENV_KEYS_TYPE) { 24 | return process.env[key]; 25 | } 26 | 27 | public isDevMode() { 28 | const mode = process.env['NODE_ENV'] || 'development'; 29 | return ['dev', 'development'].includes(mode); 30 | } 31 | 32 | private loadEnv() { 33 | const envPath = path.join(process.cwd(), '.env'); 34 | if (!this.isDevMode()) { 35 | return this.loggerService.info({ 36 | logType: 'EnvLoadPass', 37 | content: `Don't load ${envPath} without development mode`, 38 | }); 39 | } 40 | 41 | const isExist = fs.existsSync(envPath); 42 | if (!isExist) { 43 | return this.loggerService.info({ 44 | logType: 'EnvLoadFail', 45 | content: `Please create ${envPath}`, 46 | }); 47 | } 48 | 49 | const { parsed } = dotenv.config({ path: envPath }); 50 | const envs = >parsed; 51 | for (let key in envs) { 52 | process.env[key] = envs[key]; 53 | } 54 | 55 | this.loggerService.info({ 56 | logType: 'EnvLoadSuccess', 57 | content: `Load ${envPath} in development mode`, 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/services/local-cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope } from '@nestjs/common'; 2 | import { LoggerService } from './logger.service'; 3 | import QuickLru from 'quick-lru'; 4 | import { CACHE_LOCAL_TTL, CAHCE_LOCAL_LRU_MAX_SIZE } from './../constants'; 5 | 6 | export interface LruCache { 7 | value: any; // 缓存值 8 | expire: number; // 过期时间 9 | } 10 | 11 | // 针对每个中间件单独生成实例,防止开发时,缓存key设置不当导致缓存污染,难以排查问题 12 | @Injectable({ 13 | scope: Scope.TRANSIENT, 14 | }) 15 | export class LocalCacheService { 16 | private readonly ttl: number; 17 | private readonly maxSize: number; 18 | private readonly lruCache: QuickLru; 19 | 20 | constructor(private readonly loggerService: LoggerService) { 21 | this.ttl = CACHE_LOCAL_TTL; 22 | this.maxSize = CAHCE_LOCAL_LRU_MAX_SIZE; 23 | this.lruCache = new QuickLru({ maxSize: this.maxSize }); 24 | } 25 | 26 | public set(key: string, params: { value: any; ttl?: number }) { 27 | const ttl = params.ttl || this.ttl; 28 | const ts = Date.now(); 29 | const expire = ts + ttl; 30 | 31 | this.lruCache.set(key, { 32 | expire, 33 | value: params.value, 34 | }); 35 | this.loggerService.info({ 36 | logType: 'LocalCacheHit', 37 | cacheType: 'local', 38 | cacheOperation: 'create', 39 | cacheKey: key, 40 | cacheValue: String(params.value), 41 | }); 42 | } 43 | 44 | public get(key: string): any | void { 45 | const cacheLog = { 46 | logType: 'LocalCacheHit', 47 | cacheType: 'local', 48 | cacheKey: key, 49 | } as const; 50 | 51 | // 缓存不在LRU链表中 52 | if (!this.lruCache.has(key)) { 53 | this.loggerService.info({ 54 | ...cacheLog, 55 | cacheOperation: 'miss', 56 | }); 57 | return; 58 | } 59 | 60 | const data = this.lruCache.get(key); 61 | const { value, expire } = data; 62 | const now = new Date().getTime(); 63 | if (now <= expire) { 64 | this.loggerService.info({ 65 | ...cacheLog, 66 | cacheOperation: 'hit', 67 | cacheValue: String(value), 68 | }); 69 | return value; 70 | } 71 | // 缓存过期,清除本地缓存 72 | this.lruCache.delete(key); 73 | this.loggerService.info({ 74 | ...cacheLog, 75 | cacheOperation: 'del', 76 | }); 77 | return; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { asyncLocalStorage } from './../utils/'; 3 | 4 | export type LogLevel = 'info' | 'error' | 'warn'; 5 | 6 | export interface LogInfo { 7 | // 请求链路信息 8 | requestId?: string; 9 | requestTime?: number; 10 | requestPath?: string; 11 | requestMethod?: string; 12 | requestHostname?: string; 13 | 14 | // 日志属性信息 15 | logLevel?: LogLevel; 16 | logType?: string; 17 | logTime?: number; 18 | 19 | // 错误信息 20 | errStatus?: number; 21 | errMsg?: string; 22 | errStack?: string; 23 | 24 | // http client请求信息 25 | httpClientConfig?: any; 26 | httpClientRes?: string; 27 | httpClientCostTime?: number; 28 | 29 | // 缓存信息 30 | cacheType?: 'local' | 'redis'; 31 | cacheOperation?: 'create' | 'del' | 'hit' | 'miss'; 32 | cacheKey?: string; 33 | cacheValue?: string; 34 | 35 | // 其他信息 36 | costTime?: number; 37 | content?: string; 38 | } 39 | 40 | @Injectable() 41 | export class LoggerService { 42 | constructor() {} 43 | 44 | public info(logInfo: LogInfo) { 45 | this.print({ 46 | ...logInfo, 47 | logLevel: 'info', 48 | }); 49 | } 50 | 51 | public warn(logInfo: LogInfo) { 52 | this.print({ 53 | ...logInfo, 54 | logLevel: 'warn', 55 | }); 56 | } 57 | 58 | public error(logInfo: LogInfo) { 59 | this.print({ 60 | ...logInfo, 61 | logLevel: 'error', 62 | }); 63 | } 64 | 65 | private print(logInfo: LogInfo) { 66 | const now = Date.now(); 67 | const _logInfo: LogInfo = { 68 | logTime: now, 69 | ...logInfo, 70 | }; 71 | 72 | const cls = asyncLocalStorage.getStore(); 73 | if (cls) { 74 | Object.assign(_logInfo, { 75 | ...cls, 76 | costTime: now - cls.requestTime, 77 | }); 78 | } 79 | console.log(JSON.stringify(_logInfo)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/services/tcb.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { EnvService } from './env.service'; 3 | import * as tcb from '@cloudbase/node-sdk'; 4 | import { CloudBase } from '@cloudbase/node-sdk/lib/cloudbase'; 5 | import { Db } from '@cloudbase/database'; 6 | 7 | @Injectable() 8 | export class TcbService { 9 | private readonly app: CloudBase; 10 | 11 | private readonly db: Db; 12 | 13 | constructor( 14 | private readonly envService: EnvService 15 | ) { 16 | this.app = tcb.init({ 17 | secretId: this.envService.getEnvironmentVariable('TCLOUD_SECRET_ID'), 18 | secretKey: this.envService.getEnvironmentVariable('TCLOUD_SECRET_KEY'), 19 | env: this.envService.getEnvironmentVariable('TCB_ENV_ID') 20 | }) 21 | 22 | this.db = this.app.database() 23 | } 24 | 25 | public getApp() { 26 | return this.app 27 | } 28 | 29 | public getDB() { 30 | return this.db 31 | } 32 | 33 | public getCollection(colName: string) { 34 | return this.db.collection(colName) 35 | } 36 | } -------------------------------------------------------------------------------- /src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | // 异步上下文结构体 2 | interface ICls { 3 | requestId: string; 4 | requestTime: number; 5 | requestPath: string; 6 | requestMethod: string; 7 | requestHostname: string; 8 | } 9 | 10 | // 返回结构体 11 | interface IResponse { 12 | code: number; 13 | requestId: string; 14 | result?: any; 15 | errMsg?: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'async_hooks'; 2 | import { v4 } from 'uuid'; 3 | import * as crypto from 'crypto' 4 | 5 | /** 6 | * CLS 7 | */ 8 | export const asyncLocalStorage = new AsyncLocalStorage(); 9 | 10 | /** 11 | * uuid version4 12 | */ 13 | export const uuidV4 = v4; 14 | 15 | /** 16 | * sha256 with hmac 17 | */ 18 | export const sha256 = (data: string, secret = ''): string => { 19 | const hmac = crypto.createHmac('sha256', secret) 20 | hmac.update(data) 21 | return hmac.digest("hex") 22 | } 23 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | }, 15 | "include": [ 16 | "src" 17 | ], 18 | "exclude": [ 19 | "node_modules", 20 | "dist", 21 | "frontend", 22 | "notes", 23 | "database", 24 | "bin", 25 | ".vscode" 26 | ] 27 | } --------------------------------------------------------------------------------