├── .dockerignore
├── .github
└── workflows
│ └── docker-image.yml
├── .gitignore
├── .gitmodules
├── Dockerfile
├── LICENSE
├── README.en.md
├── README.md
├── admin
├── .gitignore
├── README.md
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── robots.txt
└── src
│ ├── actions
│ └── index.js
│ ├── components
│ ├── App.css
│ ├── App.js
│ ├── Comments.js
│ ├── Dashboard.js
│ ├── EditUser.js
│ ├── Editor.js
│ ├── Files.js
│ ├── Login.js
│ ├── Posts.js
│ ├── Settings.js
│ └── Users.js
│ ├── index.css
│ ├── index.js
│ ├── reducers
│ └── index.js
│ └── utils.js
├── app.js
├── bin
├── .gitignore
├── create_page_with_token.py
├── requirements.txt
└── update_tag_and_page_status.py
├── blog.conf
├── common
├── cache.js
├── config.js
├── constant.js
├── database.js
├── migrate.js
├── rss.js
└── util.js
├── config.js
├── controllers
├── file.js
├── index.js
├── option.js
├── page.js
└── user.js
├── data
├── index
│ ├── favicon.ico
│ ├── icon192.png
│ ├── icon512.png
│ ├── manifest.json
│ └── robots.txt
└── upload
│ └── .gitignore
├── middlewares
├── api-auth.js
└── upload.js
├── models
├── file.js
├── index.js
├── option.js
├── page.js
└── user.js
├── package.json
├── public
└── upload
│ └── .gitignore
├── routes
├── api-router.v1.js
└── web-router.js
└── themes
└── bulma
├── archive.ejs
├── article.ejs
├── code.ejs
├── discuss.ejs
├── index.ejs
├── links.ejs
├── list.ejs
├── message.ejs
├── partials
├── comment.ejs
├── footer.ejs
├── header.ejs
├── nav.ejs
├── page-info.ejs
├── paginator.ejs
└── prev-next.ejs
├── raw.ejs
└── static
├── bulma.min.css
├── fontawesome.min.css
├── highlight.min.js
├── main.css
└── main.js
/.dockerignore:
--------------------------------------------------------------------------------
1 | .idea
2 | node_modules
3 | .env
4 | data.db-journal
5 | .vscode
6 | *.db
7 | *.*~
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'reason'
11 | required: false
12 | jobs:
13 | push_to_registries:
14 | name: Push Docker image to multiple registries
15 | runs-on: ubuntu-latest
16 | permissions:
17 | packages: write
18 | contents: read
19 | steps:
20 | - name: Check out the repo
21 | uses: actions/checkout@v3
22 | with:
23 | submodules: 'recursive'
24 | - name: Update version info
25 | run: |
26 | sed -i "0,/v0.0.0/ s/v0.0.0/$(git describe --tags)/" ./config.js
27 |
28 | - name: Log in to Docker Hub
29 | uses: docker/login-action@v2
30 | with:
31 | username: ${{ secrets.DOCKERHUB_USERNAME }}
32 | password: ${{ secrets.DOCKERHUB_TOKEN }}
33 |
34 | - name: Log in to the Container registry
35 | uses: docker/login-action@v2
36 | with:
37 | registry: ghcr.io
38 | username: ${{ github.actor }}
39 | password: ${{ secrets.GITHUB_TOKEN }}
40 |
41 | - name: Extract metadata (tags, labels) for Docker
42 | id: meta
43 | uses: docker/metadata-action@v4
44 | with:
45 | images: |
46 | justsong/blog
47 | ghcr.io/${{ github.repository }}
48 |
49 | - name: Build and push Docker images
50 | uses: docker/build-push-action@v3
51 | with:
52 | context: .
53 | push: true
54 | tags: ${{ steps.meta.outputs.tags }}
55 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | node_modules
3 | *.db
4 | .vs
5 | data.db-journal
6 | /public/admin
7 | /public/ads.txt
8 | /public/*.xml
9 | package-lock.json
10 | yarn.lock
11 | *.log
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "themes/bootstrap"]
2 | path = themes/bootstrap
3 | url = https://github.com/songquanpeng/blog-theme-bootstrap
4 | [submodule "themes/w3"]
5 | path = themes/w3
6 | url = https://github.com/songquanpeng/blog-theme-w3
7 | [submodule "themes/next"]
8 | path = themes/next
9 | url = https://github.com/songquanpeng/blog-theme-next
10 | [submodule "themes/v2ex"]
11 | path = themes/v2ex
12 | url = https://github.com/songquanpeng/blog-theme-v2ex
13 | [submodule "themes/bootstrap5"]
14 | path = themes/bootstrap5
15 | url = https://github.com/songquanpeng/blog-theme-bootstrap5
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16 as builder
2 |
3 | WORKDIR /app
4 | COPY . .
5 | RUN npm install
6 | RUN cd admin && npm run update && cd .. && rm -r admin
7 |
8 | FROM node:16-alpine
9 | WORKDIR /app
10 | COPY --from=builder /app /app
11 | VOLUME ["/app/data"]
12 | RUN npm install sqlite3@5.0.2 # https://github.com/TryGhost/node-sqlite3/issues/1581
13 | RUN npm install pm2 -g
14 | EXPOSE 3000
15 | CMD ["pm2-runtime", "app.js"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 JustSong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.en.md:
--------------------------------------------------------------------------------
1 |
2 | 中文 | English
3 |
4 |
5 |
6 |
7 |
8 | # Blog
9 |
10 | _✨ Node.js based blog system ✨_
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Demo
31 | ·
32 | Tutorial
33 | ·
34 | Feedback
35 |
36 |
37 | ## Description
38 | + This is a blog system powered by Express.js and React.
39 | + Demonstrations
40 | + [My blog](https://iamazing.cn/) (may not be the latest version).
41 | + [Heroku App](https://express-react-blog.herokuapp.com/) (visit the [management system](https://express-react-blog.herokuapp.com/admin/) with default username `admin` and password `123456`)
42 |
43 | ## Highlights
44 | 1. You can use a **code editor** to edit your content (built-in ACE code editor with multiple themes).
45 | 2. Easy to configure and integrate with disqus and statistics system.
46 | 3. You can copy from OneNote or any other programs and **paste your content with formatting** (with the `paste with formatting` feature, don't forget to set the page type to `raw`).
47 | 4. You can use this to deploy your single page web application (such as a [game](https://iamazing.cn/page/online-battle-city)), just paste the code and set the page type to `raw`.
48 | 5. System deploy is extremely simple, no need to configure the database (here I use SQLite as the default database, but it's easy to move to other database, just by modifying the `knexfile.js`).
49 | 6. **Multiple themes available**:
50 | 1. Bulma: default theme.
51 | 2. Bootstrap: [blog-theme-bootstrap](https://github.com/songquanpeng/blog-theme-bootstrap).
52 | 3. W3: [blog-theme-w3](https://github.com/songquanpeng/blog-theme-w3).
53 | 4. V2EX: [blog-theme-v2ex](https://github.com/songquanpeng/blog-theme-v2ex).
54 | 5. Next: [blog-theme-next](https://github.com/songquanpeng/blog-theme-next).
55 | 6. Bootstrap5: [blog-theme-bootstrap5](https://github.com/songquanpeng/blog-theme-bootstrap5).
56 |
57 | ## Deployment
58 | ```shell script
59 | git clone --recurse-submodules https://github.com/songquanpeng/blog.git
60 | cd blog
61 | npm install
62 | npm run build # For Windows user, please run `npm run build2` instead
63 | npm start
64 | ```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | 中文 | English
3 |
4 |
5 |
6 |
7 | # 个人博客系统
8 |
9 | _✨ 基于 Node.js 的个人博客系统 ✨_
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | 截图展示
30 | ·
31 | 在线演示
32 | ·
33 | 部署教程
34 | ·
35 | 意见反馈
36 |
37 |
38 |
39 | ## 描述
40 | 技术栈:Express.js(服务端)+ Sequelize(ORM) + React(后台)+ Ant Design(后台 UI 库)
41 |
42 | 特点:
43 | 1. 支持多种主题。
44 | 2. 支持多种页面类型,文章页面、HTML 页面、链接页面等等。
45 | 3. 无需配置数据库,开箱即用(如果你不想用 SQLite,请修改 `config.js` 配置文件)。
46 | 4. 内置 ACE 代码编辑器,附带多种代码主题(包括 Solarized Light)。
47 | 5. 支持通过 Docker 部署,一行命令即可上线部署,详见[此处](#部署)。
48 | 6. 支持通过 Token 验证发布文章,详见[此处](./bin/create_page_with_token.py)。
49 |
50 | ## 主题
51 | 1. Bulma:Bulma CSS 风格主题,内置的默认主题。
52 | 2. Bootstrap:[Bootstrap 风格主题](https://github.com/songquanpeng/blog-theme-bootstrap)(推荐使用)。
53 | 3. W3:[W3.css 风格主题](https://github.com/songquanpeng/blog-theme-w3)。
54 | 4. V2EX: [V2EX 风格主题](https://github.com/songquanpeng/blog-theme-v2ex)。
55 | 5. Next: [Hexo Next 风格主题](https://github.com/songquanpeng/blog-theme-next)。
56 | 6. Bootstrap5: 借鉴自 [CodeLunatic/halo-theme-simple-bootstrap](https://github.com/CodeLunatic/halo-theme-simple-bootstrap) 的 [Bootstrap5 风格主题](https://github.com/songquanpeng/blog-theme-bootstrap5)。
57 |
58 | 注意:
59 | 1. 更改主题的步骤:打开后台管理系统中的设置页面 -> 自定义设置 -> 找到 THEME -> 修改后点击保存设置,记得浏览器 `Ctrl + F5` 刷新缓存。
60 | + 可选的值有:`bulma`,`bootstrap`,`bootstrap5`,`w3`,`next` 以及 `v2ex`。
61 | 2. 由于精力有限,部分主题可能由于未能及时随项目更新导致存在问题。
62 |
63 | ## 演示
64 | ### 在线演示
65 | 1. [JustSong 的个人博客](https://iamazing.cn) (可能并非最新版本).
66 | 2. [Render App](https://nodejs-blog.onrender.com) ([后台管理系统地址](https://nodejs-blog.onrender.com/admin/) 默认用户名 `admin` 以及密码 `123456`)
67 |
68 | ### 截图展示
69 | 
70 | 
71 | 
72 |
73 | ## 部署
74 | ### 通过 Docker 部署
75 | 执行:`docker run --restart=always -d -p 3000:3000 -v /home/ubuntu/data/blog:/app/data -e TZ=Asia/Shanghai justsong/blog`
76 |
77 | 开放的端口号为 3000,之后用 Nginx 配置域名,反代以及 SSL 证书即可。
78 |
79 | 数据将会保存在宿主机的 `/home/ubuntu/data/blog` 目录(数据库文件和上传的文件)。
80 |
81 | 如果想在网站根目录上传文件,则在该目录下新建一个 `index` 文件夹,里面可以放置 `favicon.ico`, `robots.txt` 等文件,具体参见 `data/index` 目录下的内容。
82 |
83 | 更新博客版本的命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`
84 |
85 | ### 通过源码部署
86 | ```shell script
87 | git clone https://github.com/songquanpeng/blog.git
88 | cd blog
89 | # 获取主题
90 | git submodule init
91 | # 更新主题
92 | git submodule update
93 | # 安装依赖
94 | npm install
95 | # 编译后台管理系统
96 | npm run build # Windows 用户请运行 `npm run build2`
97 | # 启动服务
98 | npm start
99 | # 推荐使用 pm2 进行启动
100 | # 1. 安装 pm2
101 | npm i -g pm2
102 | # 2. 使用 pm2 启动服务
103 | pm2 start ./app.js --name blog
104 | ```
105 |
--------------------------------------------------------------------------------
/admin/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /coverage
3 | /build
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | .idea
8 | .vscode
9 | package-lock.json
10 | yarn.lock
--------------------------------------------------------------------------------
/admin/README.md:
--------------------------------------------------------------------------------
1 | ## Build steps
2 | 1. `npm i`
3 | 2. `npm run build`
4 | 3. Now checkout /build, all done.
5 |
--------------------------------------------------------------------------------
/admin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog-admin",
3 | "version": "0.0.0",
4 | "private": true,
5 | "dependencies": {
6 | "@ant-design/icons": "latest",
7 | "ace-builds": "^1.4.12",
8 | "antd": "^4.6.2",
9 | "axios": "^0.21.1",
10 | "react": "^16.13.1",
11 | "react-ace": "^10.1.0",
12 | "react-dom": "^16.13.1",
13 | "react-redux": "^7.2.1",
14 | "react-router-dom": "^5.2.0",
15 | "react-scripts": "4.0.3",
16 | "redux": "^4.0.5",
17 | "redux-thunk": "^2.3.0"
18 | },
19 | "scripts": {
20 | "start": "react-scripts start",
21 | "build": "react-scripts build",
22 | "test": "react-scripts test",
23 | "eject": "react-scripts eject",
24 | "analyze": "source-map-explorer build/static/js/*",
25 | "update": "npm install && (npm run build; rm -rf ../public/admin); mv ./build ../public && mv ../public/build ../public/admin",
26 | "update2": "npm install && npm run build && rmdir /Q /S ..\\public\\admin & mkdir ..\\public\\admin && Xcopy /E /I /Q .\\build ..\\public\\admin"
27 | },
28 | "eslintConfig": {
29 | "extends": "react-app",
30 | "rules": {
31 | "no-undef": "off"
32 | }
33 | },
34 | "browserslist": {
35 | "production": [
36 | ">0.2%",
37 | "not dead",
38 | "not op_mini all"
39 | ],
40 | "development": [
41 | "last 1 chrome version",
42 | "last 1 firefox version",
43 | "last 1 safari version"
44 | ]
45 | },
46 | "devDependencies": {
47 | "prettier": "^2.1.1",
48 | "source-map-explorer": "^2.5.0"
49 | },
50 | "prettier": {
51 | "singleQuote": true
52 | },
53 | "proxy": "http://localhost:3000",
54 | "homepage": "/admin"
55 | }
56 |
--------------------------------------------------------------------------------
/admin/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/blog/dfb2ae9d0cb5a27fca3a525633785988adbd8fcb/admin/public/favicon.ico
--------------------------------------------------------------------------------
/admin/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Blog Admin
8 |
9 |
10 | You need to enable JavaScript to run this app.
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/admin/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export const getStatus = () => async (dispatch) => {
4 | const res = await axios.get('/api/user/status');
5 |
6 | if (res.data.user) {
7 | dispatch({
8 | type: 'USER_STATUS',
9 | payload: 1,
10 | });
11 |
12 | dispatch({
13 | type: 'USER',
14 | payload: res.data.user,
15 | });
16 | } else {
17 | dispatch({
18 | type: 'USER_STATUS',
19 | payload: 0,
20 | });
21 | }
22 | };
23 |
24 | export const login = (usn, psw) => async (dispatch) => {
25 | const res = await axios.post('/api/user/login', {
26 | username: usn,
27 | password: psw,
28 | });
29 |
30 | const { status, message, user } = res.data;
31 |
32 | if (status) {
33 | dispatch({
34 | type: 'USER_STATUS',
35 | payload: 1,
36 | });
37 |
38 | dispatch({
39 | type: 'USER',
40 | payload: user,
41 | });
42 | }
43 |
44 | return { status, message };
45 | };
46 |
47 | export const logout = () => async (dispatch) => {
48 | const res = await axios.get('/api/user/logout');
49 | const { status, message } = res.data;
50 |
51 | if (status) {
52 | dispatch({
53 | type: 'USER_STATUS',
54 | payload: 0,
55 | });
56 |
57 | dispatch({
58 | // TODO
59 | type: 'CLEAR_USER',
60 | payload: {
61 | userId: -1,
62 | },
63 | });
64 | }
65 |
66 | return { status, message };
67 | };
68 |
--------------------------------------------------------------------------------
/admin/src/components/App.css:
--------------------------------------------------------------------------------
1 | @import "~antd/dist/antd.css";
2 |
3 | .trigger {
4 | font-size: 18px;
5 | line-height: 64px;
6 | padding: 0 24px;
7 | cursor: pointer;
8 | transition: color 0.3s;
9 | }
10 |
11 | .trigger:hover {
12 | color: #1890ff;
13 | }
14 |
15 | .logo {
16 | position: relative;
17 | display: flex;
18 | align-items: center;
19 | padding: 16px 8px;
20 | line-height: 32px;
21 | cursor: pointer;
22 | }
23 |
24 | .logo h1 {
25 | color: white;
26 | margin: auto;
27 | font-size: larger;
28 | font-weight: bold;
29 | }
30 |
31 | .site-layout .site-layout-background {
32 | background: #fff;
33 | }
34 |
35 | .ant-layout-content {
36 | overflow-y: auto;
37 | }
38 |
39 | .content-area {
40 | background: #fff;
41 | padding: 24px;
42 | margin: 24px 16px;
43 | }
44 |
45 | .bottom-right {
46 | position: absolute !important;
47 | bottom: 30px !important;
48 | right: 30px !important;
49 | z-index: 1000;
50 | }
51 |
52 | textarea {
53 | font-family: 'Cascadia Code', Consolas, monospace, 微软雅黑;
54 | }
55 |
56 | .disabled-row {
57 | display: none;
58 | }
--------------------------------------------------------------------------------
/admin/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Layout,
4 | Menu,
5 | Dropdown,
6 | Space,
7 | Button,
8 | message as Message,
9 | } from 'antd';
10 |
11 | import {
12 | MenuUnfoldOutlined,
13 | MenuFoldOutlined,
14 | UserOutlined,
15 | PoweroffOutlined,
16 | FileTextOutlined,
17 | CommentOutlined,
18 | CloudUploadOutlined,
19 | SettingOutlined,
20 | LogoutOutlined,
21 | LoginOutlined,
22 | CodeOutlined,
23 | } from '@ant-design/icons';
24 |
25 | import { Link, Switch, Route } from 'react-router-dom';
26 |
27 | import { connect } from 'react-redux';
28 | import { logout, getStatus } from '../actions';
29 |
30 | import Editor from './Editor';
31 | import Posts from './Posts';
32 | import Settings from './Settings';
33 | import Users from './Users';
34 | import Files from './Files';
35 | import Comments from './Comments';
36 | import Login from './Login';
37 | import EditUser from './EditUser';
38 |
39 | import './App.css';
40 | import axios from 'axios';
41 |
42 | const { Header, Sider, Content } = Layout;
43 |
44 | class App extends React.Component {
45 | state = {
46 | collapsed: true,
47 | };
48 |
49 | componentDidMount = () => {
50 | this.props.getStatus();
51 | };
52 |
53 | toggle = () => {
54 | this.setState({
55 | collapsed: !this.state.collapsed,
56 | });
57 | };
58 |
59 | static getDerivedStateFromProps({ status }) {
60 | return { status };
61 | }
62 |
63 | logout = async () => {
64 | let { status, message } = await this.props.logout();
65 | if (status) {
66 | Message.success(message);
67 | this.setState({ status: 0 });
68 | } else {
69 | Message.error(message);
70 | }
71 | };
72 |
73 | shutdownServer = async () => {
74 | const res = await axios.get('/api/option/shutdown');
75 | const { message } = res.data;
76 | Message.error(message);
77 | };
78 |
79 | render() {
80 | const menu = (
81 |
82 | }>
83 | 账户管理
84 |
85 | }>
86 | 系统设置
87 |
88 |
89 | {this.state.status === 1 ? (
90 | }>
91 | {
94 | this.logout().then((r) => {});
95 | }}
96 | >
97 | 退出登录
98 |
99 |
100 | ) : (
101 | }>
102 | 用户登录
103 |
104 | )}
105 | }>
106 | this.shutdownServer()}>
107 | 关闭博客
108 |
109 |
110 |
111 | );
112 |
113 | return (
114 |
115 |
116 |
117 |
管理
118 |
119 |
120 | }>
121 | 页面管理
122 |
123 | }>
124 | 创建页面
125 |
126 | }>
127 | 文件管理
128 |
129 | }>
130 | 用户管理
131 |
132 | }>
133 | 系统设置
134 |
135 |
136 |
137 |
138 |
139 | {React.createElement(
140 | this.state.collapsed ? MenuUnfoldOutlined : MenuFoldOutlined,
141 | {
142 | className: 'trigger',
143 | onClick: this.toggle,
144 | }
145 | )}
146 |
147 |
148 | } size={'large'}>
149 | 管理员
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 | );
172 | }
173 | }
174 |
175 | const mapStateToProps = (state) => {
176 | return state;
177 | };
178 |
179 | export default connect(mapStateToProps, { logout, getStatus })(App);
180 |
--------------------------------------------------------------------------------
/admin/src/components/Comments.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { message as Message } from 'antd';
3 | import { connect } from 'react-redux';
4 |
5 | class Comments extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.state = {};
9 | }
10 |
11 | static getDerivedStateFromProps({ status }) {
12 | return { status };
13 | }
14 |
15 | async componentDidMount() {
16 | if (this.state.status === 0) {
17 | Message.error('访问被拒绝');
18 | this.props.history.push('/login');
19 | }
20 | }
21 |
22 | render() {
23 | return (
24 |
25 |
Comments
26 |
27 | );
28 | }
29 | }
30 |
31 | const mapStateToProps = (state) => {
32 | return state;
33 | };
34 |
35 | export default connect(mapStateToProps)(Comments);
36 |
--------------------------------------------------------------------------------
/admin/src/components/Dashboard.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | class Dashboard extends Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = {};
7 | }
8 |
9 | render() {
10 | return (
11 |
12 |
Dashboard
13 |
14 | );
15 | }
16 | }
17 |
18 | export default Dashboard;
19 |
--------------------------------------------------------------------------------
/admin/src/components/EditUser.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import axios from 'axios';
4 | import { connect } from 'react-redux';
5 | import { Button, message as Message, Input, Switch, Form } from 'antd';
6 |
7 | class EditUser extends Component {
8 | constructor(props) {
9 | super(props);
10 | const createNew = this.props.match.path === '/users/new';
11 | this.state = {
12 | loading: !createNew,
13 | isCreatingNewUser: createNew,
14 | user: {
15 | id: this.props.match.params.id,
16 | username: '',
17 | displayName: '',
18 | isAdmin: false,
19 | isModerator: false,
20 | isBlocked: false,
21 | email: '',
22 | url: '',
23 | avatar: '',
24 | password: '',
25 | },
26 | };
27 |
28 | this.formRef = React.createRef();
29 | }
30 |
31 | componentDidMount() {
32 | const that = this;
33 | if (!that.state.isCreatingNewUser) {
34 | axios
35 | .get('/api/user/' + that.state.user.id)
36 | .then(function (res) {
37 | if (res.data.status) {
38 | res.data.user.isAdmin = res.data.user.isAdmin === 1;
39 | res.data.user.isModerator = res.data.user.isModerator === 1;
40 | res.data.user.isBlocked = res.data.user.isBlocked === 1;
41 | console.log(res.data.user);
42 | that.setState(
43 | {
44 | user: res.data.user,
45 | },
46 | () => {
47 | that.formRef.current.resetFields();
48 | }
49 | );
50 | } else {
51 | Message.error(res.data.message);
52 | }
53 | })
54 | .catch(function (err) {
55 | Message.error(err.message);
56 | })
57 | .finally(() => {
58 | this.setState({ loading: false });
59 | });
60 | }
61 | }
62 |
63 | onValuesChange = (changedValues, allValues) => {
64 | let user = { ...this.state.user };
65 | for (let key in changedValues) {
66 | if (changedValues.hasOwnProperty(key)) {
67 | user[key] = changedValues[key];
68 | }
69 | }
70 | this.setState({ user });
71 | };
72 |
73 | submitData = async () => {
74 | const user = this.state.user;
75 | console.log(user);
76 | const res = this.state.isCreatingNewUser
77 | ? await axios.post(`/api/user`, user)
78 | : await axios.put(`/api/user`, user);
79 | const { status, message } = res.data;
80 | if (status) {
81 | Message.success('提交成功');
82 | this.props.history.goBack();
83 | } else {
84 | Message.error(message);
85 | }
86 | };
87 |
88 | render() {
89 | return (
90 |
91 |
编辑用户信息
92 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | 提交更改
129 |
130 |
131 |
132 | );
133 | }
134 | }
135 |
136 | const mapStateToProps = (state) => {
137 | return state;
138 | };
139 | export default connect(mapStateToProps)(EditUser);
140 |
--------------------------------------------------------------------------------
/admin/src/components/Editor.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Button, Input, InputNumber, Layout, message as Message, Popconfirm, Select, Space, Switch } from 'antd';
3 |
4 | import AceEditor from 'react-ace';
5 | import { connect } from 'react-redux';
6 | import 'ace-builds/src-noconflict/mode-markdown';
7 | import 'ace-builds/src-noconflict/snippets/markdown';
8 | import 'ace-builds/src-noconflict/mode-java';
9 | import 'ace-builds/src-noconflict/snippets/java';
10 | import 'ace-builds/src-noconflict/mode-python';
11 | import 'ace-builds/src-noconflict/snippets/python';
12 | import 'ace-builds/src-noconflict/mode-c_cpp';
13 | import 'ace-builds/src-noconflict/snippets/c_cpp';
14 | import 'ace-builds/src-noconflict/mode-javascript';
15 | import 'ace-builds/src-noconflict/snippets/javascript';
16 | import 'ace-builds/src-noconflict/mode-html';
17 | import 'ace-builds/src-noconflict/snippets/html';
18 | import 'ace-builds/src-noconflict/mode-sh';
19 | import 'ace-builds/src-noconflict/snippets/sh';
20 | import 'ace-builds/src-noconflict/mode-typescript';
21 | import 'ace-builds/src-noconflict/snippets/typescript';
22 | import 'ace-builds/src-noconflict/mode-css';
23 | import 'ace-builds/src-noconflict/snippets/css';
24 | import 'ace-builds/src-noconflict/mode-sql';
25 | import 'ace-builds/src-noconflict/snippets/sql';
26 | import 'ace-builds/src-noconflict/mode-golang';
27 | import 'ace-builds/src-noconflict/snippets/golang';
28 | import 'ace-builds/src-noconflict/mode-csharp';
29 | import 'ace-builds/src-noconflict/snippets/csharp';
30 |
31 | import axios from 'axios';
32 | import { getDate } from '../utils';
33 |
34 | const modes = [
35 | 'markdown',
36 | 'html',
37 | 'java',
38 | 'python',
39 | 'c_cpp',
40 | 'javascript',
41 | 'sh',
42 | 'typescript',
43 | 'css',
44 | 'sql',
45 | 'golang',
46 | 'csharp'
47 | ];
48 |
49 | const { Sider, Content } = Layout;
50 |
51 | let languages = [];
52 | modes.forEach((lang) => {
53 | languages.push({ key: lang, text: lang, value: lang });
54 | });
55 |
56 | const editorThemes = [
57 | 'monokai',
58 | 'github',
59 | 'tomorrow',
60 | 'kuroir',
61 | 'twilight',
62 | 'xcode',
63 | 'textmate',
64 | 'solarized_dark',
65 | 'solarized_light',
66 | 'terminal'
67 | ];
68 |
69 | editorThemes.forEach((theme) =>
70 | require(`ace-builds/src-noconflict/theme-${theme}`)
71 | );
72 |
73 | let themes = [];
74 | editorThemes.forEach((theme) => {
75 | themes.push({ key: theme, text: theme, value: theme });
76 | });
77 |
78 | export const PAGE_OPTIONS = [
79 | { key: 'grey', label: '全部', value: -1 },
80 | { key: 'volcano', label: '文章', value: 0 },
81 | { key: 'geekblue', label: '代码', value: 1 },
82 | { key: 'orange', label: '简报', value: 2 },
83 | { key: 'olive', label: '讨论', value: 3 },
84 | { key: 'green', label: '链接', value: 4 },
85 | { key: 'pink', label: 'HTML', value: 5 },
86 | { key: 'gold', label: '媒体', value: 6 },
87 | { key: 'violet', label: '时间线', value: 7 },
88 | { key: 'cyan', label: '重定向', value: 8 },
89 | { key: 'purple', label: '文本', value: 9 }
90 | ];
91 |
92 | const PAGE_TYPES = {
93 | ARTICLE: 0,
94 | CODE: 1,
95 | BULLETIN: 2,
96 | DISCUSS: 3,
97 | LINKS: 4,
98 | RAW: 5,
99 | MEDIA: 6,
100 | REDIRECT: 8,
101 | TEXT: 9
102 | };
103 |
104 | class Editor extends Component {
105 | constructor(props) {
106 | super(props);
107 | this.state = {
108 | loading: false,
109 | theme: 'solarized_light',
110 | language: 'markdown',
111 | pasteWithFormatting: false,
112 | fontSize: 18,
113 | // fontSize: this.loadEditorFontSize(),
114 | saveInterval: 60,
115 | isNewPage: this.props.match.path === '/editor',
116 | originPage: undefined,
117 | page: {
118 | id: this.props.match.params.id,
119 | type: PAGE_TYPES.ARTICLE,
120 | link: '',
121 | pageStatus: 1,
122 | commentStatus: 1,
123 | title: '',
124 | content: '---\ntitle: \ndescription: \ntags: \n- Others\n---\n',
125 | tag: '',
126 | password: '',
127 | description: ''
128 | },
129 | showDrawer: false
130 | };
131 | this.onChange = this.onChange.bind(this);
132 | // setInterval(this.onSubmit, this.state.saveInterval);
133 | window.showCloseHint = false;
134 | window.onbeforeunload = (e) => {
135 | if (window.showCloseHint) {
136 | return '检测到未保存的更改';
137 | }
138 | };
139 | }
140 |
141 | static getDerivedStateFromProps({ status }) {
142 | return { status };
143 | }
144 |
145 | async componentDidMount() {
146 | let editorContent = localStorage.getItem('editorContent');
147 | if (editorContent !== null && editorContent !== '') {
148 | if (this.state.isNewPage) {
149 | let page = { ...this.state.page };
150 | page.content = editorContent;
151 | this.setState({ page });
152 | } else {
153 | this.setState({ showRestoreConfirm: true });
154 | }
155 | }
156 | if (this.state.status === 0) {
157 | Message.error('访问被拒绝');
158 | this.props.history.push('/login');
159 | return;
160 | }
161 | this.setState({ originPage: this.state.page });
162 | this.loadEditorConfig();
163 | if (!this.state.isNewPage) {
164 | await this.fetchData();
165 | this.adjustEditorLanguage(this.state.page.type);
166 | }
167 |
168 | // TODO: Register event, not working
169 | // document.addEventListener('keydown', (e) => {
170 | // console.log('fuck ', document.isKeyDownEventRegistered);
171 | // if (!document.isKeyDownEventRegistered) {
172 | // document.isKeyDownEventRegistered = true;
173 | // if (e.ctrlKey && e.key === 's') {
174 | // e.preventDefault();
175 | // this.onSubmit();
176 | // }
177 | // }
178 | // });
179 |
180 | document.querySelector('.ace_editor').addEventListener('paste', this.onPaste, true);
181 | }
182 |
183 | fetchData = async () => {
184 | try {
185 | this.setState({ loading: true });
186 | const res = await axios.get(`/api/page/${this.props.match.params.id}`);
187 | const { status, message, page } = res.data;
188 | if (status) {
189 | this.setState({ originPage: page, page });
190 | } else {
191 | Message.error(message);
192 | }
193 | this.setState({ loading: false });
194 | } catch (e) {
195 | Message.error(e.message);
196 | }
197 | };
198 |
199 | loadEditorFontSize() {
200 | let fontSize = localStorage.getItem('fontSize');
201 | fontSize = parseInt(fontSize);
202 | if (!fontSize) {
203 | fontSize = 18;
204 | }
205 | return fontSize;
206 | }
207 |
208 | loadEditorConfig() {
209 | let theme = localStorage.getItem('theme');
210 | if (theme === null) {
211 | theme = this.state.theme;
212 | }
213 | let fontSize = this.loadEditorFontSize();
214 | let saveInterval = localStorage.getItem('saveInterval');
215 | if (saveInterval == null) {
216 | saveInterval = this.state.saveInterval;
217 | }
218 | this.setState({
219 | theme,
220 | fontSize: parseInt(fontSize),
221 | saveInterval: parseInt(saveInterval)
222 | });
223 | }
224 |
225 | saveEditorConfig() {
226 | localStorage.setItem('theme', this.state.theme);
227 | localStorage.setItem('fontSize', this.state.fontSize);
228 | localStorage.setItem('saveInterval', this.state.saveInterval);
229 | }
230 |
231 | onChange(newValue) {
232 | window.showCloseHint = true;
233 | let page = { ...this.state.page };
234 | page.content = newValue;
235 | localStorage.setItem('editorContent', page.content);
236 | let noUserInputContent = false;
237 | this.setState({ page, noUserInputContent });
238 | }
239 |
240 | onPaste = async (e) => {
241 | const { selection } = this.content.editor;
242 | const { row, column, document } = selection.anchor;
243 | const lines = document.$lines;
244 | let index = column;
245 | for (let i = 0; i < row; i += 1) {
246 | index += lines[i].length + 1;
247 | }
248 | const {files} = e.clipboardData;
249 | let isImage = false;
250 | if (files.length) {
251 | let formData = new FormData();
252 | if (files[0]['type'].split('/')[0] === 'image') {
253 | isImage = true;
254 | }
255 | formData.append("file", files[0]);
256 | formData.append("description", `编辑文章《${this.state.page.title}》时上传,时间为 ${getDate()}`)
257 | let res = await axios.post('/api/file', formData, {
258 | headers: { "Content-Type": "multipart/form-data" },
259 | });
260 | if (res.data.status) {
261 | const {file} = res.data;
262 | const { page } = this.state;
263 | const { content } = page;
264 | const uploadContent = `${isImage ? "!" : ""}[${file.filename}](${file.path})`;
265 | page.content = `${content.substring(0, index)}${uploadContent}${content.substring(index)}`;
266 | this.setState({ page }, () => {
267 | selection.moveCursorTo(row, lines[row].length + uploadContent.length);
268 | });
269 | }
270 | }
271 | };
272 |
273 | onEditorBlur = () => {
274 | let page = { ...this.state.page };
275 | let content = page.content;
276 | let title = '';
277 | let tag = '';
278 | let description = '';
279 | let hasGetTitle = false;
280 | let readyToGetTags = false;
281 | let lines = content.split('\n');
282 | for (let i = 0; i < lines.length; ++i) {
283 | let line = lines[i];
284 | if (line.startsWith('description')) {
285 | description = line.substring(12).trim();
286 | continue;
287 | }
288 | if (!hasGetTitle && line.startsWith('title')) {
289 | title = line.substring(6).trim();
290 | hasGetTitle = true;
291 | continue;
292 | }
293 | if (!hasGetTitle) continue;
294 | if (!line.startsWith('tag') && !readyToGetTags) continue;
295 | if (!readyToGetTags) {
296 | readyToGetTags = true;
297 | continue;
298 | }
299 |
300 | if (line.startsWith('-') && !line.trim().endsWith('-')) {
301 | tag += `;${line.trim().substring(1).trim()}`;
302 | } else {
303 | break;
304 | }
305 | }
306 | page.tag = tag.trim().slice(1);
307 | page.title = title.trim();
308 | page.description = description;
309 | if (page.link === '') page.link = this.getValidLink(page.title);
310 | this.setState({ page });
311 | };
312 |
313 | onInputChange = (e) => {
314 | const { name, value } = e.target;
315 | let page = { ...this.state.page };
316 | if (name === 'link') {
317 | page[name] = this.getValidLink(value);
318 | } else {
319 | page[name] = value;
320 | }
321 | this.setState({ page });
322 | };
323 |
324 | onThemeChange = (e, { value }) => {
325 | this.setState({ theme: value }, () => {
326 | this.saveEditorConfig();
327 | });
328 | };
329 |
330 | onFontSizeChange = (value) => {
331 | this.setState({ fontSize: value }, () => {
332 | this.saveEditorConfig();
333 | });
334 | };
335 |
336 | onTypeChange = (e, { value }) => {
337 | let page = { ...this.state.page };
338 | page.type = value;
339 | this.setState({ page }, () => {
340 | this.adjustEditorLanguage(value);
341 | });
342 | };
343 |
344 | adjustEditorLanguage(pageType) {
345 | let language = this.state.language;
346 | switch (pageType) {
347 | case PAGE_TYPES.ARTICLE:
348 | language = 'markdown';
349 | break;
350 | case PAGE_TYPES.RAW:
351 | language = 'html';
352 | break;
353 | default:
354 | break;
355 | }
356 | this.languageChangeHelper(language);
357 | }
358 |
359 | onLanguageChange = (e, { value }) => {
360 | this.languageChangeHelper(value);
361 | };
362 |
363 | languageChangeHelper = (value) => {
364 | this.setState({ language: value });
365 | if (this.state.noUserInputContent && this.state.isNewPage) {
366 | let page = { ...this.state.page };
367 | let content = '---\ntitle: \ndescription: \ntags: \n- Others\n---\n';
368 | if (
369 | [
370 | 'java',
371 | 'c_cpp',
372 | 'javascript',
373 | 'typescript',
374 | 'css',
375 | 'csharp',
376 | 'golang',
377 | 'sql'
378 | ].includes(value)
379 | ) {
380 | content = '/*\ntitle: \ndescription: \ntags: \n- Others\n*/\n';
381 | } else if (['html', 'ejs'].includes(value)) {
382 | content = '\n';
383 | } else if (['python'].includes(value)) {
384 | content = '"""\ntitle: \ndescription: \ntags: \n- Others\n"""\n';
385 | } else if (['ruby'].includes(value)) {
386 | content = '=begin\ntitle: \ndescription: \ntags: \n- Others\n=end\n';
387 | }
388 | page.content = content;
389 | this.setState({ page });
390 | }
391 | };
392 |
393 | onCommentStatusChange = () => {
394 | let page = { ...this.state.page };
395 | page.commentStatus = page.commentStatus === 0 ? 1 : 0;
396 | this.setState({ page });
397 | };
398 |
399 | onPublishStatusChange = () => {
400 | let page = { ...this.state.page };
401 | page.pageStatus = page.pageStatus === 0 ? 1 : 0;
402 | this.setState({ page });
403 | };
404 |
405 | onStayOnTopStatusChange = () => {
406 | let page = { ...this.state.page };
407 | page.pageStatus = page.pageStatus === 2 ? 1 : 2;
408 | this.setState({ page });
409 | };
410 |
411 | onHiddenStatusChange = () => {
412 | let page = { ...this.state.page };
413 | page.pageStatus = page.pageStatus === 3 ? 1 : 3;
414 | this.setState({ page });
415 | };
416 |
417 | onPasteWithFormattingChange = () => {
418 | let pasteWithFormatting = !this.state.pasteWithFormatting;
419 | this.setState({ pasteWithFormatting });
420 | if (this.state.noUserInputContent) {
421 | let page = { ...this.state.page };
422 | page.type = PAGE_TYPES.RAW;
423 | this.setState({ page }, () => {
424 | this.adjustEditorLanguage(page.type);
425 | });
426 | }
427 | };
428 |
429 | onSubmit = (e) => {
430 | window.showCloseHint = false;
431 | this.onEditorBlur();
432 | if (this.state.page.link === '') {
433 | let page = { ...this.state.page };
434 | page.link = this.getValidLink();
435 | if (!page.title) {
436 | page.title = '无标题';
437 | }
438 | this.setState({ page }, () => {
439 | this.state.isNewPage ? this.postNewPage() : this.updatePage();
440 | });
441 | } else {
442 | this.state.isNewPage ? this.postNewPage() : this.updatePage();
443 | }
444 | };
445 |
446 | getDate(format) {
447 | if (format === undefined) format = 'yyyy-MM-dd';
448 | const date = new Date();
449 | const o = {
450 | 'M+': date.getMonth() + 1,
451 | 'd+': date.getDate(),
452 | 'h+': date.getHours(),
453 | 'm+': date.getMinutes(),
454 | 's+': date.getSeconds(),
455 | S: date.getMilliseconds()
456 | };
457 |
458 | if (/(y+)/.test(format)) {
459 | format = format.replace(
460 | RegExp.$1,
461 | (date.getFullYear() + '').substr(4 - RegExp.$1.length)
462 | );
463 | }
464 |
465 | for (let k in o) {
466 | if (new RegExp('(' + k + ')').test(format)) {
467 | format = format.replace(
468 | RegExp.$1,
469 | RegExp.$1.length === 1
470 | ? o[k]
471 | : ('00' + o[k]).substr(('' + o[k]).length)
472 | );
473 | }
474 | }
475 | return format;
476 | }
477 |
478 | getValidLink(origin) {
479 | if (origin === undefined) {
480 | origin = this.getDate();
481 | }
482 | return origin
483 | .trim()
484 | .toLowerCase()
485 | .replace(/[\s#%+/&=?`]+/g, '-');
486 | }
487 |
488 | reset = (e) => {
489 | let page = this.state.originPage;
490 | this.setState({ page });
491 | };
492 |
493 | deletePage = () => {
494 | this.setState({ deleteConfirm: false });
495 | const that = this;
496 | let id = this.props.match.params.id;
497 | if (!id) {
498 | id = this.state.page.id;
499 | }
500 | axios.delete(`/api/page/${id}`).then(async function(res) {
501 | const { status, message } = res.data;
502 | if (status) {
503 | Message.success('页面删除成功');
504 | that.props.history.push('/editor');
505 | } else {
506 | Message.error(message);
507 | }
508 | });
509 | };
510 |
511 | cancelDelete = () => {
512 | this.setState({ deleteConfirm: false });
513 | };
514 |
515 | clearEditorStorage() {
516 | localStorage.setItem('editorContent', '');
517 | }
518 |
519 | updatePage() {
520 | const that = this;
521 | axios.put('/api/page', this.state.page).then(async function(res) {
522 | const { status, message } = res.data;
523 | if (status) {
524 | Message.success('页面更新成功');
525 | that.clearEditorStorage();
526 | } else {
527 | Message.error(message);
528 | }
529 | });
530 | }
531 |
532 | postNewPage() {
533 | const that = this;
534 | axios.post('/api/page', this.state.page).then(async function(res) {
535 | const { status, message, id } = res.data;
536 | if (status) {
537 | let page = { ...that.state.page };
538 | page.id = id;
539 | that.setState({ isNewPage: false, page });
540 | Message.success('页面创建成功');
541 | that.clearEditorStorage();
542 | } else {
543 | Message.error(message);
544 | }
545 | });
546 | }
547 |
548 | renderEditor() {
549 | return (
550 | <>
551 | {
553 | this.content = content;
554 | }}
555 | style={{ width: '100%', minHeight: '100%' }}
556 | mode={this.state.language}
557 | theme={this.state.theme}
558 | name={'editor'}
559 | onChange={this.onChange}
560 | value={this.state.page.content}
561 | fontSize={this.state.fontSize}
562 | onBlur={this.onEditorBlur}
563 | setOptions={{ useWorker: false }}
564 | maxLines={Infinity}
565 | />
566 | >
567 | );
568 | }
569 |
570 | renderPanel() {
571 | return (
572 |
711 | );
712 | }
713 |
714 | render() {
715 | return (
716 |
723 |
730 | {this.renderPanel()}
731 |
732 |
733 | {this.renderEditor()}
734 |
735 |
736 | );
737 | }
738 | }
739 |
740 | const mapStateToProps = (state) => {
741 | return state;
742 | };
743 |
744 | export default connect(mapStateToProps)(Editor);
745 |
--------------------------------------------------------------------------------
/admin/src/components/Files.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import { connect } from 'react-redux';
4 | import axios from 'axios';
5 |
6 | import {
7 | message as Message,
8 | Button,
9 | Divider,
10 | Upload,
11 | Table,
12 | Space,
13 | Popconfirm,
14 | Input,
15 | } from 'antd';
16 |
17 | import { InboxOutlined } from '@ant-design/icons';
18 |
19 | const { Dragger } = Upload;
20 | const { Search } = Input;
21 |
22 | class Files extends Component {
23 | constructor(props) {
24 | super(props);
25 | this.state = {
26 | loading: true,
27 | uploading: true,
28 | files: [],
29 | message: {
30 | visible: false,
31 | color: 'red',
32 | header: '',
33 | content: '',
34 | },
35 | searchTypingTimeout: 0,
36 | };
37 | const protocol = window.location.href.split('/')[0];
38 | const domain = window.location.href.split('/')[2];
39 | this.filePrefix = `${protocol}//${domain}`;
40 | this.columns = [
41 | {
42 | title: '文件名',
43 | dataIndex: 'filename',
44 | render: (value, record) => (
45 |
46 | {value}
47 |
48 | ),
49 | },
50 | {
51 | title: '描述',
52 | dataIndex: 'description',
53 | render: (value) => {value ? value : "无描述信息"}
,
54 | },
55 | {
56 | title: '操作',
57 | render: (record) => (
58 |
59 | this.copyFilePath(record.path)}>
60 | 复制路径
61 |
62 | this.deleteFile(record.id)}
66 | okText="确认"
67 | cancelText="取消"
68 | >
69 |
70 | 删除文件
71 |
72 |
73 |
74 | ),
75 | },
76 | ];
77 | }
78 |
79 | static getDerivedStateFromProps({ status }) {
80 | return { status };
81 | }
82 |
83 | async componentDidMount() {
84 | if (this.state.status === 0) {
85 | Message.error('访问被拒绝');
86 | this.props.history.push('/login');
87 | return;
88 | }
89 | await this.fetchData();
90 | }
91 |
92 | fetchData = async () => {
93 | try {
94 | this.setState({ loading: true });
95 | const res = await axios.get(`/api/file/`);
96 | const { status, message, files } = res.data;
97 | if (status) {
98 | this.setState({ files });
99 | } else {
100 | Message.error(message);
101 | }
102 | this.setState({ loading: false });
103 | } catch (e) {
104 | Message.error(e.message);
105 | }
106 | };
107 |
108 | onInputChange = (e) => {
109 | this.setState({ keyword: e.target.value });
110 | if (this.state.searchTypingTimeout) {
111 | clearTimeout(this.state.searchTypingTimeout);
112 | }
113 | this.setState({
114 | searchTypingTimeout: setTimeout(() => {
115 | this.search();
116 | }, 500),
117 | });
118 | };
119 |
120 | search = () => {
121 | this.setState({ loading: true });
122 | axios
123 | .post('/api/file/search', {
124 | keyword: this.state.keyword,
125 | })
126 | .then(async (res) => {
127 | this.setState({ loading: false });
128 | this.setState({ files: res.data.files });
129 | })
130 | .catch((err) => {
131 | console.error(err);
132 | this.setState({ loading: false });
133 | Message.error(err.message);
134 | });
135 | };
136 |
137 | downloadFile = (id) => {
138 | window.location = `//${window.location.href.split('/')[2]}/upload/${id}`;
139 | };
140 |
141 | copyFilePath = (path) => {
142 | let fullPath = window.location.href.split('/').slice(0, 3).join('/') + path;
143 | navigator.clipboard
144 | .writeText(fullPath)
145 | .then((r) => {
146 | Message.success('已复制:' + fullPath);
147 | })
148 | .catch((e) => {
149 | Message.error(e.message);
150 | });
151 | };
152 |
153 | deleteFile = (id) => {
154 | axios
155 | .delete(`/api/file/${id}`)
156 | .then((res) => {
157 | if (res.data.status) {
158 | Message.success('文件删除成功');
159 | this.fetchData().then((r) => {});
160 | } else {
161 | Message.error('文件删除失败:', res.data.message);
162 | }
163 | })
164 | .catch((err) => {
165 | console.error(err);
166 | Message.error(err.message);
167 | });
168 | };
169 |
170 | render() {
171 | const { loading } = this.state;
172 | const that = this;
173 | const props = {
174 | name: 'file',
175 | multiple: true,
176 | action: '/api/file/',
177 | onChange(info) {
178 | const { status } = info.file;
179 | if (status === 'done') {
180 | Message.success(`文件上传成功:${info.file.name}`);
181 | that.fetchData().then((r) => {});
182 | } else if (status === 'error') {
183 | Message.error(`文件上传失败:${info.file.name}`);
184 | }
185 | },
186 | };
187 | return (
188 |
189 |
文件管理
190 |
191 |
192 |
193 |
194 |
195 |
196 | 点击此处上传或者拖拽文件上传
197 |
198 |
199 |
200 |
201 |
208 |
216 |
217 | );
218 | }
219 | }
220 |
221 | const mapStateToProps = (state) => {
222 | return state;
223 | };
224 |
225 | export default connect(mapStateToProps)(Files);
226 |
--------------------------------------------------------------------------------
/admin/src/components/Login.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { login, getStatus } from '../actions';
4 |
5 | import { Form, Input, Button, Checkbox, Space, message as Message } from 'antd';
6 |
7 | class Login extends Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = { username: '', password: '', remember: true };
11 | }
12 |
13 | componentDidMount() {
14 | if (this.props.isLogin) {
15 | this.props.history.push('/');
16 | }
17 | }
18 |
19 | onValuesChange = (changedValues, allValues) => {
20 | for (let key in changedValues) {
21 | if (changedValues.hasOwnProperty(key)) {
22 | this.setState({ [key]: changedValues[key] });
23 | }
24 | }
25 | };
26 |
27 | onSubmit = async () => {
28 | try {
29 | let { status, message } = await this.props.login(
30 | this.state.username,
31 | this.state.password
32 | );
33 | if (status) {
34 | this.props.getStatus();
35 | Message.success('登录成功');
36 | this.props.history.push('/');
37 | } else {
38 | Message.error(message);
39 | }
40 | } catch (e) {
41 | Message.error(e.message);
42 | }
43 | };
44 |
45 | render() {
46 | const layout = {
47 | labelCol: { span: 10 },
48 | wrapperCol: { span: 4 },
49 | };
50 | const tailLayout = {
51 | wrapperCol: { offset: 10, span: 16 },
52 | };
53 | return (
54 |
55 | {' '}
56 |
72 |
73 |
74 |
75 |
85 |
86 |
87 |
88 | {/**/}
89 | {/* Remember me */}
90 | {/* */}
91 |
92 |
93 |
94 |
95 | 提交
96 |
97 | 重置
98 |
99 |
100 |
101 |
102 | );
103 | }
104 | }
105 |
106 | const mapStateToProps = (state) => {
107 | return state;
108 | };
109 |
110 | export default connect(mapStateToProps, { login, getStatus })(Login);
111 |
--------------------------------------------------------------------------------
/admin/src/components/Posts.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import {
3 | Table,
4 | Tag,
5 | Button,
6 | message as Message,
7 | Tooltip,
8 | Space,
9 | Popconfirm,
10 | Input,
11 | Select,
12 | Row,
13 | Col,
14 | } from 'antd';
15 | import { CheckCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
16 | import { connect } from 'react-redux';
17 | import axios from 'axios';
18 |
19 | import { PAGE_OPTIONS } from './Editor';
20 | import { getDate } from '../utils';
21 |
22 | const { Search } = Input;
23 |
24 | class Posts extends Component {
25 | constructor(props) {
26 | super(props);
27 | this.state = {
28 | pages: [],
29 | loading: false,
30 | searchTypingTimeout: 0,
31 | searchOption: -1,
32 | keyword: '',
33 | activeItem: '',
34 | direction: 'up',
35 | status: 0,
36 | };
37 | this.columns = [
38 | {
39 | title: '标题',
40 | dataIndex: 'title',
41 | sorter: (a, b) => a.title > b.title,
42 | sortDirections: ['descend', 'ascend'],
43 | render: (value, record) => (
44 |
47 |
48 | {value ? value : '无标题'}
49 |
50 |
51 | ),
52 | },
53 | {
54 | title: '类型',
55 | dataIndex: 'type',
56 | render: (value) => (
57 |
58 | {PAGE_OPTIONS[value + 1].label}
59 |
60 | ),
61 | },
62 | {
63 | title: '阅读量',
64 | dataIndex: 'view',
65 | sorter: (a, b) => parseInt(a.view) > parseInt(b.view),
66 | sortDirections: ['descend', 'ascend'],
67 | render: (value) => {value} ,
68 | },
69 | {
70 | title: '标签',
71 | dataIndex: 'tag',
72 | render: (tags) => (
73 | <>
74 | {tags.split(';').map((tag) => {
75 | return (
76 |
77 | {tag}
78 |
79 | );
80 | })}
81 | >
82 | ),
83 | },
84 | {
85 | title: '状态',
86 | dataIndex: 'pageStatus',
87 | sorter: (a, b) => parseInt(a.pageStatus) > parseInt(b.pageStatus),
88 | sortDirections: ['descend', 'ascend'],
89 | render: (value, record) => {
90 | if (value === 0) {
91 | return (
92 | }
94 | color="default"
95 | onClick={() => this.switchStatus(record.id, 'pageStatus')}
96 | style={{cursor: 'pointer'}}
97 | >
98 | 已撤回
99 |
100 | );
101 | } else if (value === 1) {
102 | return (
103 | }
105 | color="success"
106 | onClick={() => this.switchStatus(record.id, 'pageStatus')}
107 | style={{cursor: 'pointer'}}
108 | >
109 | 已发布
110 |
111 | );
112 | } else if (value === 2) {
113 | return (
114 | }
116 | color="orange"
117 | onClick={() => this.switchStatus(record.id, 'pageStatus')}
118 | style={{cursor: 'pointer'}}
119 | >
120 | 已置顶
121 |
122 | );
123 | } else {
124 | return (
125 | }
127 | color="default"
128 | onClick={() => this.switchStatus(record.id, 'pageStatus')}
129 | style={{cursor: 'pointer'}}
130 | >
131 | 已隐藏
132 |
133 | );
134 | }
135 | },
136 | },
137 | {
138 | title: '评论',
139 | dataIndex: 'commentStatus',
140 | sorter: (a, b) => parseInt(a.commentStatus) > parseInt(b.commentStatus),
141 | sortDirections: ['descend', 'ascend'],
142 | render: (value, record) =>
143 | value === 1 ? (
144 | }
146 | color="success"
147 | onClick={() => this.switchStatus(record.id, 'commentStatus')}
148 | style={{cursor: 'pointer'}}
149 | >
150 | 已开启
151 |
152 | ) : (
153 | }
155 | color="default"
156 | onClick={() => this.switchStatus(record.id, 'commentStatus')}
157 | style={{cursor: 'pointer'}}
158 | >
159 | 已关闭
160 |
161 | ),
162 | },
163 | {
164 | title: '操作',
165 | render: (record) => (
166 |
167 | this.editPage(record.id)}>
168 | 编辑
169 |
170 | this.exportPage(record.id)}>导出
171 | this.deletePage(record.id)}
175 | okText="确认"
176 | cancelText="取消"
177 | >
178 |
179 | 删除
180 |
181 |
182 |
183 | ),
184 | },
185 | ];
186 | }
187 |
188 | static getDerivedStateFromProps({ status }) {
189 | return { status };
190 | }
191 |
192 | async componentDidMount() {
193 | if (this.state.status === 0) {
194 | Message.error('访问被拒绝');
195 | this.props.history.push('/login');
196 | return;
197 | }
198 | await this.loadPagesFromServer();
199 | }
200 |
201 | onSearchOptionChange = (e, { value }) => {
202 | this.setState({ searchOption: value }, () => {
203 | this.search();
204 | });
205 | };
206 |
207 | searchPages = (e) => {
208 | this.setState({ keyword: e.target.value });
209 | if (this.state.searchTypingTimeout) {
210 | clearTimeout(this.state.searchTypingTimeout);
211 | }
212 | this.setState({
213 | searchTypingTimeout: setTimeout(() => {
214 | this.search();
215 | }, 500),
216 | });
217 | };
218 |
219 | search = () => {
220 | const that = this;
221 | axios
222 | .post(`/api/page/search`, {
223 | // TODO
224 | type: this.state.searchOption,
225 | keyword: this.state.keyword,
226 | })
227 | .then(async function (res) {
228 | const { status, message, pages } = res.data;
229 | if (status) {
230 | that.setState({ pages });
231 | } else {
232 | Message.error(message);
233 | }
234 | })
235 | .catch(function (err) {
236 | console.error(err);
237 | });
238 | };
239 |
240 | async loadPagesFromServer() {
241 | try {
242 | this.setState({ loading: true });
243 | const res = await axios.get('/api/page');
244 | let { status, message, pages } = res.data;
245 | if (status) {
246 | pages.forEach((page) => {
247 | page.createdAt = getDate(page.createdAt);
248 | page.updatedAt = getDate(page.updatedAt);
249 | });
250 | this.setState({ pages });
251 | Message.success('数据加载完毕');
252 | } else {
253 | Message.error(message);
254 | }
255 | this.setState({ loading: false });
256 | } catch (e) {
257 | Message.error(e.message);
258 | }
259 | }
260 |
261 | editPage = (id) => {
262 | this.props.history.push(`/editor/${id}`);
263 | };
264 |
265 | viewPage = (link) => {
266 | window.location = `//${window.location.href.split('/')[2]}/page/${link}`;
267 | };
268 |
269 | exportPage = (id) => {
270 | window.location = `//${
271 | window.location.href.split('/')[2]
272 | }/api/page/export/${id}`;
273 | };
274 |
275 | getPageById = (id) => {
276 | for (let i = 0; i < this.state.pages.length; i++) {
277 | if (this.state.pages[i].id === id) {
278 | return [i, this.state.pages[i]];
279 | }
280 | }
281 | return [-1, undefined];
282 | };
283 |
284 | updateTargetPage = (index, page) => {
285 | if (index === -1) {
286 | return;
287 | }
288 | // https://stackoverflow.com/a/71530834
289 | let pages = [...this.state.pages];
290 | pages[index] = { ...page };
291 | this.setState({ pages }, ()=>{
292 | console.log(this.state.pages[index])
293 | });
294 | };
295 |
296 | deletePage = (id) => {
297 | const that = this;
298 | axios.delete(`/api/page/${id}`).then(async function (res) {
299 | const { status, message } = res.data;
300 | if (status) {
301 | let [i, page] = that.getPageById(id);
302 | if (i !== -1) {
303 | page.deleted = true;
304 | that.updateTargetPage(i, page);
305 | Message.success('删除成功');
306 | } else {
307 | Message.error('删除失败');
308 | console.error(id, i, page, that.state.pages);
309 | }
310 | } else {
311 | Message.error(message);
312 | }
313 | });
314 | };
315 |
316 | switchStatus = (id, key) => {
317 | const that = this;
318 | let pages = this.state.pages;
319 | let page = pages.find((x) => x.id === id);
320 | let base = 2;
321 | if (key === 'pageStatus') {
322 | base = 4;
323 | }
324 | page[key] = (page[key] + 1) % base;
325 | page.id = id;
326 | axios
327 | .put('/api/page/', page)
328 | .then(async function (res) {
329 | if (res.data.status) {
330 | let i = that.getPageById(id)[0];
331 | that.updateTargetPage(i, page);
332 | Message.success('更新成功');
333 | } else {
334 | Message.error(res.data.message);
335 | }
336 | })
337 | .catch(function (e) {
338 | Message.error(e.message);
339 | });
340 | };
341 |
342 | render() {
343 | return (
344 |
345 |
页面管理
346 |
347 |
348 |
355 |
356 |
357 |
364 |
365 |
366 |
367 |
record.deleted && 'disabled-row'}
374 | />
375 |
376 | );
377 | }
378 | }
379 |
380 | const mapStateToProps = (state) => {
381 | return state;
382 | };
383 |
384 | export default connect(mapStateToProps)(Posts);
385 |
--------------------------------------------------------------------------------
/admin/src/components/Settings.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import { connect } from 'react-redux';
4 |
5 | import axios from 'axios';
6 | import { message as Message, Button, Tabs, Form, Input } from 'antd';
7 |
8 | const { TabPane } = Tabs;
9 |
10 | const tabs = [
11 | {
12 | label: '通用设置',
13 | settings: [
14 | {
15 | key: 'domain',
16 | description: '请输入你的域名,例如:www.domain.com',
17 | },
18 | {
19 | key: 'language',
20 | description: '语言',
21 | },
22 | {
23 | key: 'copyright',
24 | description: '请输入 HTML 代码,其将被放置在页面的末尾',
25 | },
26 | {
27 | key: 'allow_comments',
28 | description: 'true 或者 false',
29 | },
30 | {
31 | key: 'use_cache',
32 | description: 'true 或者 false',
33 | },
34 | ],
35 | },
36 | {
37 | label: '自定义设置',
38 | settings: [
39 | {
40 | key: 'theme',
41 | description:
42 | "博客主题,可选值:bulma, bootstrap, bootstrap5, v2ex, next 以及 w3",
43 | },
44 | {
45 | key: 'code_theme',
46 | description:
47 | '从这里选择一个代码主题:https://www.jsdelivr.com/package/npm/highlight.js?path=styles',
48 | },
49 | {
50 | key: 'site_name',
51 | description: "网站名称",
52 | },
53 | {
54 | key: 'description',
55 | description: '网站描述信息',
56 | },
57 | {
58 | key: 'nav_links',
59 | description: '必须是合法的 JSON 格式的文本',
60 | isBlock: true,
61 | },
62 | {
63 | key: 'author',
64 | description: '你的名字',
65 | },
66 | {
67 | key: 'motto',
68 | description: '你的格言',
69 | },
70 | {
71 | key: 'favicon',
72 | description: '请输入一个图片链接',
73 | },
74 | {
75 | key: 'brand_image',
76 | description: '请输入一个图片链接',
77 | },
78 | {
79 | key: 'index_page_content',
80 | description: '自定义首页 HTML 代码,输入 404 则对外隐藏首页',
81 | isBlock: true,
82 | }
83 | ],
84 | },
85 | {
86 | label: '其他设置',
87 | settings: [
88 | {
89 | key: 'ad',
90 | description: '广告代码',
91 | isBlock: true,
92 | },
93 | {
94 | key: 'extra_header_code',
95 | description: '此处代码会被插入到 header 标签内,可在此处放入统计代码',
96 | isBlock: true,
97 | },
98 | {
99 | key: 'extra_footer_code',
100 | description: '此处代码会被插入到 footer 标签内',
101 | },
102 | {
103 | key: 'disqus',
104 | description: 'Disqus 标识符,未输入则无法启用评论',
105 | },
106 | {
107 | key: 'extra_footer_text',
108 | description: '自定义页脚信息,支持 HTML,可在此放入备案信息等',
109 | },
110 | {
111 | key: 'message_push_api',
112 | description:
113 | '消息推送 API 链接,具体参见:https://github.com/songquanpeng/message-pusher',
114 | },
115 | ],
116 | },
117 | ];
118 |
119 | class Settings extends Component {
120 | constructor(props) {
121 | super(props);
122 | this.state = {
123 | loading: true,
124 | submitLoading: false,
125 | language: 'javascript',
126 | options: {},
127 | optionIndex: 0,
128 | };
129 | }
130 |
131 | static getDerivedStateFromProps({ status }) {
132 | return { status };
133 | }
134 |
135 | async componentDidMount() {
136 | if (this.state.status === 0) {
137 | Message.error('访问被拒绝');
138 | this.props.history.push('/login');
139 | return;
140 | }
141 | await this.fetchData();
142 | }
143 |
144 | fetchData = async () => {
145 | try {
146 | this.setState({ loading: true });
147 | const res = await axios.get(`/api/option/`);
148 | let { status, message, options } = res.data;
149 | let temp = {};
150 | if (status) {
151 | options.forEach((option) => {
152 | temp[option.key] = option.value;
153 | });
154 | options = temp;
155 | console.log(options);
156 | this.setState({ options });
157 | } else {
158 | Message.error(message);
159 | }
160 | this.setState({ loading: false });
161 | } catch (e) {
162 | Message.error(e.message);
163 | }
164 | };
165 |
166 | updateOption = (key, value) => {
167 | let options = this.state.options;
168 | options[key] = value;
169 | this.setState({ options });
170 | };
171 |
172 | submit = async () => {
173 | let options = this.state.options;
174 | try {
175 | const res = await axios.put(`/api/option/`, options);
176 | const { status, message } = res.data;
177 | if (status) {
178 | Message.success('设置更新成功');
179 | } else {
180 | Message.error(message);
181 | }
182 | this.setState({ loading: false });
183 | } catch (e) {
184 | Message.error(e.message);
185 | } finally {
186 | this.setState({ submitLoading: false });
187 | }
188 | };
189 |
190 | render() {
191 | return (
192 |
193 |
系统设置
194 |
195 |
196 | {tabs.map((tab) => {
197 | tab.settings.sort((a, b) => {
198 | if (a.key < b.key) {
199 | return -1;
200 | }
201 | if (a.key > b.key) {
202 | return 1;
203 | }
204 | return 0;
205 | });
206 | return (
207 |
208 |
217 | {setting.isBlock ? (
218 | {
222 | this.updateOption(setting.key, e.target.value);
223 | }}
224 | rows={10}
225 | />
226 | ) : (
227 | {
231 | this.updateOption(setting.key, e.target.value);
232 | }}
233 | />
234 | )}
235 |
236 | );
237 | })}
238 | this.submit()}>
239 | 保存设置
240 |
241 |
242 |
243 | );
244 | })}
245 |
246 |
247 |
248 | );
249 | }
250 | }
251 |
252 | const mapStateToProps = (state) => {
253 | return state;
254 | };
255 |
256 | export default connect(mapStateToProps)(Settings);
257 |
--------------------------------------------------------------------------------
/admin/src/components/Users.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import { connect } from 'react-redux';
4 |
5 | import axios from 'axios';
6 | import {
7 | Table,
8 | Tag,
9 | Button,
10 | message as Message,
11 | Tooltip,
12 | Space,
13 | Popconfirm,
14 | } from 'antd';
15 |
16 | class Users extends Component {
17 | constructor(props) {
18 | super(props);
19 | this.state = {
20 | userId: -1,
21 | users: [],
22 | user: {},
23 | loading: false,
24 | loadingUserStatus: true,
25 | };
26 | this.columns = [
27 | {
28 | title: '用户名',
29 | dataIndex: 'username',
30 | render: (value, record) => (
31 |
32 | {value}
33 |
34 | ),
35 | },
36 | {
37 | title: '邮箱',
38 | dataIndex: 'email',
39 | render: (value) => <>{value ? value : '无'}>,
40 | },
41 | {
42 | title: '是否是超级管理员',
43 | dataIndex: 'isAdmin',
44 | render: (value) => (
45 | {value ? '是' : '否'}
46 | ),
47 | },
48 | {
49 | title: '是否是普通管理员',
50 | dataIndex: 'isModerator',
51 | render: (value) => (
52 | {value ? '是' : '否'}
53 | ),
54 | },
55 | {
56 | title: '状态',
57 | dataIndex: 'isBlocked',
58 | render: (value) => (
59 |
60 | {value ? '被封禁' : '正常'}
61 |
62 | ),
63 | },
64 | {
65 | title: '操作',
66 | render: (record) => (
67 |
68 | this.editUser(record.id)}>编辑
69 | this.deleteUser(record.id)}
73 | okText="确认"
74 | cancelText="取消"
75 | >
76 |
77 | 删除
78 |
79 |
80 |
81 | ),
82 | },
83 | ];
84 | }
85 |
86 | static getDerivedStateFromProps({ status }) {
87 | return { status };
88 | }
89 |
90 | async componentDidMount() {
91 | if (this.state.status === 0) {
92 | Message.error('访问被拒绝');
93 | this.props.history.push('/login');
94 | return;
95 | }
96 | await this.fetchData();
97 | await this.fetchUserStatus();
98 | }
99 |
100 | fetchUserStatus = async () => {
101 | try {
102 | const res = await axios.get(`/api/user/status`);
103 | const { status, message, user } = res.data;
104 | if (status) {
105 | this.setState({ user });
106 | } else {
107 | Message.config(message);
108 | }
109 | this.setState({ loadingUserStatus: false });
110 | } catch (e) {
111 | Message.error(e.message);
112 | }
113 | };
114 |
115 | fetchData = async () => {
116 | try {
117 | this.setState({ loading: true });
118 | const res = await axios.get(`/api/user`);
119 | const { status, message, users } = res.data;
120 | if (status) {
121 | this.setState({ users });
122 | } else {
123 | Message.error(message);
124 | }
125 | this.setState({ loading: false });
126 | } catch (e) {
127 | Message.error(e.message);
128 | }
129 | };
130 |
131 | addUser = () => {
132 | this.props.history.push('/users/new');
133 | };
134 |
135 | refreshToken = async ()=>{
136 | try {
137 | const res = await axios.post(`/api/user/refresh_token`);
138 | const { status, message, accessToken } = res.data;
139 | if (status) {
140 | await navigator.clipboard.writeText(accessToken);
141 | Message.success('Access Token 刷新成功,已复制到剪切板');
142 | } else {
143 | Message.error(message);
144 | }
145 | } catch (e) {
146 | Message.error(e.message);
147 | }
148 | }
149 |
150 | deleteUser = (id) => {
151 | const that = this;
152 | axios.delete(`/api/user/${id}`).then(async function (res) {
153 | const { status, message } = res.data;
154 | if (status) {
155 | Message.success('用户删除成功');
156 | await that.fetchData();
157 | } else {
158 | Message.error(message);
159 | }
160 | });
161 | };
162 |
163 | editUser = (id) => {
164 | this.props.history.push(`/users/${id}`);
165 | };
166 |
167 | render() {
168 | return (
169 |
170 |
用户管理
171 |
178 |
{
180 | this.addUser();
181 | }}
182 | >
183 | 创建新用户账户
184 |
185 |
{
187 | this.refreshToken().then();
188 | }}
189 | style={{ marginLeft: '16px' }}
190 | >
191 | 刷新 Access Token
192 |
193 |
194 | );
195 | }
196 | }
197 |
198 | const mapStateToProps = (state) => {
199 | return state;
200 | };
201 |
202 | export default connect(mapStateToProps)(Users);
203 |
--------------------------------------------------------------------------------
/admin/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | #root {
11 | height: 100%;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
16 | monospace;
17 | }
18 |
--------------------------------------------------------------------------------
/admin/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './components/App';
5 | import { HashRouter } from 'react-router-dom';
6 | import { createStore, applyMiddleware } from 'redux';
7 | import reduxThunk from 'redux-thunk';
8 | import { Provider } from 'react-redux';
9 | import reducers from './reducers';
10 |
11 | const store = createStore(reducers, applyMiddleware(reduxThunk));
12 |
13 | ReactDOM.render(
14 |
15 |
16 |
17 |
18 |
19 |
20 | ,
21 | document.getElementById('root')
22 | );
23 |
--------------------------------------------------------------------------------
/admin/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | const userReducers = (state = { userId: -1 }, action) => {
4 | switch (action.type) {
5 | case 'USER':
6 | return { ...state, ...action.payload };
7 | case 'CLEAR_USER':
8 | return action.payload;
9 | default:
10 | return state;
11 | }
12 | };
13 |
14 | // 0: not login 1: login 2: unknown
15 | const statusReducers = (state = 2, action) => {
16 | if (action.type === 'USER_STATUS') {
17 | return action.payload;
18 | } else {
19 | return state;
20 | }
21 | };
22 |
23 | const PORTAL_INIT_STATE = {
24 | open: false,
25 | color: '',
26 | header: '',
27 | body: '',
28 | };
29 |
30 | const portalReducers = (state = PORTAL_INIT_STATE, action) => {
31 | if (action.type === 'SET_PORTAL') {
32 | return { ...state, ...action.payload };
33 | } else {
34 | return state;
35 | }
36 | };
37 |
38 | export default combineReducers({
39 | status: statusReducers,
40 | user: userReducers,
41 | portal: portalReducers,
42 | });
43 |
--------------------------------------------------------------------------------
/admin/src/utils.js:
--------------------------------------------------------------------------------
1 | export const getDate = (dateStr) => {
2 | let format = 'yyyy-MM-dd hh:mm:ss';
3 | let date;
4 | if (dateStr) {
5 | date = new Date(dateStr);
6 | } else {
7 | date = new Date();
8 | }
9 | const o = {
10 | 'M+': date.getMonth() + 1,
11 | 'd+': date.getDate(),
12 | 'h+': date.getHours(),
13 | 'm+': date.getMinutes(),
14 | 's+': date.getSeconds(),
15 | S: date.getMilliseconds(),
16 | };
17 |
18 | if (/(y+)/.test(format)) {
19 | format = format.replace(
20 | RegExp.$1,
21 | (date.getFullYear() + '').substr(4 - RegExp.$1.length)
22 | );
23 | }
24 |
25 | for (let k in o) {
26 | if (new RegExp('(' + k + ')').test(format)) {
27 | format = format.replace(
28 | RegExp.$1,
29 | RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length)
30 | );
31 | }
32 | }
33 | return format;
34 | };
35 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const http = require('http');
3 | const session = require('express-session');
4 | const flash = require('connect-flash');
5 | const cookieParser = require('cookie-parser');
6 | const logger = require('morgan');
7 | const { updateConfig, loadNoticeContent } = require('./common/config');
8 | const config = require('./config');
9 | const serveStatic = require('serve-static');
10 | const path = require('path');
11 | const enableRSS = require('./common/rss').enableRSS;
12 | const rateLimit = require('express-rate-limit');
13 | const compression = require('compression');
14 | const crypto = require('crypto');
15 | const webRouter = require('./routes/web-router');
16 | const apiRouterV1 = require('./routes/api-router.v1');
17 | const app = express();
18 | const server = http.createServer(app);
19 | const { initializeDatabase } = require('./models');
20 | const { loadAllPages } = require('./common/cache');
21 |
22 | app.use(
23 | rateLimit({
24 | windowMs: 30 * 1000,
25 | max: 60
26 | })
27 | );
28 | app.use(
29 | '/api/comment',
30 | rateLimit({
31 | windowMs: 60 * 1000,
32 | max: 5
33 | })
34 | );
35 | app.use(compression());
36 | app.locals.systemName = config.systemName;
37 | app.locals.systemVersion = config.systemVersion;
38 | app.locals.config = {};
39 | app.locals.config.theme = 'bulma';
40 | app.locals.page = undefined;
41 | app.locals.notice = '请创建一个链接为 notice 的页面,其内容将在此显示';
42 | app.locals.loggedin = false;
43 | app.locals.isAdmin = false;
44 | app.locals.sitemap = undefined;
45 | app.set('view engine', 'ejs');
46 | app.set('trust proxy', true);
47 | app.use(logger('dev'));
48 | app.use(express.json({ limit: '50mb' }));
49 | app.use(express.urlencoded({ extended: false, limit: '50mb' }));
50 | app.use(cookieParser(crypto.randomBytes(64).toString('hex')));
51 | app.use(
52 | session({
53 | resave: true,
54 | saveUninitialized: true,
55 | secret: crypto.randomBytes(64).toString('hex')
56 | })
57 | );
58 |
59 | app.use(flash());
60 |
61 | (async () => {
62 | await initializeDatabase();
63 | // load configuration & update app.locals
64 | await updateConfig(app);
65 | await loadNoticeContent(app);
66 | enableRSS(app.locals.config);
67 | // load pages
68 | await loadAllPages();
69 |
70 | // Then we set up the app.
71 | let serveStaticOptions = {
72 | maxAge: config.cacheMaxAge * 1000
73 | };
74 | app.use('/upload', serveStatic(config.uploadPath, serveStaticOptions));
75 | app.use('/admin', serveStatic(path.join(__dirname, 'public', 'admin'), serveStaticOptions));
76 | app.get('/feed.xml', (req, res) => {
77 | res.download(path.join(__dirname, 'public', 'feed.xml'));
78 | });
79 | app.use(
80 | serveStatic(path.join(__dirname, 'data', 'index'), serveStaticOptions)
81 | );
82 |
83 | app.use('*', (req, res, next) => {
84 | if (req.session.user !== undefined) {
85 | res.locals.loggedin = true;
86 | res.locals.isAdmin = req.session.user.isAdmin;
87 | }
88 | next();
89 | });
90 |
91 | app.use('/', webRouter);
92 | app.use('/api', apiRouterV1);
93 |
94 | app.use(function(req, res, next) {
95 | if (!res.headersSent) {
96 | res.render('message', {
97 | title: '未找到目标页面',
98 | message: '所请求的页面不存在,请检查页面链接是否正确'
99 | });
100 | }
101 | });
102 | })();
103 |
104 | server.listen(config.port);
105 |
106 | server.on('error', err => {
107 | console.error(
108 | `An error occurred on the server, please check if port ${config.port} is occupied.`
109 | );
110 | console.error(err.toString());
111 | });
112 |
113 | server.on('listening', () => {
114 | console.log(`Server listen on port: ${config.port}.`);
115 | });
116 |
117 | module.exports = app;
118 |
--------------------------------------------------------------------------------
/bin/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | *.pyc
--------------------------------------------------------------------------------
/bin/create_page_with_token.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 |
4 | def create_page(title, description, tags, content):
5 | res = requests.post('http://localhost:3000/api/page', json={
6 | 'title': title,
7 | 'description': description,
8 | 'tags': tags,
9 | 'content': content
10 | }, headers={
11 | 'authorization': "366f984e-10a4-4b35-8ab2-f196e8b02aaf"
12 | })
13 | return res.json()
14 |
15 |
16 | print(create_page('title', 'description', ['tag1', 'tag2'], 'content'))
17 |
--------------------------------------------------------------------------------
/bin/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/blog/dfb2ae9d0cb5a27fca3a525633785988adbd8fcb/bin/requirements.txt
--------------------------------------------------------------------------------
/bin/update_tag_and_page_status.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import sqlite3
3 |
4 |
5 | def main(args):
6 | connection = sqlite3.connect(args.db_path)
7 | cursor = connection.cursor()
8 | res = cursor.execute("SELECT id, tag FROM Pages")
9 | pages = res.fetchall()
10 | for page_id, page_tag in pages:
11 | if args.space2semicolon:
12 | page_tag = page_tag.replace(' ', ';')
13 | if args.hyphen2space:
14 | page_tag = page_tag.replace('-', ' ')
15 | hide_page = False
16 | for hidden_tag in args.hide_tags:
17 | if hidden_tag in page_tag:
18 | hide_page = True
19 | break
20 | cursor.execute("UPDATE Pages SET tag = ? WHERE id = ?", (page_tag, page_id))
21 | if hide_page:
22 | cursor.execute("UPDATE Pages SET pageStatus = ? WHERE id = ?", (3, page_id))
23 | cursor.execute("")
24 | connection.commit()
25 |
26 |
27 | if __name__ == '__main__':
28 | parser = argparse.ArgumentParser()
29 | parser.add_argument('--space2semicolon', action='store_true')
30 | parser.add_argument('--hyphen2space', action='store_true')
31 | parser.add_argument('--db_path', type=str, default="../data.db")
32 | parser.add_argument('--hide_tags', type=str, nargs='+', default=['LeetCode', '剑指', '牛客', '算法题', '问题', 'Linux 系统', 'PyTorch 相关笔记'])
33 | main(parser.parse_args())
34 |
--------------------------------------------------------------------------------
/blog.conf:
--------------------------------------------------------------------------------
1 | # Remember to add `include /path/to/blog.conf;` to the http block of file `/etc/nginx/nginx.conf`.
2 | # Then you can choose to use certbot to request a free SSL certificate: `sudo certbot --nginx`.
3 | # After that restart nginx: `sudo service restart nginx`.
4 |
5 | server {
6 | listen 80 default;
7 | server_name _;
8 | return 301 https://$host$request_uri;
9 | }
10 |
11 | server {
12 | listen 443 ssl http2;
13 | listen [::]:443 ssl http2;
14 | server_name your.domain.com; # Change it to your domain.
15 |
16 | location / {
17 | proxy_pass http://localhost:3000;
18 | proxy_http_version 1.1;
19 | proxy_set_header Upgrade $http_upgrade;
20 | proxy_set_header Connection 'upgrade';
21 | proxy_set_header Host $host;
22 | proxy_set_header X-Forwarded-For $remote_addr;
23 | proxy_cache_bypass $http_upgrade;
24 | }
25 | }
--------------------------------------------------------------------------------
/common/cache.js:
--------------------------------------------------------------------------------
1 | const { parseTagStr } = require('./util');
2 | const { Page } = require('../models');
3 | const { PAGE_STATUS, PAGE_TYPES } = require('./constant');
4 | const { Op } = require('sequelize');
5 | const { md2html } = require('./util');
6 | const LRU = require('lru-cache');
7 | const config = require('../config');
8 |
9 | const options = {
10 | max: config.maxCachePosts,
11 | allowStale: true,
12 | updateAgeOnGet: true,
13 | updateAgeOnHas: false,
14 | };
15 |
16 | let pages = undefined;
17 | // Key is the page id, value the index of this page in array pages.
18 | let id2index = new Map();
19 | // Key is the page id, value is the converted content.
20 | // let convertedContentCache = new Map();
21 | const convertedContentCache = new LRU(options);
22 | // Key is a tag name, value is an array of pages list ordered by their links.
23 | let categoryCache = new Map();
24 |
25 | function updateCache(page, isNew, updateConvertedContent) {
26 | // update converted content cache
27 | convertContent(page, updateConvertedContent);
28 | // Delete corresponding key in categoryCache
29 | let [category, _] = parseTagStr(page.tag);
30 | categoryCache.delete(category);
31 |
32 | if (isNew) {
33 | // Add new page to the front of pages.
34 | pages.unshift(page);
35 | } else {
36 | // Update pages.
37 | let i = id2index.get(page.id);
38 | if (i !== undefined) {
39 | pages.splice(i, 1);
40 | pages.unshift(page);
41 | }
42 | }
43 | // Update the index.
44 | // updateId2Index();
45 | }
46 |
47 | function deleteCacheEntry(id) {
48 | let index = id2index.get(id);
49 | if (index === undefined) return;
50 | // Delete corresponding key in categoryCache
51 | let [category, _] = parseTagStr(pages[index].tag);
52 | categoryCache.delete(category);
53 |
54 | // Clear converted content cache.
55 | convertedContentCache.delete(id);
56 | // Delete this page form pages array.
57 | pages.splice(index, 1);
58 | }
59 |
60 | function updateId2Index() {
61 | id2index.clear();
62 | for (let i = 0; i < pages.length; i++) {
63 | id2index.set(pages[i].id, i);
64 | }
65 | }
66 |
67 | function getLinks(id) {
68 | let i = id2index.get(id);
69 | if (i === undefined) {
70 | i = 0;
71 | }
72 | let prevIndex = Math.max(i - 1, 0);
73 | let nextIndex = Math.min(i + 1, pages.length - 1);
74 |
75 | return {
76 | prev: {
77 | title: pages[prevIndex].title,
78 | link: pages[prevIndex].link
79 | },
80 | next: {
81 | title: pages[nextIndex].title,
82 | link: pages[nextIndex].link
83 | }
84 | };
85 | }
86 |
87 | async function loadAllPages() {
88 | // The password & token of user shouldn't be load!
89 | try {
90 | pages = await Page.findAll({
91 | where: {
92 | [Op.or]: [
93 | { pageStatus: PAGE_STATUS.PUBLISHED },
94 | { pageStatus: PAGE_STATUS.TOPPED }
95 | ]
96 | },
97 | order: [
98 | ['pageStatus', 'DESC'],
99 | ['updatedAt', 'DESC']
100 | ],
101 | raw: true
102 | });
103 | updateId2Index();
104 | } catch (e) {
105 | console.log('Failed to load all pages!');
106 | console.error(e);
107 | }
108 | }
109 |
110 | async function getPageListByTag(tag) {
111 | // Check cache.
112 | if (categoryCache.has(tag)) {
113 | return categoryCache.get(tag);
114 | }
115 |
116 | // Retrieve from database.
117 | let list = [];
118 | try {
119 | list = await Page.findAll({
120 | where: {
121 | [Op.or]: [
122 | {
123 | tag: {
124 | [Op.like]: `${tag};%`
125 | }
126 | },
127 | {
128 | tag: {
129 | [Op.eq]: `${tag}`
130 | }
131 | }
132 | ],
133 | [Op.not]: [{ pageStatus: PAGE_STATUS.RECALLED }]
134 | },
135 | attributes: ['link', 'title'],
136 | order: [['link', 'ASC']],
137 | raw: true
138 | });
139 | // Save it to cache
140 | categoryCache.set(tag, list);
141 | } catch (e) {
142 | console.error('Failed to get pages list by tag!');
143 | console.error(e);
144 | }
145 | return list;
146 | }
147 |
148 | async function getPagesByRange(start, num) {
149 | if (pages === undefined) {
150 | // This means the server is just started.
151 | await loadAllPages();
152 | }
153 | if (num === -1) {
154 | return pages.slice(start);
155 | }
156 | return pages.slice(start, start + num);
157 | }
158 |
159 | function convertContent(page, refresh) {
160 | if (convertedContentCache.has(page.id) && !refresh) {
161 | return convertedContentCache.get(page.id);
162 | }
163 | let convertedContent = '';
164 |
165 | let lines = page.content.split('\n');
166 | let deleteCount = 0;
167 | for (let i = 1; i < lines.length; ++i) {
168 | let line = lines[i];
169 | if (line.startsWith('---')) {
170 | deleteCount = i + 1;
171 | break;
172 | }
173 | }
174 | lines.splice(0, deleteCount);
175 |
176 | if (page.type === PAGE_TYPES.ARTICLE || page.type === PAGE_TYPES.DISCUSS) {
177 | convertedContent = md2html(lines.join('\n'));
178 | } else if (page.type === PAGE_TYPES.LINKS) {
179 | let linkList = [];
180 | let linkCount = -1;
181 | for (let i = 0; i < lines.length; ++i) {
182 | let line = lines[i].split(':');
183 | let key = line[0].trim();
184 | if (!['title', 'link', 'image', 'description'].includes(key)) continue;
185 | let value = line
186 | .splice(1)
187 | .join(':')
188 | .trim();
189 | if (key === 'title') {
190 | linkList.push({
191 | title: 'No title',
192 | image: '',
193 | link: '/',
194 | description: 'No description'
195 | });
196 | linkCount++;
197 | } else {
198 | if (linkCount < 0) continue;
199 | }
200 | linkList[linkCount][key] = value;
201 | }
202 | convertedContent = JSON.stringify(linkList);
203 | } else if (page.type === PAGE_TYPES.REDIRECT) {
204 | convertedContent = lines.join('\n').trim();
205 | } else if (page.type === PAGE_TYPES.TEXT) {
206 | convertedContent = lines.join('\n').trimStart();
207 | }
208 | convertedContentCache.set(page.id, convertedContent);
209 | return convertedContent;
210 | }
211 |
212 | function updateView(id) {
213 | let i = id2index.get(id);
214 | if (i === undefined) return;
215 | if (i >= 0 && i < pages.length) {
216 | pages[i].view++;
217 | }
218 | }
219 |
220 | module.exports = {
221 | getPagesByRange,
222 | convertContent,
223 | deleteCacheEntry,
224 | getLinks,
225 | updateCache,
226 | updateView,
227 | loadAllPages: loadAllPages,
228 | getPageListByTag
229 | };
230 |
--------------------------------------------------------------------------------
/common/config.js:
--------------------------------------------------------------------------------
1 | const { convertContent } = require('./cache');
2 | const Option = require('../models').Option;
3 | const Page = require('../models').Page;
4 | const path = require('path');
5 |
6 | async function updateConfig(app) {
7 | let config = app.locals.config;
8 | try {
9 | let options = await Option.findAll();
10 | options.forEach(option => {
11 | config[option.key] = option.value;
12 | });
13 | config.title = config.motto + ' | ' + config.site_name;
14 | } catch (e) {
15 | console.error('Unable to update config.');
16 | console.error(e);
17 | }
18 | app.cache = {};
19 | app.set(
20 | 'views',
21 | path.join(__dirname, `../themes/${app.locals.config.theme}`)
22 | );
23 | }
24 |
25 | async function loadNoticeContent(app) {
26 | let page = await Page.findOne({
27 | where: {
28 | link: 'notice'
29 | },
30 | raw: true
31 | });
32 | if (page) {
33 | app.locals.notice = convertContent(page, true);
34 | }
35 | }
36 |
37 | module.exports = {
38 | updateConfig,
39 | loadNoticeContent
40 | };
41 |
--------------------------------------------------------------------------------
/common/constant.js:
--------------------------------------------------------------------------------
1 | const PAGE_TYPES = {
2 | ARTICLE: 0,
3 | CODE: 1,
4 | BULLETIN: 2,
5 | DISCUSS: 3,
6 | LINKS: 4,
7 | RAW: 5,
8 | MEDIA: 6,
9 | TIMELINE: 7,
10 | REDIRECT: 8,
11 | TEXT: 9
12 | };
13 |
14 | const PAGE_STATUS = {
15 | RECALLED: 0,
16 | PUBLISHED: 1,
17 | TOPPED: 2,
18 | HIDDEN: 3
19 | };
20 |
21 | module.exports = { PAGE_TYPES, PAGE_STATUS };
22 |
--------------------------------------------------------------------------------
/common/database.js:
--------------------------------------------------------------------------------
1 | const { Sequelize } = require('sequelize');
2 | const config = require('../config');
3 |
4 | const sequelize = new Sequelize({
5 | dialect: 'sqlite',
6 | storage: config.database
7 | });
8 |
9 | module.exports = sequelize;
10 |
--------------------------------------------------------------------------------
/common/migrate.js:
--------------------------------------------------------------------------------
1 | const sqlite3 = require('sqlite3');
2 | const oldDb = new sqlite3.Database('./old.db');
3 | const { initializeDatabase, Page, User, Option } = require('../models');
4 |
5 | async function getAdminId() {
6 | let user = await User.findOne({
7 | where: {
8 | username: 'admin'
9 | },
10 | raw: true
11 | });
12 | return user.id;
13 | }
14 |
15 | async function migratePages() {
16 | let id = await getAdminId();
17 | oldDb.all('select * from pages', [], (err, pages) => {
18 | if (err) {
19 | console.error(err);
20 | }
21 | pages.forEach(async page => {
22 | try {
23 | let pageObj = await Page.create({
24 | type: page.type,
25 | link: page.link,
26 | pageStatus: page.page_status,
27 | commentStatus: page.comment_status,
28 | title: page.title,
29 | content: page.content,
30 | tag: page.tag,
31 | description: page.description,
32 | password: page.password,
33 | view: page.view,
34 | upVote: page.up_vote,
35 | downVote: page.down_vote,
36 | UserId: id,
37 | createdAt: page.post_time,
38 | updatedAt: page.edit_time
39 | });
40 | } catch (e) {
41 | console.error(e);
42 | }
43 | });
44 | });
45 | }
46 |
47 | async function migrateOptions() {
48 | oldDb.all('select * from options', [], (err, options) => {
49 | if (err) {
50 | console.error(err);
51 | }
52 | options.forEach(async option => {
53 | try {
54 | let optionObj = await Option.findOne({
55 | where: {
56 | key: option.name
57 | }
58 | });
59 | await optionObj.update({
60 | key: option.name,
61 | value: option.value
62 | });
63 | } catch (e) {
64 | console.error(e);
65 | }
66 | });
67 | });
68 | }
69 |
70 | (async () => {
71 | await initializeDatabase();
72 | await migratePages();
73 | await migrateOptions();
74 | })();
75 |
--------------------------------------------------------------------------------
/common/rss.js:
--------------------------------------------------------------------------------
1 | const Feed = require('feed').Feed;
2 | const { getPagesByRange } = require('./cache');
3 | const fs = require('fs');
4 | const { convertContent } = require('./cache');
5 | const { md2html } = require('./util');
6 |
7 | let Config;
8 |
9 | function enableRSS(config) {
10 | Config = config;
11 | setTimeout(async () => {
12 | await generateRSS();
13 | setInterval(generateRSS, 24 * 60 * 60 * 1000);
14 | }, 5000);
15 | }
16 |
17 | async function generateRSS() {
18 | console.log('Start generating Feed.');
19 | const feed = new Feed({
20 | title: `${Config.site_name}`,
21 | description: `${Config.description}`,
22 | id: Config.domain,
23 | link: `https://${Config.domain}/`,
24 | language: `${Config.language}`,
25 | favicon: `${Config.favicon}`,
26 | copyright: `${Config.copyright}`,
27 | feedLinks: {
28 | atom: 'https://example.com/atom.xml'
29 | },
30 | author: {
31 | name: `${Config.author}`,
32 | link: `https://${Config.domain}/`
33 | }
34 | });
35 |
36 | try {
37 | let pages = await getPagesByRange(0, 10);
38 | pages.forEach(page => {
39 | feed.addItem({
40 | title: page.title,
41 | id: page.link,
42 | link: `https://${Config.domain}/page/${page.link}`,
43 | description: page.description,
44 | converted_content: convertContent(page, false),
45 | author: [
46 | {
47 | name: page.author
48 | }
49 | ],
50 | date: new Date(page.updatedAt)
51 | });
52 | });
53 | fs.writeFile('./public/feed.xml', feed.atom1(), () => {
54 | console.log('Feed generated.');
55 | });
56 | } catch (e) {
57 | console.error(e);
58 | }
59 | }
60 |
61 | module.exports = {
62 | enableRSS
63 | };
64 |
--------------------------------------------------------------------------------
/common/util.js:
--------------------------------------------------------------------------------
1 | const { lexer, parser } = require('marked');
2 | const fs = require('fs');
3 | const crypto = require('crypto');
4 |
5 | function titleToLink(title) {
6 | return title.trim().replace(/\s/g, '-');
7 | }
8 |
9 | function getDate(format, dateStr) {
10 | if (format === undefined || format === 'default')
11 | format = 'yyyy-MM-dd hh:mm:ss';
12 | let date;
13 | if (dateStr) {
14 | date = new Date(dateStr);
15 | } else {
16 | date = new Date();
17 | }
18 | const o = {
19 | 'M+': date.getMonth() + 1,
20 | 'd+': date.getDate(),
21 | 'h+': date.getHours(),
22 | 'm+': date.getMinutes(),
23 | 's+': date.getSeconds(),
24 | S: date.getMilliseconds()
25 | };
26 |
27 | if (/(y+)/.test(format)) {
28 | format = format.replace(
29 | RegExp.$1,
30 | (date.getFullYear() + '').substr(4 - RegExp.$1.length)
31 | );
32 | }
33 |
34 | for (let k in o) {
35 | if (new RegExp('(' + k + ')').test(format)) {
36 | format = format.replace(
37 | RegExp.$1,
38 | RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length)
39 | );
40 | }
41 | }
42 | return format;
43 | }
44 |
45 | function md2html(markdown) {
46 | return parser(lexer(markdown));
47 | }
48 |
49 | function parseTagStr(tag) {
50 | let tags = tag.split(';');
51 | let category = undefined;
52 | if (tags.length !== 0) {
53 | category = tags.shift();
54 | }
55 | return [category, tags];
56 | }
57 |
58 | async function fileExists(path) {
59 | return !!(await fs.promises.stat(path).catch(e => false));
60 | }
61 |
62 | const saltLength = 16;
63 |
64 | function hashPasswordWithSalt(password) {
65 | const salt = crypto.randomBytes(Math.ceil(saltLength / 2)).toString('hex').slice(0, saltLength);
66 | const hash = crypto.createHmac('sha512', salt);
67 | hash.update(password);
68 | const hashedPassword = hash.digest('hex');
69 | return salt + hashedPassword;
70 | }
71 |
72 | function checkPassword(plainTextPassword, hashedPasswordWithSalt) {
73 | const salt = hashedPasswordWithSalt.substring(0, saltLength);
74 | const realHashedPassword = hashedPasswordWithSalt.substring(saltLength);
75 | const hash = crypto.createHmac('sha512', salt);
76 | hash.update(plainTextPassword);
77 | const hashedPassword = hash.digest('hex');
78 | return hashedPassword === realHashedPassword;
79 | }
80 |
81 | module.exports = {
82 | titleToLink,
83 | parseTagStr,
84 | getDate,
85 | md2html,
86 | fileExists,
87 | hashPasswordWithSalt,
88 | checkPassword
89 | };
90 |
--------------------------------------------------------------------------------
/config.js:
--------------------------------------------------------------------------------
1 | let fs = require('fs');
2 |
3 | let config = {
4 | port: process.env.PORT || 3000,
5 | database: process.env.SQLITE_PATH || './data/data.db',
6 | auth_cookie_name: 'blog',
7 | uploadPath: process.env.UPLOAD_PATH || './data/upload',
8 | systemName: 'Blog',
9 | systemVersion: 'v0.0.0',
10 | cacheMaxAge: 30 * 24 * 3600, // 30 days
11 | maxCachePosts: 32
12 | };
13 |
14 | function init() {
15 | if (config.systemVersion === 'v0.0.0') {
16 | if (!fs.existsSync(config.uploadPath)) {
17 | fs.mkdirSync(config.uploadPath);
18 | }
19 | let meta = JSON.parse(fs.readFileSync('package.json').toString());
20 | config.systemVersion = `v${meta.version}`;
21 | }
22 | }
23 |
24 | init();
25 |
26 | module.exports = config;
27 |
--------------------------------------------------------------------------------
/controllers/file.js:
--------------------------------------------------------------------------------
1 | const { Op } = require('sequelize');
2 | const { File } = require('../models');
3 | const uploadPath = require('../config').uploadPath;
4 | const fs = require('fs');
5 | const path = require('path');
6 |
7 | async function getAll(req, res, next) {
8 | let files = [];
9 | let message = 'ok';
10 | let status = true;
11 | try {
12 | files = await File.findAll({ order: [['updatedAt', 'DESC']], raw: true });
13 | } catch (e) {
14 | status = false;
15 | message = e.message;
16 | }
17 | res.json({ status, message, files });
18 | }
19 |
20 | async function upload(req, res) {
21 | const { file } = req;
22 | const newFile = {
23 | description: req.body.description,
24 | filename: file.originalname,
25 | path: '/upload/' + file.filename,
26 | id: file.id
27 | };
28 | let status = false;
29 | let message = 'ok';
30 | try {
31 | let file = await File.create(newFile);
32 | status = file !== null;
33 | } catch (e) {
34 | message = e.message;
35 | console.error(message);
36 | }
37 | res.json({ status, message, file: newFile });
38 | }
39 |
40 | async function get(req, res, next) {
41 | const id = req.params.id;
42 | let file;
43 | let status = false;
44 | let message = 'ok';
45 | try {
46 | file = await File.findOne({
47 | where: {
48 | id
49 | },
50 | raw: true
51 | });
52 | status = file !== null;
53 | } catch (e) {
54 | message = e.message;
55 | }
56 | res.json({ status, message, file });
57 | }
58 |
59 | async function delete_(req, res, next) {
60 | const id = req.params.id;
61 | let status = false;
62 | let message = 'ok';
63 | try {
64 | let rows = await File.destroy({
65 | where: {
66 | id
67 | }
68 | });
69 | status = rows === 1;
70 | let filePath = path.join(uploadPath, id);
71 | fs.unlink(filePath, error => {
72 | if (error) {
73 | console.error(error);
74 | }
75 | });
76 | } catch (e) {
77 | message = e.message;
78 | }
79 |
80 | res.json({ status, message });
81 | }
82 |
83 | async function search(req, res, next) {
84 | let keyword = req.body.keyword;
85 | keyword = keyword ? keyword.trim() : '';
86 | let files = [];
87 | let message = 'ok';
88 | let status = true;
89 | try {
90 | files = await File.findAll({
91 | where: {
92 | [Op.or]: [
93 | {
94 | filename: {
95 | [Op.like]: `%${keyword}%`
96 | }
97 | },
98 | {
99 | description: {
100 | [Op.like]: `%${keyword}%`
101 | }
102 | }
103 | ]
104 | }
105 | });
106 | } catch (e) {
107 | status = false;
108 | message = e.message;
109 | console.error(e);
110 | }
111 | res.json({
112 | status,
113 | message,
114 | files
115 | });
116 | }
117 |
118 | module.exports = {
119 | getAll,
120 | upload,
121 | get,
122 | delete_,
123 | search
124 | };
125 |
--------------------------------------------------------------------------------
/controllers/index.js:
--------------------------------------------------------------------------------
1 | const { updateView } = require('../common/cache');
2 | const { getLinks } = require('../common/cache');
3 | const { getDate } = require('../common/util');
4 | const { getPagesByRange } = require('../common/cache');
5 | const { Page } = require('../models');
6 | const { SitemapStream, streamToPromise } = require('sitemap');
7 | const { createGzip } = require('zlib');
8 | const { PAGE_STATUS, PAGE_TYPES } = require('../common/constant');
9 | const { convertContent } = require('../common/cache');
10 | const { Op } = require('sequelize');
11 | const path = require('path');
12 | const config = require('../config');
13 | const { parseTagStr } = require('../common/util');
14 | const { getPageListByTag } = require('../common/cache');
15 |
16 | async function getIndex(req, res, next) {
17 | if (req.url === '/' && req.app.locals.config.index_page_content !== '') {
18 | if (req.app.locals.config.index_page_content === '404') {
19 | res.status(404);
20 | res.end();
21 | } else {
22 | res.setHeader('Content-Type', 'text/html');
23 | res.send(req.app.locals.config.index_page_content);
24 | }
25 | return;
26 | }
27 | let page = parseInt(req.query.p);
28 | if (!page || page <= 0) {
29 | page = 0;
30 | }
31 | let pageSize = 10;
32 | let start = page * pageSize;
33 | let pages = await getPagesByRange(start, pageSize);
34 | if (page !== 0 && pages.length === 0) {
35 | res.redirect('/');
36 | } else {
37 | res.render('index', {
38 | pages: pages,
39 | prev: `?p=${page - 1}`,
40 | next: `?p=${page + 1}`
41 | });
42 | }
43 | }
44 |
45 | async function getArchive(req, res, next) {
46 | let pages = await getPagesByRange(0, -1);
47 | res.render('archive', {
48 | pages: pages.reverse()
49 | });
50 | }
51 |
52 | async function getSitemap(req, res, next) {
53 | res.header('Content-Type', 'application/xml');
54 | res.header('Content-Encoding', 'gzip');
55 |
56 | if (req.app.locals.sitemap) {
57 | res.send(req.app.locals.sitemap);
58 | return;
59 | }
60 | try {
61 | const hostname = 'https://' + req.app.locals.config.domain;
62 | const smStream = new SitemapStream({ hostname });
63 | const pipeline = smStream.pipe(createGzip());
64 | let pages = await getPagesByRange(0, -1);
65 | pages.forEach(page => {
66 | smStream.write({ url: `/page/` + page.link });
67 | });
68 | streamToPromise(pipeline).then(sm => (req.app.locals.sitemap = sm));
69 | smStream.end();
70 | pipeline.pipe(res).on('error', e => {
71 | throw e;
72 | });
73 | } catch (e) {
74 | console.error(e);
75 | res.status(500).end();
76 | }
77 | }
78 |
79 | async function getMonthArchive(req, res, next) {
80 | const year = req.params.year;
81 | let month = req.params.month;
82 | const time = year + '-' + month;
83 | let startDate = new Date(year, parseInt(month) - 1, 1, 0, 0, 0, 0);
84 | let endDate = new Date(startDate);
85 | endDate.setMonth(startDate.getMonth() + 1);
86 | try {
87 | let pages = await Page.findAll({
88 | where: {
89 | pageStatus: {
90 | [Op.not]: PAGE_STATUS.RECALLED
91 | },
92 | createdAt: {
93 | [Op.between]: [startDate, endDate]
94 | }
95 | },
96 | raw: true
97 | });
98 | res.render('list', { pages, title: time });
99 | } catch (e) {
100 | res.render('message', {
101 | title: '错误',
102 | message: e.message
103 | });
104 | }
105 | }
106 |
107 | async function getTag(req, res, next) {
108 | const tag = req.params.tag;
109 | try {
110 | let pages = await Page.findAll({
111 | where: {
112 | pageStatus: {
113 | [Op.not]: PAGE_STATUS.RECALLED
114 | },
115 | tag: {
116 | [Op.like]: `%${tag}%`
117 | }
118 | },
119 | raw: true
120 | });
121 | res.render('list', { pages, title: tag });
122 | } catch (e) {
123 | res.render('message', {
124 | title: '错误',
125 | message: e.message
126 | });
127 | }
128 | }
129 |
130 | async function getPage(req, res, next) {
131 | const link = req.params.link;
132 | let page = await Page.findOne({
133 | where: {
134 | [Op.and]: [{ link }],
135 | [Op.not]: [{ pageStatus: PAGE_STATUS.RECALLED }]
136 | }
137 | });
138 | if (page === null) {
139 | return res.render('message', {
140 | title: '错误',
141 | message: `未找到链接为 ${link} 且公共可见的页面`
142 | });
143 | }
144 | // Update views
145 | page.increment('view').then();
146 | page = page.get({ plain: true });
147 | // Change the data format.
148 | page.createdAt = getDate('default', page.createdAt);
149 | page.updatedAt = getDate('default', page.updatedAt);
150 |
151 | page.view++;
152 | updateView(page.id);
153 | if (page.password) {
154 | page.converted_content = '本篇文章被密码保护,需要输入密码才能查看,但是正在使用的主题不支持该功能!
';
155 | } else {
156 | page.converted_content = convertContent(page, false);
157 | }
158 | // Category
159 | let [category, tags] = parseTagStr(page.tag);
160 | page.tags = tags;
161 | if (category && category !== 'Others') {
162 | page.category = category;
163 | page.categoryList = await getPageListByTag(page.category);
164 | } else {
165 | page.categoryList = [];
166 | }
167 |
168 | res.locals.links = getLinks(page.id);
169 | switch (page.type) {
170 | case PAGE_TYPES.ARTICLE:
171 | res.render('article', { page });
172 | break;
173 | case PAGE_TYPES.CODE:
174 | res.render('code', { page });
175 | break;
176 | case PAGE_TYPES.RAW:
177 | res.render('raw', { page });
178 | break;
179 | case PAGE_TYPES.DISCUSS:
180 | res.render('discuss', { page });
181 | break;
182 | case PAGE_TYPES.LINKS:
183 | let linkList;
184 | try {
185 | linkList = JSON.parse(page.converted_content);
186 | } catch (e) {
187 | console.error(e.message);
188 | }
189 | res.render('links', { page, linkList });
190 | break;
191 | case PAGE_TYPES.REDIRECT:
192 | res.redirect(page.converted_content);
193 | break;
194 | case PAGE_TYPES.TEXT:
195 | let type = 'text/plain';
196 | if (page.link.endsWith('.html')) {
197 | type = 'text/html';
198 | } else if (page.link.endsWith('.json')) {
199 | type = 'application/json';
200 | }
201 | res.set('Content-Type', type);
202 | res.send(page.converted_content);
203 | break;
204 | default:
205 | res.render('message', {
206 | title: '错误',
207 | message: `意料之外的页面类型:${page.type}`
208 | });
209 | }
210 | }
211 |
212 | async function getStaticFile(req, res, next) {
213 | let filePath = req.path;
214 | if (filePath) {
215 | res.set('Cache-control', `public, max-age=${config.cacheMaxAge}`);
216 | return res.sendFile(
217 | path.join(
218 | __dirname,
219 | `../themes/${req.app.locals.config.theme}/${filePath}`
220 | )
221 | );
222 | }
223 | res.sendStatus(404);
224 | }
225 |
226 | module.exports = {
227 | getIndex,
228 | getArchive,
229 | getSitemap,
230 | getMonthArchive,
231 | getTag,
232 | getPage,
233 | getStaticFile
234 | };
235 |
--------------------------------------------------------------------------------
/controllers/option.js:
--------------------------------------------------------------------------------
1 | const { updateConfig } = require('../common/config');
2 | const { Option } = require('../models');
3 |
4 | async function getAll(req, res) {
5 | let options = [];
6 | let message = 'ok';
7 | let status = true;
8 | try {
9 | options = await Option.findAll({ raw: true });
10 | } catch (e) {
11 | status = false;
12 | message = e.message;
13 | }
14 | res.json({ status, message, options });
15 | }
16 |
17 | async function get(req, res) {
18 | const key = req.params.name;
19 | let option;
20 | let status = false;
21 | let message = 'ok';
22 | try {
23 | option = await Option.findOne({
24 | where: {
25 | key
26 | },
27 | raw: true
28 | });
29 | status = option !== null;
30 | } catch (e) {
31 | message = e.message;
32 | }
33 | res.json({ status, message, option });
34 | }
35 |
36 | async function update(req, res, next) {
37 | const options = req.body;
38 | for (const [key, value] of Object.entries(options)) {
39 | if (req.app.locals.config[key] !== value) {
40 | let newOption = {
41 | key,
42 | value
43 | };
44 | try {
45 | let option = await Option.findOne({
46 | where: {
47 | key
48 | }
49 | });
50 | if (option) {
51 | await option.update(newOption);
52 | }
53 | } catch (e) {
54 | console.error(e);
55 | }
56 | }
57 | }
58 | // Here we actually didn't check the status.
59 | let status = true;
60 | let message = 'ok';
61 | await updateConfig(req.app);
62 | res.json({ status, message });
63 | }
64 |
65 | async function shutdown(req, res, next) {
66 | process.exit();
67 | }
68 |
69 | module.exports = {
70 | shutdown,
71 | update,
72 | get,
73 | getAll
74 | };
75 |
--------------------------------------------------------------------------------
/controllers/page.js:
--------------------------------------------------------------------------------
1 | const { convertContent, deleteCacheEntry } = require('../common/cache');
2 | const sequelize = require('sequelize');
3 | const { Op } = require('sequelize');
4 | const { Page } = require('../models');
5 | const Stream = require('stream');
6 | const { loadNoticeContent } = require('../common/config');
7 | const { updateCache, loadAllPages } = require('../common/cache');
8 | const { getDate } = require('../common/util');
9 | const { PAGE_STATUS, PAGE_TYPES } = require('../common/constant');
10 |
11 | async function search(req, res) {
12 | const type = Number(req.body.type);
13 | let types = [];
14 | if (type === undefined || type === -1) {
15 | types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
16 | } else {
17 | types.push(type);
18 | }
19 |
20 | let keyword = req.body.keyword;
21 | keyword = keyword ? keyword.trim() : '';
22 |
23 | let pages = [];
24 | let message = 'ok';
25 | let status = true;
26 | try {
27 | pages = await Page.findAll({
28 | where: {
29 | type: types,
30 | [Op.or]: [
31 | {
32 | title: {
33 | [Op.like]: `%${keyword}%`
34 | }
35 | },
36 | {
37 | description: {
38 | [Op.like]: `%${keyword}%`
39 | }
40 | },
41 | {
42 | tag: {
43 | [Op.like]: `%${keyword}%`
44 | }
45 | },
46 | {
47 | link: {
48 | [Op.like]: `%${keyword}%`
49 | }
50 | }
51 | ]
52 | },
53 | order: [sequelize.literal('"updatedAt" DESC')]
54 | });
55 | } catch (e) {
56 | status = false;
57 | message = e.message;
58 | console.error(e);
59 | }
60 |
61 | res.json({ status, message, pages });
62 | }
63 |
64 | async function create(req, res) {
65 | req.app.locals.sitemap = undefined;
66 | let type = req.body.type;
67 | let link = req.body.link;
68 | let pageStatus = req.body.pageStatus;
69 | let commentStatus = req.body.commentStatus;
70 | let title = req.body.title;
71 | let content = req.body.content;
72 | let tag = req.body.tag;
73 | let description = req.body.description;
74 | let password = req.body.password;
75 | let userId = req.session.user.id;
76 | let view = 0;
77 | let upVote = 0;
78 | let downVote = 0;
79 |
80 | if (req.session.authWithToken) {
81 | let oldContent = content;
82 | content = `---\ntitle: ${title}\ndescription: ${description}\ntags: \n`;
83 | let tags = req.body.tags;
84 | for (let i = 0; i < tags.length; i++) {
85 | content += `- ${tags[i]}\n`;
86 | }
87 | content += `---\n\n${oldContent}`;
88 | tag = tags.join(';');
89 | if (pageStatus === undefined) {
90 | pageStatus = PAGE_STATUS.PUBLISHED;
91 | }
92 | if (commentStatus === undefined) {
93 | commentStatus = 1;
94 | }
95 | if (type === undefined) {
96 | type = PAGE_TYPES.ARTICLE;
97 | }
98 | if (link === undefined) {
99 | link = title;
100 | }
101 | }
102 |
103 | let page;
104 | let message = 'ok';
105 | let status = false;
106 | let id;
107 | try {
108 | page = await Page.create({
109 | type,
110 | link,
111 | pageStatus,
112 | commentStatus,
113 | title,
114 | content,
115 | tag,
116 | description,
117 | password,
118 | view,
119 | upVote,
120 | downVote,
121 | UserId: userId
122 | });
123 | if (page) {
124 | page = page.get({ plain: true });
125 | // WTF, the date attributes here are object.
126 | page.createdAt = getDate('default', page.createdAt.toUTCString());
127 | page.updatedAt = getDate('default', page.updatedAt.toUTCString());
128 | id = page.id;
129 | status = true;
130 | updateCache(page, true, true);
131 | await loadAllPages();
132 | }
133 | } catch (e) {
134 | console.error(e);
135 | message = e.message;
136 | }
137 | if (link.toString() === 'notice') {
138 | loadNoticeContent(req.app).then();
139 | }
140 | res.json({ status, message, id });
141 | }
142 |
143 | async function getAll(req, res) {
144 | let pages;
145 | let status = true;
146 | let message = 'ok';
147 | try {
148 | pages = await Page.findAll({
149 | attributes: [
150 | 'id',
151 | 'type',
152 | 'link',
153 | 'pageStatus',
154 | 'commentStatus',
155 | 'title',
156 | 'tag',
157 | 'password',
158 | 'view',
159 | 'upVote',
160 | 'downVote',
161 | 'createdAt',
162 | 'updatedAt',
163 | 'UserId'
164 | ],
165 | order: [['updatedAt', 'DESC']]
166 | });
167 | } catch (e) {
168 | console.error(e);
169 | message = e.message;
170 | status = false;
171 | }
172 | res.json({ status, message, pages });
173 | }
174 |
175 | async function export_(req, res, next) {
176 | const id = req.params.id;
177 | try {
178 | let page = await Page.findOne({ where: { id } });
179 | if (page) {
180 | const filename = page.link + '.md';
181 | res.setHeader(
182 | 'Content-disposition',
183 | 'attachment; filename*=UTF-8\'\'' + encodeURIComponent(filename)
184 | );
185 | res.setHeader('Content-type', 'text/md');
186 | const fileStream = new Stream.Readable({
187 | read(size) {
188 | return true;
189 | }
190 | });
191 | fileStream.pipe(res);
192 | fileStream.push(page.content);
193 | res.end();
194 | return;
195 | }
196 | } catch (e) {
197 | console.error(e);
198 | }
199 | next();
200 | }
201 |
202 | async function get(req, res) {
203 | const id = req.params.id;
204 | let status = true;
205 | let message = 'ok';
206 | let page;
207 |
208 | try {
209 | page = await Page.findOne({
210 | where: { id }
211 | });
212 | } catch (e) {
213 | console.error(e);
214 | message = e.message;
215 | status = false;
216 | }
217 | res.json({ status, message, page });
218 | }
219 |
220 | async function getRenderedPage(req, res) {
221 | const id = req.params.id;
222 | let password = req.query.password;
223 | let status = false;
224 | let message = '';
225 | let content = '';
226 | if (!password) {
227 | password = '';
228 | }
229 | try {
230 | let page = await Page.findOne({
231 | where: {
232 | id, password,
233 | [Op.not]: [{ pageStatus: PAGE_STATUS.RECALLED }]
234 | }
235 | });
236 | if (page) {
237 | content = convertContent(page, false);
238 | status = true;
239 | } else {
240 | message = '文章不存在或被撤回,或密码错误';
241 | }
242 | } catch (e) {
243 | console.error(e);
244 | message = e.message;
245 | }
246 | res.json({ status, message, content });
247 | }
248 |
249 | async function update(req, res, next) {
250 | req.app.locals.sitemap = undefined;
251 | let id = req.body.id;
252 | let type = req.body.type;
253 | let link = req.body.link;
254 | let pageStatus = req.body.pageStatus;
255 | let commentStatus = req.body.commentStatus;
256 | let title = req.body.title;
257 | let content = req.body.content;
258 | let tag = req.body.tag;
259 | let description = req.body.description;
260 | let password = req.body.password;
261 | let updatedAt = new Date();
262 |
263 | let newPage = {
264 | type,
265 | link,
266 | pageStatus,
267 | commentStatus,
268 | title,
269 | content,
270 | tag,
271 | description,
272 | password,
273 | updatedAt
274 | };
275 |
276 | let message = 'ok';
277 | let status = false;
278 | let updateConvertedContent = false;
279 | try {
280 | let page = await Page.findOne({
281 | where: { id }
282 | });
283 | if (page) {
284 | let oldContent = page.get().content;
285 | await page.update(newPage);
286 | page = page.get({ plain: true });
287 | page.createdAt = getDate('default', page.createdAt.toUTCString());
288 | page.updatedAt = getDate('default', page.updatedAt.toUTCString());
289 | updateConvertedContent = oldContent !== newPage.content;
290 | status = true;
291 | loadAllPages();
292 | updateCache(page, false, updateConvertedContent);
293 | }
294 | } catch (e) {
295 | console.error(e);
296 | message = e.message;
297 | }
298 |
299 | // If we update the page notice, we should update sth to make it on index page.
300 | if (link.toString() === 'notice') {
301 | loadNoticeContent(req.app).then();
302 | }
303 | res.json({ status, message });
304 | }
305 |
306 | async function delete_(req, res) {
307 | req.app.locals.sitemap = undefined;
308 | const id = req.params.id;
309 |
310 | let status = false;
311 | let message = 'ok';
312 | try {
313 | let rows = await Page.destroy({
314 | where: {
315 | id
316 | }
317 | });
318 | status = rows === 1;
319 | } catch (e) {
320 | message = e.message;
321 | }
322 | deleteCacheEntry(id);
323 | await loadAllPages();
324 |
325 | res.json({ status, message });
326 | }
327 |
328 | module.exports = {
329 | search,
330 | create,
331 | getAll,
332 | export_,
333 | get,
334 | getRenderedPage,
335 | update,
336 | delete_
337 | };
338 |
--------------------------------------------------------------------------------
/controllers/user.js:
--------------------------------------------------------------------------------
1 | const { Op } = require('sequelize');
2 | const { User } = require('../models');
3 | const axios = require('axios');
4 | const { v4: uuidv4 } = require('uuid');
5 | const { hashPasswordWithSalt, checkPassword } = require('../common/util');
6 |
7 | async function login(req, res) {
8 | let username = req.body.username;
9 | let password = req.body.password;
10 | if (username) username = username.trim();
11 | if (password) password = password.trim();
12 | if (username === '' || password === '') {
13 | return res.json({ status: false, message: '无效的参数' });
14 | }
15 |
16 | let user = await User.findOne({
17 | where: {
18 | [Op.and]: [{ username }]
19 | },
20 | raw: true
21 | });
22 | if (user && checkPassword(password, user.password)) {
23 | if (!user.isBlocked) {
24 | req.session.user = user;
25 | res.json({
26 | status: true,
27 | message: 'ok',
28 | user: user
29 | });
30 | if (req.app.locals.config.message_push_api) {
31 | let url = `${req.app.locals.config.message_push_api}IP 地址为 ${req.ip} 的用户刚刚登录了你的博客网站`;
32 | axios.get(url).then(() => {});
33 | }
34 | } else {
35 | res.json({
36 | status: false,
37 | message: '用户已被封禁'
38 | });
39 | }
40 | } else {
41 | res.json({
42 | status: false,
43 | message: '无效的凭证'
44 | });
45 | }
46 | }
47 |
48 | async function logout(req, res) {
49 | req.session.user = undefined;
50 | res.json({
51 | status: true,
52 | message: '注销成功'
53 | });
54 | }
55 |
56 | async function status(req, res) {
57 | res.json({
58 | status: true,
59 | user: req.session.user
60 | });
61 | }
62 |
63 | async function refreshToken(req, res) {
64 | let uuid = uuidv4();
65 | let userId = req.session.user.id;
66 | let user = await User.findOne({
67 | where: {
68 | id: userId
69 | }
70 | })
71 | if (user) {
72 | await user.update({
73 | accessToken: uuid
74 | });
75 | res.json({
76 | status: true,
77 | message: 'ok',
78 | accessToken: uuid
79 | });
80 | } else {
81 | res.json({
82 | status: false,
83 | message: '用户不存在'
84 | });
85 | }
86 | }
87 |
88 | async function update(req, res) {
89 | const id = req.body.id;
90 | let username = req.body.username;
91 | let password = req.body.password;
92 | let displayName = req.body.displayName;
93 | let isAdmin = req.body.isAdmin;
94 | let isModerator = req.body.isModerator;
95 | let isBlocked = req.body.isBlocked;
96 | let email = req.body.email;
97 | let url = req.body.url;
98 | const avatar = req.body.avatar;
99 |
100 | let newUser = {
101 | username,
102 | displayName,
103 | isAdmin,
104 | isModerator,
105 | isBlocked,
106 | email,
107 | avatar,
108 | url
109 | };
110 |
111 | let message = 'ok';
112 | let status = false;
113 | try {
114 | let user = await User.findOne({
115 | where: {
116 | id
117 | }
118 | });
119 | if (user) {
120 | if (password) {
121 | newUser.password = hashPasswordWithSalt(password);
122 | }
123 | await user.update(newUser);
124 | }
125 | status = user !== null;
126 | } catch (e) {
127 | message = e.message;
128 | console.error(e);
129 | }
130 | res.json({ status, message });
131 | }
132 |
133 | async function get(req, res) {
134 | const id = req.params.id;
135 |
136 | let user;
137 | let status = false;
138 | let message = 'ok';
139 | try {
140 | user = await User.findOne({
141 | attributes: { exclude: ['password'] },
142 | where: {
143 | id
144 | },
145 | raw: true
146 | });
147 | status = user !== null;
148 | } catch (e) {
149 | message = e.message;
150 | }
151 | res.json({ status, message, user });
152 | }
153 |
154 | async function getAll(req, res) {
155 | let users = [];
156 | let message = 'ok';
157 | let status = true;
158 | try {
159 | users = await User.findAll({
160 | attributes: { exclude: ['password'] },
161 | raw: true
162 | });
163 | } catch (e) {
164 | status = false;
165 | message = e.message;
166 | }
167 | res.json({ status, message, users });
168 | }
169 |
170 | async function create(req, res) {
171 | const username = req.body.username;
172 | let password = req.body.password;
173 | let displayName = req.body.displayName;
174 | if (!displayName) {
175 | displayName = username;
176 | }
177 | const email = req.body.email;
178 | const url = req.body.url;
179 | let isAdmin = req.body.isAdmin;
180 | let isModerator = req.body.isModerator;
181 | let isBlocked = req.body.isBlocked;
182 | const avatar = req.body.avatar;
183 |
184 | if (!username.trim() || !password.trim()) {
185 | return res.json({
186 | status: false,
187 | message: '无效的参数'
188 | });
189 | }
190 | password = hashPasswordWithSalt(password);
191 | let message = 'ok';
192 | let user = undefined;
193 | try {
194 | user = await User.create({
195 | username,
196 | password,
197 | displayName,
198 | email,
199 | url,
200 | isAdmin,
201 | isModerator,
202 | isBlocked,
203 | avatar
204 | });
205 | } catch (e) {
206 | message = e.message;
207 | }
208 |
209 | res.json({
210 | status: user !== undefined,
211 | message
212 | });
213 | }
214 |
215 | async function delete_(req, res) {
216 | const id = req.params.id;
217 |
218 | let status = false;
219 | let message = 'ok';
220 | try {
221 | let rows = await User.destroy({
222 | where: {
223 | id
224 | }
225 | });
226 | status = rows === 1;
227 | } catch (e) {
228 | message = e.message;
229 | }
230 |
231 | res.json({ status, message });
232 | }
233 |
234 | module.exports = {
235 | login,
236 | logout,
237 | status,
238 | refreshToken,
239 | update,
240 | get,
241 | getAll,
242 | create,
243 | delete_
244 | };
245 |
--------------------------------------------------------------------------------
/data/index/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/blog/dfb2ae9d0cb5a27fca3a525633785988adbd8fcb/data/index/favicon.ico
--------------------------------------------------------------------------------
/data/index/icon192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/blog/dfb2ae9d0cb5a27fca3a525633785988adbd8fcb/data/index/icon192.png
--------------------------------------------------------------------------------
/data/index/icon512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/blog/dfb2ae9d0cb5a27fca3a525633785988adbd8fcb/data/index/icon512.png
--------------------------------------------------------------------------------
/data/index/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "LIGHTX-CMS",
3 | "short_name": "LIGHTX-CMS",
4 | "display": "standalone",
5 | "start_url": "/",
6 | "icons": [
7 | {
8 | "src": "/icon192.png",
9 | "type": "image/png",
10 | "sizes": "192x192"
11 | },
12 | {
13 | "src": "/icon512.png",
14 | "type": "image/png",
15 | "sizes": "512x512"
16 | }
17 | ],
18 | "theme_color": "#ffffff",
19 | "background_color": "#ffffff"
20 | }
--------------------------------------------------------------------------------
/data/index/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /admin/
3 |
--------------------------------------------------------------------------------
/data/upload/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
--------------------------------------------------------------------------------
/middlewares/api-auth.js:
--------------------------------------------------------------------------------
1 | const { User } = require('../models');
2 | const { Op } = require('sequelize');
3 | const bannedMessage = '用户已被封禁';
4 | const deniedMessage = '访问被拒绝';
5 |
6 | exports.tokenAuth = async (req, res, next) => {
7 | if (req.session.user) {
8 | return next();
9 | }
10 | let accessToken = req.headers['Authorization'];
11 | if (!accessToken) {
12 | accessToken = req.headers['authorization'];
13 | }
14 | if (!accessToken) {
15 | return res.json({
16 | status: false,
17 | message: '令牌为空'
18 | });
19 | }
20 | try {
21 | let user = await User.findOne({
22 | where: {
23 | [Op.and]: [{ accessToken }]
24 | },
25 | attributes: ['id', 'username', 'isBlocked'],
26 | raw: true
27 | });
28 | if (!user) {
29 | return res.json({
30 | status: false,
31 | message: '无效的令牌'
32 | });
33 | }
34 | if (user.isBlocked) {
35 | return res.json({
36 | status: false,
37 | message: bannedMessage
38 | });
39 | }
40 | req.session.user = user;
41 | req.session.authWithToken = true;
42 | return next();
43 | } catch (e) {
44 | return res.json({
45 | status: false,
46 | message: e.message
47 | })
48 | }
49 | }
50 |
51 | exports.userRequired = (req, res, next) => {
52 | if (!req.session.user) {
53 | return res.json({
54 | status: false,
55 | message: '用户未登录,或登录状态已过期'
56 | });
57 | }
58 | if (req.session.user.isBlocked) {
59 | return res.json({
60 | status: false,
61 | message: bannedMessage
62 | });
63 | }
64 | next();
65 | };
66 |
67 | exports.modRequired = (req, res, next) => {
68 | if (
69 | !req.session.user ||
70 | req.session.user.isBlocked ||
71 | !req.session.user.isModerator
72 | ) {
73 | return res.json({
74 | status: false,
75 | message:
76 | req.session.user && req.session.user.isBlocked
77 | ? bannedMessage
78 | : deniedMessage
79 | });
80 | }
81 | next();
82 | };
83 |
84 | exports.adminRequired = (req, res, next) => {
85 | if (
86 | !req.session.user ||
87 | req.session.user.isBlocked ||
88 | !req.session.user.isAdmin
89 | ) {
90 | return res.json({
91 | status: false,
92 | message:
93 | req.session.user && req.session.user.isBlocked
94 | ? bannedMessage
95 | : deniedMessage
96 | });
97 | }
98 | next();
99 | };
100 |
--------------------------------------------------------------------------------
/middlewares/upload.js:
--------------------------------------------------------------------------------
1 | const multer = require('multer');
2 | const path = require('path');
3 | const { fileExists, getDate } = require('../common/util');
4 | const uploadPath = require('../config').uploadPath;
5 |
6 | exports.upload = multer({
7 | storage: multer.diskStorage({
8 | destination: function(req, file, callback) {
9 | callback(null, uploadPath);
10 | },
11 | filename: async function(req, file, callback) {
12 | file.originalname = file.originalname.replaceAll(" ", "_");
13 | if (await fileExists(path.join(uploadPath, path.basename(file.originalname)))) {
14 | let parts = file.originalname.split('.');
15 | let extension = "";
16 | if (parts.length > 1) {
17 | extension = parts.pop();
18 | }
19 | file.id = parts.join('.') + getDate("_yyyyMMddhhmmss");
20 | if (extension) {
21 | file.id += "." + extension;
22 | }
23 | } else {
24 | file.id = file.originalname;
25 | }
26 | callback(null, file.id);
27 | }
28 | })
29 | });
30 |
--------------------------------------------------------------------------------
/models/file.js:
--------------------------------------------------------------------------------
1 | const { DataTypes, Model } = require('sequelize');
2 | const sequelize = require('../common/database');
3 |
4 | class File extends Model {}
5 |
6 | File.init(
7 | {
8 | id: {
9 | type: DataTypes.STRING,
10 | defaultValue: DataTypes.UUIDV4,
11 | primaryKey: true
12 | },
13 | description: DataTypes.TEXT,
14 | path: DataTypes.STRING,
15 | filename: DataTypes.STRING
16 | },
17 | { sequelize }
18 | );
19 |
20 | module.exports = File;
21 |
--------------------------------------------------------------------------------
/models/index.js:
--------------------------------------------------------------------------------
1 | const User = require('./user');
2 | const File = require('./file');
3 | const Option = require('./option');
4 | const Page = require('./page');
5 | const sequelize = require('../common/database');
6 | const { hashPasswordWithSalt } = require('../common/util');
7 |
8 | Page.belongsTo(User);
9 | User.hasMany(Page);
10 |
11 | async function initializeDatabase() {
12 | // The following code will cause the UserId be deleted on startup. :(
13 | // await sequelize.sync({ alter: true });
14 | await sequelize.sync();
15 | console.log('Database configured.');
16 | const isNoAdminExisted =
17 | (await User.findOne({ where: { isAdmin: true } })) === null;
18 | if (isNoAdminExisted) {
19 | console.log('No admin user existed! Creating one for you.');
20 | await User.create({
21 | username: 'admin',
22 | password: hashPasswordWithSalt('123456'),
23 | displayName: 'Administrator',
24 | isAdmin: true,
25 | isModerator: true
26 | });
27 | }
28 | await initializeOptions();
29 | }
30 |
31 | async function initializeOptions() {
32 | let plainOptions = [
33 | ['ad', ''],
34 | ['allow_comments', 'true'],
35 | ['author', '我的名字'],
36 | ['brand_image', ''],
37 | [
38 | 'code_theme',
39 | 'https://cdn.jsdelivr.net/npm/highlight.js@10.6.0/styles/solarized-light.css'
40 | ],
41 | ['copyright', ''],
42 | ['description', '站点描述信息'],
43 | ['disqus', ''],
44 | ['domain', 'www.your-domain.com'],
45 | ['extra_footer_code', ''],
46 | ['extra_footer_text', ''],
47 | ['extra_header_code', ''],
48 | ['favicon', ''],
49 | ['language', 'zh'],
50 | ['message_push_api', ''],
51 | ['motto', '我的格言'],
52 | [
53 | 'nav_links',
54 | '[{"key": "Meta","value": [{"link":"/","text":"首页"},{"link":"/archive","text":"存档"},{"link":"/page/links","text":"友链"},{"link":"/page/about","text":"关于"}]},{"key": "其他","value": [{"link":"/admin","text":"后台管理"}, {"link":"https://github.com/songquanpeng/blog","text":"源码地址"}, {"link":"/feed.xml","text":"订阅博客"}]}]'
55 | ],
56 | ['port', '3000'],
57 | ['site_name', '站点名称'],
58 | ['theme', 'bulma'],
59 | ['index_page_content', ''],
60 | ['use_cache', 'true']
61 | ];
62 | for (const option of plainOptions) {
63 | let [key, value] = option;
64 | await Option.findOrCreate({ where: { key }, defaults: { value } });
65 | }
66 | }
67 |
68 | exports.initializeDatabase = initializeDatabase;
69 | exports.User = User;
70 | exports.File = File;
71 | exports.Option = Option;
72 | exports.Page = Page;
73 |
--------------------------------------------------------------------------------
/models/option.js:
--------------------------------------------------------------------------------
1 | const { DataTypes, Model } = require('sequelize');
2 | const sequelize = require('../common/database');
3 |
4 | class Option extends Model {}
5 |
6 | Option.init(
7 | {
8 | key: {
9 | type: DataTypes.STRING,
10 | primaryKey: true
11 | },
12 | value: DataTypes.TEXT
13 | },
14 | { sequelize, timestamps: false }
15 | );
16 |
17 | module.exports = Option;
18 |
--------------------------------------------------------------------------------
/models/page.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const { DataTypes, Model } = require('sequelize');
3 | const sequelize = require('../common/database');
4 | const { PAGE_TYPES, PAGE_STATUS } = require('../common/constant');
5 |
6 | class Page extends Model {}
7 |
8 | Page.init(
9 | {
10 | id: {
11 | type: DataTypes.UUID,
12 | defaultValue: DataTypes.UUIDV4,
13 | primaryKey: true
14 | },
15 | type: {
16 | type: DataTypes.INTEGER,
17 | defaultValue: PAGE_TYPES.ARTICLE
18 | },
19 | link: {
20 | type: DataTypes.STRING,
21 | allowNull: false
22 | },
23 | pageStatus: {
24 | type: DataTypes.INTEGER,
25 | defaultValue: PAGE_STATUS.PUBLISHED
26 | },
27 | commentStatus: {
28 | type: DataTypes.INTEGER,
29 | defaultValue: 1
30 | },
31 | title: {
32 | type: DataTypes.STRING,
33 | allowNull: false
34 | },
35 | content: {
36 | type: DataTypes.TEXT,
37 | allowNull: false
38 | },
39 | tag: DataTypes.STRING,
40 | password: DataTypes.STRING,
41 | view: DataTypes.INTEGER,
42 | upVote: DataTypes.INTEGER,
43 | downVote: DataTypes.INTEGER,
44 | description: DataTypes.TEXT,
45 | updatedAt: {
46 | type: DataTypes.DATE,
47 | defaultValue: Sequelize.NOW
48 | }
49 | },
50 | { sequelize, updatedAt: false }
51 | );
52 |
53 | module.exports = Page;
54 |
--------------------------------------------------------------------------------
/models/user.js:
--------------------------------------------------------------------------------
1 | const { DataTypes, Model } = require('sequelize');
2 | const sequelize = require('../common/database');
3 |
4 | class User extends Model {}
5 |
6 | User.init(
7 | {
8 | id: {
9 | type: DataTypes.UUID,
10 | defaultValue: DataTypes.UUIDV4,
11 | primaryKey: true
12 | },
13 | username: {
14 | type: DataTypes.STRING,
15 | unique: true
16 | },
17 | displayName: {
18 | type: DataTypes.STRING,
19 | allowNull: false
20 | },
21 | password: {
22 | type: DataTypes.STRING,
23 | allowNull: false
24 | },
25 | accessToken: {
26 | type: DataTypes.UUID,
27 | defaultValue: DataTypes.UUIDV4
28 | },
29 | email: {
30 | type: DataTypes.STRING,
31 | unique: true
32 | },
33 | url: DataTypes.STRING,
34 | avatar: DataTypes.STRING,
35 | isAdmin: {
36 | type: DataTypes.BOOLEAN,
37 | defaultValue: false
38 | },
39 | isModerator: {
40 | type: DataTypes.BOOLEAN,
41 | defaultValue: false
42 | },
43 | isBlocked: {
44 | type: DataTypes.BOOLEAN,
45 | defaultValue: false
46 | }
47 | },
48 | { sequelize }
49 | );
50 |
51 | module.exports = User;
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog",
3 | "version": "0.5.13",
4 | "private": true,
5 | "scripts": {
6 | "start": "node ./app.js",
7 | "devStart": "nodemon ./app.js",
8 | "migrate": "node common/migrate.js",
9 | "build": "cd admin && npm run update",
10 | "build2": "cd admin && npm run update2"
11 | },
12 | "dependencies": {
13 | "axios": "^0.21.1",
14 | "compression": "^1.7.4",
15 | "connect-flash": "^0.1.1",
16 | "cookie-parser": "^1.4.5",
17 | "debug": "~2.6.9",
18 | "ejs": "^3.1.2",
19 | "express": "~4.17.1",
20 | "express-rate-limit": "^5.1.3",
21 | "express-session": "^1.17.1",
22 | "feed": "^4.2.2",
23 | "http-errors": "~1.6.2",
24 | "lru-cache": "^7.14.1",
25 | "marked": ">=2.0.0",
26 | "morgan": "~1.9.0",
27 | "multer": "^1.4.2",
28 | "mysql": "^2.18.1",
29 | "sanitize-html": ">=2.3.2",
30 | "sequelize": "^6.5.0",
31 | "serve-static": "^1.14.1",
32 | "sitemap": "^6.1.2",
33 | "sqlite3": "^5.0.1",
34 | "uuid": "^3.4.0"
35 | },
36 | "devDependencies": {
37 | "nodemon": "^2.0.6",
38 | "prettier": "^1.19.1"
39 | },
40 | "prettier": {
41 | "singleQuote": true
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/public/upload/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
--------------------------------------------------------------------------------
/routes/api-router.v1.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const { userRequired, adminRequired, tokenAuth } = require('../middlewares/api-auth');
4 | const { upload } = require('../middlewares/upload');
5 |
6 | const page = require('../controllers/page');
7 | const user = require('../controllers/user');
8 | const option = require('../controllers/option');
9 | const file = require('../controllers/file');
10 |
11 | router.post('/page/search', userRequired, page.search);
12 | router.post('/page', tokenAuth, userRequired, page.create);
13 | router.get('/page', userRequired, page.getAll);
14 | router.get('/page/export/:id', userRequired, page.export_);
15 | router.get('/page/render/:id', page.getRenderedPage);
16 | router.get('/page/:id', userRequired, page.get);
17 | router.put('/page', userRequired, page.update);
18 | router.delete('/page/:id', userRequired, page.delete_);
19 |
20 | router.post('/user/login', user.login);
21 | router.get('/user/logout', user.logout);
22 | router.get('/user/status', userRequired, user.status);
23 | router.post('/user/refresh_token', userRequired, user.refreshToken);
24 | router.put('/user', adminRequired, user.update);
25 | router.get('/user', adminRequired, user.getAll);
26 | router.get('/user/:id', adminRequired, user.get);
27 | router.post('/user', adminRequired, user.create);
28 | router.delete('/user/:id', adminRequired, user.delete_);
29 |
30 | router.get('/option', adminRequired, option.getAll);
31 | router.get('/option/shutdown', adminRequired, option.shutdown);
32 | router.get('/option/:name', adminRequired, option.get);
33 | router.put('/option', adminRequired, option.update);
34 |
35 | router.get('/file', adminRequired, file.getAll);
36 | router.post('/file', adminRequired, upload.single('file'), file.upload);
37 | router.get('/file/:id', adminRequired, file.get);
38 | router.delete('/file/:id', adminRequired, file.delete_);
39 | router.post('/file/search', adminRequired, file.search);
40 |
41 | module.exports = router;
42 |
--------------------------------------------------------------------------------
/routes/web-router.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 |
4 | const index = require('../controllers/index');
5 |
6 | router.get('/', index.getIndex);
7 | router.get('/archive', index.getArchive);
8 | router.get('/archive/:year/:month', index.getMonthArchive);
9 | router.get('/sitemap.xml', index.getSitemap);
10 | router.get('/tag/:tag', index.getTag);
11 | router.get('/page/:link', index.getPage);
12 | router.get(/static\/.*/, index.getStaticFile);
13 |
14 | module.exports = router;
15 |
--------------------------------------------------------------------------------
/themes/bulma/archive.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 |
4 |
存档
5 |
6 |
7 |
8 | 标题
9 | 发布时间
10 | 编辑时间
11 |
12 |
13 |
14 | <% pages.forEach(page => { %>
15 |
16 |
17 |
18 | <%= page.title %>
19 |
20 |
21 |
22 |
23 | <%= page.createdAt.substring(0, 10) %>
24 |
25 |
26 |
27 |
28 | <%= page.updatedAt.substring(0, 10) %>
29 |
30 |
31 |
32 | <% }) %>
33 |
34 |
35 |
36 |
37 | <%- include('./partials/footer') %>
38 |
--------------------------------------------------------------------------------
/themes/bulma/article.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 |
4 |
5 |
6 |
7 | <%- include('./partials/page-info') %>
8 | <% if(page.password) { %>
9 |
10 |
本篇文章被密码保护,请输入访问密码:
11 |
12 |
13 |
14 |
15 |
16 |
18 | 提交
19 |
20 |
21 |
22 |
23 | <% } else { %>
24 | <%- page.converted_content %>
25 | <% } %>
26 | Links: <%- page.link %>
27 | <%- config.copyright %>
28 | <%- config.ad %>
29 |
30 |
31 |
32 |
51 |
52 |
53 | <%- include('./partials/prev-next') %>
54 | <%- include('./partials/comment') %>
55 |
56 |
57 | <% if(!page.password) { %>
58 |
63 | <% } %>
64 |
65 | <%- include('./partials/footer') %>
66 |
--------------------------------------------------------------------------------
/themes/bulma/code.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 |
4 | <%- include('./partials/page-info') %>
5 |
6 | Focus
7 | Copy
8 |
9 |
10 | <%=page.content%>
11 |
12 | <%- config.copyright %>
13 | <%- config.ad %>
14 |
15 |
16 |
17 | <%- include('./partials/prev-next') %>
18 | <%- include('./partials/comment') %>
19 |
20 |
21 |
50 |
51 |
52 | <%- include('./partials/footer') %>
53 |
--------------------------------------------------------------------------------
/themes/bulma/discuss.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 | <%- include('./partials/page-info') %>
4 |
5 | <%-page.converted_content%>
6 |
7 | <%- include('./partials/comment') %>
8 | <%- include('./partials/prev-next') %>
9 |
10 |
11 | <%- include('./partials/footer') %>
12 |
--------------------------------------------------------------------------------
/themes/bulma/index.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 |
4 |
5 | <% if (pages.length){ %>
6 | <% pages.forEach((page)=>{ %>
7 |
8 |
9 |
10 |
11 |
16 | <% page.tag.trim().split(";").forEach(function (tag) { if(tag !== ""){ %>
17 |
<%= tag %>
18 | <% }}); %>
19 |
<%= page.createdAt.substring(0, 10) %>
21 |
<%- page.view %> views
22 |
23 | <%= page.description ? page.description : "无描述信息" %>
24 |
25 |
26 |
27 |
28 |
29 | <% })} %>
30 |
31 | <%- include('./partials/paginator') %>
32 |
33 |
34 |
35 |
38 |
39 |
40 | <%= config.description %>
41 | <% if(config.brand_image) { %>
42 |
43 | <% }%>
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | <%- notice %>
55 |
56 |
57 |
58 |
59 |
60 | <%- include('./partials/footer') %>
61 |
--------------------------------------------------------------------------------
/themes/bulma/links.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 |
4 | <%- include('./partials/page-info') %>
5 |
6 |
7 | <% if (linkList && linkList.length){ %>
8 | <% linkList.forEach((link)=>{ %>
9 |
10 | <% if(link && link.image === ''){
11 | if (link.link.endsWith('/')) {
12 | link.image = link.link + 'favicon.ico';
13 | } else {
14 | link.image = link.link + '/favicon.ico';
15 | }
16 | } %>
17 |
33 |
34 |
35 | <% });} %>
36 | <%- include('./partials/prev-next') %>
37 | <%- include('./partials/comment') %>
38 |
39 |
74 |
75 | <%- include('./partials/footer') %>
76 |
--------------------------------------------------------------------------------
/themes/bulma/list.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 |
<%= title %>
4 |
5 |
6 |
7 | 标题
8 | 发布时间
9 | 编辑时间
10 |
11 |
12 |
13 | <% pages.sort((a,b) => (a.createdAt > b.createdAt) ? 1 : ((b.createdAt > a.createdAt) ? -1 : 0)).forEach(page => { %>
14 |
15 |
16 |
17 | <%= page.title %>
18 |
19 |
20 |
21 |
22 | <%= page.createdAt.substring(0, 10) %>
23 |
24 |
25 |
26 |
27 | <%= page.updatedAt.substring(0, 10) %>
28 |
29 |
30 |
31 | <% }) %>
32 |
33 |
34 |
35 |
36 | <%- include('./partials/footer') %>
37 |
--------------------------------------------------------------------------------
/themes/bulma/message.ejs:
--------------------------------------------------------------------------------
1 | <%- include('./partials/header') %>
2 |
3 | <%= message %>
4 |
5 | <%- include('./partials/footer') %>
6 |
--------------------------------------------------------------------------------
/themes/bulma/partials/comment.ejs:
--------------------------------------------------------------------------------
1 | <% if(config.allow_comments === 'true' && page.commentStatus === 1){ %>
2 | <% if(config.disqus !== undefined && config.disqus !== "") { %>
3 |
4 |
17 | <% } %>
18 | <% } %>
19 |
--------------------------------------------------------------------------------
/themes/bulma/partials/footer.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
17 | <%- config.extra_footer_code %>
18 |