├── .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 | license 17 | 18 | 19 | release 20 | 21 | 22 | release 23 | 24 | 25 | docker pull 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 | license 16 | 17 | 18 | release 19 | 20 | 21 | release 22 | 23 | 24 | docker pull 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 | ![桌面端首页](https://user-images.githubusercontent.com/39998050/108320215-76e02e00-71fd-11eb-8ecc-caeff90eb0da.png) 70 | ![后台管理页面文章列表页面](https://user-images.githubusercontent.com/39998050/108320192-6f208980-71fd-11eb-8e3d-92e61dce09e6.png) 71 | ![编辑器页面](https://user-images.githubusercontent.com/39998050/108320168-6465f480-71fd-11eb-8abd-f74588d9e39a.png) 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 | 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 | 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 |
100 | 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 |
573 | 574 | 链接 575 | 581 | 密码 582 | 588 | 类型 589 | 689 | 颜色主题 690 | 73 | 74 | 75 | 85 | 86 | 87 | 88 | {/**/} 89 | {/* Remember me*/} 90 | {/**/} 91 | 92 | 93 | 94 | 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 | 170 | 171 | this.deletePage(record.id)} 175 | okText="确认" 176 | cancelText="取消" 177 | > 178 | 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 | { 231 | this.updateOption(setting.key, e.target.value); 232 | }} 233 | /> 234 | )} 235 | 236 | ); 237 | })} 238 | 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 | 69 | this.deleteUser(record.id)} 73 | okText="确认" 74 | cancelText="取消" 75 | > 76 | 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 | 185 | 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 | 21 | 26 | 31 | 32 | <% }) %> 33 | 34 |
标题发布时间编辑时间
17 | 18 | <%= page.title %> 19 | 20 | 22 | 25 | 27 | 30 |
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 | 20 |
21 |
22 |
23 | <% } else { %> 24 | <%- page.converted_content %> 25 | <% } %> 26 | 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 | 7 | 8 |
9 |
10 |         <%=page.content%>
11 |     
12 | <%- config.copyright %> 13 | <%- config.ad %> 14 | 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 | 28 |
29 | <% })} %> 30 |
31 | <%- include('./partials/paginator') %> 32 |
33 |
34 |
35 |

36 | <%= config.site_name %> 37 |

38 |
39 |
40 | <%= config.description %> 41 | <% if(config.brand_image) { %> 42 | brand_image 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 | 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 | 20 | 25 | 30 | 31 | <% }) %> 32 | 33 |
标题发布时间编辑时间
16 | 17 | <%= page.title %> 18 | 19 | 21 | 24 | 26 | 29 |
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 | 19 | 20 | -------------------------------------------------------------------------------- /themes/bulma/partials/header.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%if(page !== undefined){%> 7 | <%= page.title %> 8 | 9 | <%} else{%> 10 | <%= config.title %> 11 | 12 | <%}%> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <%- config.extra_header_code %> 23 | 24 | 25 | 38 | 39 |
40 | <%- include('./nav') %> 41 |
42 |
-------------------------------------------------------------------------------- /themes/bulma/partials/nav.ejs: -------------------------------------------------------------------------------- 1 | 66 | -------------------------------------------------------------------------------- /themes/bulma/partials/page-info.ejs: -------------------------------------------------------------------------------- 1 |

<%-page.title%>

2 |
3 | 4 | 标签: 5 | <% page.tag.trim().split(";").forEach(function (tag) {if (tag !== "") {%> 6 | <%= tag %> 7 | <% }}); %> 8 | 9 | 发布于:<%-page.createdAt%> 10 | 编辑于:<%-page.updatedAt%> 11 | 浏览量:<%-page.view%> 12 |
-------------------------------------------------------------------------------- /themes/bulma/partials/paginator.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /themes/bulma/partials/prev-next.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /themes/bulma/raw.ejs: -------------------------------------------------------------------------------- 1 | <%- include('./partials/header') %> 2 |
3 | <%- include('./partials/page-info') %> 4 |
5 | <%- page.content %> 6 | <%- config.copyright %> 7 | <%- config.ad %> 8 |
9 | <%- include('./partials/prev-next') %> 10 | <%- include('./partials/comment') %> 11 |
12 | 13 | 14 | <%- include('./partials/footer') %> 15 | -------------------------------------------------------------------------------- /themes/bulma/static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Verdana, Candara, Arial, Helvetica, Microsoft YaHei, sans-serif; 3 | line-height: 1.6; 4 | margin: 0; 5 | } 6 | 7 | nav { 8 | margin-bottom: 16px; 9 | } 10 | 11 | a { 12 | text-decoration: none; 13 | color: #007bff; 14 | } 15 | 16 | a:hover { 17 | text-decoration: none !important; 18 | color: #007bff; 19 | } 20 | 21 | .page-card-title a { 22 | color: #368CCB; 23 | text-decoration: none; 24 | } 25 | 26 | .page-card-title a:hover { 27 | color: #368CCB; 28 | text-decoration: none; 29 | } 30 | 31 | .wrapper { 32 | max-width: 960px; 33 | margin: 0 auto; 34 | } 35 | 36 | #page-container { 37 | position: relative; 38 | min-height: 97vh; 39 | } 40 | 41 | #content-wrap { 42 | padding-bottom: 4rem; 43 | } 44 | 45 | #footer { 46 | height: 4rem; 47 | } 48 | 49 | #footer a { 50 | /*color: black;*/ 51 | } 52 | 53 | code { 54 | font-family: Consolas, 'Courier New', monospace; 55 | } 56 | 57 | .page-card-list { 58 | margin: 8px 8px; 59 | } 60 | 61 | .page-card-title { 62 | font-size: x-large; 63 | font-weight: 500; 64 | color: #000000; 65 | text-decoration: none; 66 | margin-bottom: 4px; 67 | } 68 | 69 | .page-card-text { 70 | margin-top: 16px; 71 | } 72 | 73 | .pagination { 74 | margin: 16px 4px; 75 | } 76 | 77 | .pagination a { 78 | border: none; 79 | overflow: hidden; 80 | } 81 | 82 | .shadow { 83 | box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, .1), 0 0 0 1px rgba(10, 10, 10, .02); 84 | } 85 | 86 | .nav-shadow { 87 | box-shadow: 0 2px 3px rgba(26, 26, 26, .1); 88 | } 89 | 90 | .paginator div { 91 | border: 2px solid #000; 92 | cursor: pointer; 93 | display: inline-block; 94 | min-width: 100px; 95 | text-align: center; 96 | font-weight: bold; 97 | padding: 10px; 98 | } 99 | 100 | .box article { 101 | overflow-wrap: break-word; 102 | /*font-size: larger;*/ 103 | word-break: break-word; 104 | line-height: 1.6; 105 | padding: 16px; 106 | /*margin-bottom: 16px;*/ 107 | background-color: #ffffff; 108 | } 109 | 110 | .toc-level-1 { 111 | list-style-type: none; 112 | } 113 | 114 | .toc-level-2 { 115 | list-style-type: none; 116 | } 117 | 118 | .toc-level-3 { 119 | list-style-type: disc; 120 | } 121 | 122 | .toc-level-4 { 123 | list-style-type: circle; 124 | } 125 | 126 | .toc-level-5 { 127 | list-style-type: square; 128 | } 129 | 130 | .toc-level-6 { 131 | list-style-type: square; 132 | } 133 | 134 | img { 135 | max-width: 100%; 136 | max-height: 100%; 137 | } 138 | 139 | .article-container { 140 | margin: auto; 141 | max-width: 960px; 142 | padding: 16px 16px; 143 | overflow-wrap: break-word; 144 | word-break: break-word; 145 | line-height: 1.6; 146 | /*font-size: larger;*/ 147 | } 148 | 149 | article { 150 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 151 | font-size: 16px; 152 | line-height: 1.5; 153 | word-wrap: break-word; 154 | color: #24292f; 155 | } 156 | 157 | article p, article blockquote, article ul, article ol, article dl, article table, article pre, article details { 158 | margin-top: 0; 159 | margin-bottom: 16px; 160 | } 161 | 162 | article ul, article ol { 163 | padding-left: 2em; 164 | } 165 | 166 | article ul ul, article ul ol, article ol ol, article ol ul { 167 | margin-top: 0; 168 | margin-bottom: 0; 169 | } 170 | 171 | article .tag { 172 | font-family: Verdana, Candara, Arial, Helvetica, Microsoft YaHei, sans-serif; 173 | } 174 | 175 | article a { 176 | color: #007bff; 177 | text-decoration: none; 178 | } 179 | 180 | article a:hover { 181 | color: #007bff; 182 | text-decoration: none; 183 | } 184 | 185 | article h2, 186 | article h3, 187 | article h4, 188 | article h5, 189 | article h6 { 190 | margin-top: 24px; 191 | margin-bottom: 16px; 192 | font-weight: 600; 193 | line-height: 1.5; 194 | margin-block-start: 1em; 195 | margin-block-end: 0.2em; 196 | } 197 | 198 | article h1 { 199 | font-size: 2em 200 | } 201 | 202 | article h2 { 203 | padding-bottom: 0.3em; 204 | font-size: 1.5em; 205 | } 206 | 207 | article h3 { 208 | font-size: 1.25em 209 | } 210 | 211 | article h4 { 212 | font-size: 1.25em; 213 | } 214 | 215 | article h5 { 216 | font-size: 1.1em; 217 | } 218 | 219 | article h6 { 220 | font-size: 1em; 221 | font-weight: bold 222 | } 223 | 224 | @media screen and (max-width: 960px) { 225 | article h1 { 226 | font-size: 1.5em 227 | } 228 | 229 | article h2 { 230 | font-size: 1.35em 231 | } 232 | 233 | article h3 { 234 | font-size: 1.3em 235 | } 236 | 237 | article h4 { 238 | font-size: 1.2em; 239 | } 240 | } 241 | 242 | article p { 243 | margin-top: 0; 244 | margin-bottom: 16px; 245 | } 246 | 247 | article table { 248 | margin: auto; 249 | border-collapse: collapse; 250 | border-spacing: 0; 251 | vertical-align: middle; 252 | text-align: left; 253 | min-width: 66%; 254 | } 255 | 256 | article table td, 257 | article table th { 258 | padding: 5px 8px; 259 | border: 1px solid #bbb; 260 | } 261 | 262 | article blockquote { 263 | margin-left: 0; 264 | padding: 0 1em; 265 | border-left: 0.25em solid #ddd; 266 | } 267 | 268 | article ol ul { 269 | list-style-type: circle; 270 | } 271 | 272 | article pre { 273 | max-width: 960px; 274 | display: block; 275 | overflow: auto; 276 | padding: 0; 277 | margin-top: 12px; 278 | margin-bottom: 12px; 279 | border-radius: 6px; 280 | } 281 | 282 | article pre code { 283 | font-size: 14px; 284 | } 285 | 286 | article ol { 287 | text-decoration: none; 288 | padding-inline-start: 40px; 289 | margin-bottom: 1.25rem; 290 | padding-left: 2em; 291 | } 292 | 293 | article ul { 294 | padding-left: 2em; 295 | } 296 | 297 | article li + li { 298 | margin-top: 0.25em; 299 | } 300 | 301 | code { 302 | font-family: "JetBrains Mono", "Cascadia Code", Consolas, Microsoft YaHei, monospace; 303 | } 304 | 305 | article code { 306 | color: #24292f; 307 | background-color: rgb(175 184 193 / 20%); 308 | padding: .065em .4em; 309 | border-radius: 6px; 310 | font-family: "JetBrains Mono", "Cascadia Code", Consolas, Microsoft YaHei, monospace; 311 | } 312 | 313 | article .copyright { 314 | display: none; 315 | } 316 | 317 | .info { 318 | font-size: 14px; 319 | line-height: 28px; 320 | text-align: left; 321 | color: #738292; 322 | margin-bottom: 24px; 323 | } 324 | 325 | .info a { 326 | text-decoration: none; 327 | color: inherit; 328 | } 329 | 330 | /* Code Page Style*/ 331 | .code-page { 332 | margin-top: 32px; 333 | padding-left: 16px; 334 | padding-right: 16px; 335 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 336 | } 337 | 338 | .code-page code { 339 | font-size: 16px; 340 | width: 100%; 341 | height: 100%; 342 | } 343 | 344 | .code-page pre { 345 | margin-top: 2px; 346 | overflow-x: auto; 347 | padding: 0; 348 | font-size: 16px; 349 | background-color: rgba(0, 0, 0, 0); 350 | } 351 | 352 | .code-page .control-panel { 353 | width: 100%; 354 | } 355 | 356 | #code-display { 357 | padding: 16px 24px; 358 | } 359 | 360 | .discuss h1 { 361 | font-size: 24px; 362 | line-height: 36px; 363 | text-align: left; 364 | } 365 | 366 | .discuss .time { 367 | font-size: 12px; 368 | line-height: 18px; 369 | text-align: left; 370 | color: #738292; 371 | } 372 | 373 | .discuss .content { 374 | font-size: 16px; 375 | line-height: 24px; 376 | text-align: left; 377 | } 378 | 379 | .raw { 380 | padding: 16px; 381 | } 382 | 383 | .raw .raw-content { 384 | overflow-y: hidden; 385 | overflow-x: scroll; 386 | } 387 | 388 | .links { 389 | margin: 16px; 390 | } 391 | 392 | span.line { 393 | display: inline-block; 394 | } 395 | 396 | .toc { 397 | position: sticky; 398 | top: 24px; 399 | } -------------------------------------------------------------------------------- /themes/bulma/static/main.js: -------------------------------------------------------------------------------- 1 | function generateTOC() { 2 | const titles = getTitles(); 3 | insertTOC(titles); 4 | } 5 | 6 | function getTitles() { 7 | const article = document.getElementById('article'); 8 | const nodes = ['H2']; 9 | let titles = []; 10 | let count = 0; 11 | article.childNodes.forEach(function(e, i) { 12 | if (nodes.includes(e.nodeName)) { 13 | const id = 'h' + count++; 14 | e.setAttribute('id', id); 15 | titles.push({ 16 | id: id, 17 | text: e.innerHTML, 18 | level: Number(e.nodeName.substring(1, 2)), 19 | nodeName: e.nodeName 20 | }); 21 | } 22 | }); 23 | return titles; 24 | } 25 | 26 | function insertTOC(titles) { 27 | const toc = document.getElementById('toc'); 28 | for (let i = 0; i < titles.length; i++) { 29 | let title = titles[i]; 30 | let template = `
  • ${title.text}
  • `; 31 | toc.insertAdjacentHTML('beforeend', template); 32 | } 33 | if (titles.length === 0) { 34 | let tocContainer = document.getElementById('toc-container'); 35 | if (tocContainer) { 36 | tocContainer.style.display = 'none'; 37 | } 38 | } 39 | } 40 | 41 | async function submitArticlePassword(postId, passwordInputId, labelId, anchorId) { 42 | let password = document.getElementById(passwordInputId).value; 43 | if (!password) return; 44 | let res = await fetch(`/api/page/render/${postId}?password=${password}`); 45 | let data = await res.json(); 46 | if (data.status) { 47 | document.getElementById(anchorId).style.display = 'none'; 48 | document.getElementById(anchorId).insertAdjacentHTML('beforebegin', data.content); 49 | generateTOC(); 50 | } else { 51 | document.getElementById(labelId).innerText = "密码错误,请重试!"; 52 | document.getElementById(passwordInputId).value = ''; 53 | } 54 | } 55 | --------------------------------------------------------------------------------