├── .prettierignore ├── .eslintignore ├── src ├── styles │ ├── states.sass │ ├── index.sass │ ├── variables.sass │ ├── post-content.sass │ ├── highlight.sass │ ├── formats.sass │ └── elements.sass ├── types │ ├── index.ts │ ├── ApiResponse.ts │ └── Database.ts ├── assets │ └── logo.png ├── utils │ ├── getErrMsg.ts │ ├── index.ts │ ├── setTitle.ts │ ├── getAvatar.ts │ ├── globalStates.ts │ ├── userData.ts │ ├── siteCache.ts │ └── FileUploader.ts ├── components │ ├── DateString.vue │ ├── ExternalLink.vue │ ├── UserLink.vue │ ├── Placeholder.vue │ ├── NProgress.vue │ ├── Lazyload.vue │ ├── GlobalAside.vue │ ├── GlobalPlaceholder.vue │ ├── GlobalFooter.vue │ ├── CommentEdit.vue │ ├── GlobalSideNav.vue │ ├── GlobalHeader.vue │ ├── QuickEdit.vue │ ├── AuthorCard.vue │ ├── CommentList.vue │ ├── PostList.vue │ ├── FloatToolbox.vue │ └── GlobalHeaderUserDropdown.vue ├── view │ ├── 404.vue │ ├── archives.vue │ ├── auth.vue │ ├── index.vue │ ├── user.vue │ ├── edit-post.vue │ └── post.vue ├── config.ts ├── App.vue ├── vue-app-env.d.ts ├── main.ts └── router.ts ├── public ├── favicon.ico └── images │ ├── wordpress.svg │ └── spinner.svg ├── .prettierrc.yml ├── vite.config.ts ├── index.html ├── tsconfig.json ├── .eslintrc.yml ├── _config.sample.yml ├── .vscode ├── vue.code-snippets └── serverless.code-snippets ├── api ├── config.ts ├── index.ts ├── comment.ts ├── utils.ts ├── post.ts └── user.ts ├── LICENSE ├── vercel.json ├── package.json ├── .gitignore └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.dev.* 2 | dist 3 | *.d.ts 4 | -------------------------------------------------------------------------------- /src/styles/states.sass: -------------------------------------------------------------------------------- 1 | .lock-scroll 2 | overflow: hidden 3 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ApiResponse' 2 | export * from './Database' 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeNowOrg/BlogNow/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeNowOrg/BlogNow/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: 'es5' 2 | tabWidth: 2 3 | semi: false 4 | singleQuote: true 5 | arrowParens: always 6 | quoteProps: as-needed 7 | -------------------------------------------------------------------------------- /src/utils/getErrMsg.ts: -------------------------------------------------------------------------------- 1 | export function getErrMsg(err: any): string { 2 | return err?.response?.data?.message || err.message || 'HTTP Timeout' 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getAvatar' 2 | export * from './getErrMsg' 3 | export * from './globalStates' 4 | export * from './setTitle' 5 | export * from './siteCache' 6 | export * from './userData' 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | build: { 7 | sourcemap: true, 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /src/components/DateString.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/utils/setTitle.ts: -------------------------------------------------------------------------------- 1 | // import { PROJECT_NAME } from '../config' 2 | 3 | export function setTitle(...title: string[]) { 4 | title = title || [] 5 | title.push('Blog Now') 6 | document.title = title.join(' | ') 7 | return document.title 8 | } 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Blog Now 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/view/404.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 23 | -------------------------------------------------------------------------------- /src/styles/index.sass: -------------------------------------------------------------------------------- 1 | @import variables.sass 2 | @import elements.sass 3 | @import formats.sass 4 | @import states.sass 5 | @import highlight.sass 6 | @import post-content.sass 7 | 8 | html, 9 | body 10 | margin: 0 11 | padding: 0 12 | position: relative 13 | 14 | * 15 | box-sizing: border-box 16 | 17 | #app 18 | font-family: Avenir, Helvetica, Arial, sans-serif 19 | -webkit-font-smoothing: antialiased 20 | -moz-osx-font-smoothing: grayscale 21 | color: var(--theme-text-color) 22 | 23 | ::selection 24 | background-color: rgba(var(--theme-accent-color--rgb), 0.5) -------------------------------------------------------------------------------- /src/utils/getAvatar.ts: -------------------------------------------------------------------------------- 1 | export function getAvatar( 2 | url?: string, 3 | options: { 4 | width?: number 5 | preset?: 6 | | '404' 7 | | 'mm' 8 | | 'identicon' 9 | | 'monsterid' 10 | | 'wavatar' 11 | | 'retro' 12 | | 'blank' 13 | restrict?: 'g' | 'pg' | 'r' | 'x' 14 | } = {} 15 | ): string { 16 | url = url || 'https://gravatar.loli.net/avatar/' 17 | return `${url}?${new URLSearchParams({ 18 | s: '' + (options.width || 120), 19 | d: options.preset || 'identicon', 20 | r: options.restrict || 'g', 21 | })}` 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "sourceMap": true, 9 | "lib": ["esnext", "dom"], 10 | "plugins": [{ "name": "@vuedx/typescript-plugin-vue" }], 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts", 17 | "src/**/*.d.ts", 18 | "src/**/*.tsx", 19 | "src/**/*.vue", 20 | "api/**/*.d.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ExternalLink.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | commonjs: true 4 | es2021: true 5 | parser: 6 | vue-eslint-parser 7 | plugins: 8 | - '@typescript-eslint' 9 | extends: 10 | - 'eslint:recommended' 11 | - 'plugin:@typescript-eslint/recommended' 12 | - 'plugin:vue/vue3-recommended' 13 | - 'prettier' 14 | parserOptions: 15 | parser: '@typescript-eslint/parser' 16 | extraFileExtensions: 17 | - .vue 18 | ecmaVersion: 12 19 | rules: 20 | indent: ['error', 2, { SwitchCase: 1 }] 21 | quotes: ['error', 'single'] 22 | semi: ['error', 'never'] 23 | # 烦死了 24 | '@typescript-eslint/no-explicit-any': 0 25 | '@typescript-eslint/explicit-module-boundary-types': 0 -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../package.json' 2 | 3 | export const SITE_ENV = process.env.NODE_ENV === 'development' ? 'dev' : 'prod' 4 | export const API_BASE = 5 | SITE_ENV === 'prod' ? '/api' : 'https://blog-now.vercel.app/api' 6 | export const PROJECT_NAME = 'Blog Now' 7 | export const VERSION = version 8 | 9 | // Copyright 10 | export const GITHUB_OWNER = 'FreeNowOrg' 11 | export const GITHUB_REPO = 'BlogNow' 12 | export const GITHUB_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}` 13 | const year = new Date().getFullYear() 14 | export const COPYRIGHT_YEAR = 2021 15 | export const COPYRIGHT_STR = 16 | year === COPYRIGHT_YEAR ? COPYRIGHT_YEAR : `${COPYRIGHT_YEAR} - ${year}` 17 | -------------------------------------------------------------------------------- /src/components/UserLink.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 29 | -------------------------------------------------------------------------------- /src/types/ApiResponse.ts: -------------------------------------------------------------------------------- 1 | import { DbCommentDoc } from '.' 2 | import { DbPostDoc, DbUserDoc } from './Database' 3 | 4 | export type ApiResponse< 5 | BODY extends unknown, 6 | CUSTOM_BODY extends unknown = {} 7 | > = { 8 | status: number 9 | message: string 10 | body: BODY 11 | } & CUSTOM_BODY 12 | 13 | export interface ApiAttachedUser { 14 | author: ApiResponseUser 15 | editor: ApiResponseUser 16 | } 17 | 18 | export type ApiResponseComment = DbCommentDoc & ApiAttachedUser 19 | export type ApiResponseCommentList = { 20 | comments: ApiResponseComment[] 21 | total_comments: number 22 | limit: number 23 | offset: number 24 | has_next: number 25 | } 26 | 27 | export type ApiResponsePost = DbPostDoc & 28 | ApiAttachedUser & { 29 | cover: string 30 | } 31 | 32 | export type ApiResponseUser = DbUserDoc & { not_exist?: true } 33 | -------------------------------------------------------------------------------- /src/styles/variables.sass: -------------------------------------------------------------------------------- 1 | :root 2 | font-size: 16px 3 | --theme-accent-color: rgb(106, 112, 215) 4 | --theme-accent-color--rgb: 106, 112, 215 5 | --theme-accent-color-darken: rgb(89, 85, 198) 6 | --theme-accent-link-color: rgb(255, 255, 255) 7 | --theme-secondary-color: rgb(224, 32, 128) 8 | --theme-secondary-color--rgb: 224, 32, 128 9 | --theme-text-color: rgb(44, 62, 80) 10 | --theme-link-color: rgb(63, 81, 181) 11 | --theme-link-color--rgb: 63, 81, 181 12 | --theme-background-color: #f4f9ff 13 | --theme-text-shadow-color: #fff 14 | --theme-box-shadow-color: #ddd 15 | --theme-box-shadow-color-hover: #b8b8b8 16 | --theme-border-color: #888 17 | --theme-box-shadow: 0 0 8px var(--theme-box-shadow-color) 18 | --theme-box-shadow-hover: 0 0 14px var(--theme-box-shadow-color-hover) 19 | --theme-tag-color: rgb(214, 228, 255) 20 | --theme-danger-color: #f55 21 | --theme-bookmark-color: #ff69b4 22 | -------------------------------------------------------------------------------- /src/components/Placeholder.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 41 | -------------------------------------------------------------------------------- /_config.sample.yml: -------------------------------------------------------------------------------- 1 | # 设定保存的方式暂时未确定,可能会变更 2 | 3 | # Site meta 4 | site_mame: Blog Now 5 | site_desc: This is a blog built by BlogNow engine. 6 | site_logo: https://www.wjghj.cn/public/icons/wiki-wordmark.svg 7 | 8 | # Top nav links & Side nav links 9 | site_menu: 10 | - text: Home 11 | link: / 12 | - text: Posts 13 | children: 14 | - text: Archives 15 | link: /archives 16 | - text: Tags 17 | link: /tags 18 | - text: About 19 | link: /-/about 20 | 21 | # Footer 22 | footer_top: global footer 23 | footer_bottom: Powered by BlogNow 24 | 25 | # Scheme 26 | link_scheme: 27 | post: /post/:uuid 28 | post_pid: /pid/:pid 29 | post_slug: /-/:slug 30 | archives: /archives/:year/:month/:day 31 | user: /user/:uuid 32 | user_self: /user/@me 33 | 34 | # Custom 35 | insert_head_end: # e.g. 36 | insert_body_end: # e.g. 37 | -------------------------------------------------------------------------------- /.vscode/vue.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | "Init vue components": { 9 | "scope": "vue", 10 | "prefix": "vue", 11 | "body": [ 12 | "", 15 | "", 16 | "", 20 | "", 21 | "" 22 | ], 23 | "description": "Init vue components" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { router } from './utils' 3 | import { VercelRequest, VercelResponse } from '@vercel/node' 4 | 5 | router.endpoint('/api/config') 6 | 7 | export const COLNAME = { 8 | COMMENT: 'comments', 9 | CONFIG: 'config', 10 | LOG: 'logs', 11 | USER: 'users', 12 | POST: 'posts', 13 | TAG: 'tags', 14 | } 15 | 16 | const CONFIG_DEFAULTS = [ 17 | { key: 'siteName', val: 'Blog Now' }, 18 | { key: 'siteDesc', val: 'My new blog!' }, 19 | ] 20 | 21 | export function getLocalConfig(key: string) { 22 | let fileJSON: Record = {} 23 | try { 24 | const file = readFileSync('blognow.config.json') 25 | fileJSON = JSON.parse(file.toString()) 26 | } catch (e) { 27 | console.warn('Can not find local config file') 28 | } 29 | return process.env[key.toUpperCase()] || fileJSON[key] || null 30 | } 31 | 32 | export default (req: VercelRequest, res: VercelResponse) => { 33 | router.endpoint('/api/config') 34 | router.setCollection(COLNAME.CONFIG) 35 | 36 | return router.init(req, res) 37 | } 38 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | 31 | 41 | -------------------------------------------------------------------------------- /src/components/NProgress.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 38 | 39 | 49 | -------------------------------------------------------------------------------- /src/utils/globalStates.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { computed, ref } from 'vue' 3 | import { getErrMsg, userData } from '.' 4 | import { API_BASE } from '../config' 5 | import type { DbPostDoc } from '../types/Database' 6 | 7 | // Site meta 8 | export interface SiteMetaType { 9 | total_posts: number 10 | total_users: number 11 | total_tags: number 12 | founded_at: string 13 | latest_post: Partial 14 | } 15 | export const siteMeta = ref(null) 16 | export async function getSiteMeta(): Promise { 17 | return axios.get(`${API_BASE}/site/meta`).then( 18 | ({ data }: any) => { 19 | const meta = data.body.meta 20 | siteMeta.value = meta 21 | return meta 22 | }, 23 | (e) => { 24 | const content = getErrMsg(e) 25 | globalInitErrors.value.push({ title: 'Failed to get site meta', content }) 26 | return null 27 | } 28 | ) 29 | } 30 | 31 | export const globalInitDone = computed( 32 | () => !!(userData.value.uuid !== undefined && siteMeta.value) 33 | ) 34 | export const globalInitErrors = ref<{ title?: string; content: string }[]>([]) 35 | -------------------------------------------------------------------------------- /src/styles/post-content.sass: -------------------------------------------------------------------------------- 1 | #post-content, 2 | #edit-post-container .v-md-editor__preview-wrapper 3 | img 4 | max-width: 100% 5 | blockquote 6 | margin: 0.2rem 0 7 | padding: 0.5rem 0.5rem 0.5rem 1rem 8 | border-left: 4px solid #ccc 9 | color: #888 10 | p 11 | margin: 0.2rem 12 | 13 | #edit-post-container .v-md-editor__preview-wrapper 14 | padding: 1rem 15 | font-size: 14px 16 | 17 | // Headers 18 | #post-content, 19 | #edit-post-container .v-md-editor__preview-wrapper, 20 | .site-style 21 | h1 22 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.5), 75%) 23 | margin-top: 1rem 24 | margin-bottom: 1rem 25 | padding: 0 0.4rem 26 | border: none 27 | 28 | h2 29 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.35), 50%) 30 | margin: 0.4rem 0 31 | padding: 0 0.4rem 32 | 33 | h3 34 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.25), 35%) 35 | margin: 0.4rem 0 36 | padding: 0 0.4rem 37 | 38 | h4 39 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.2), 25%) 40 | margin: 0.4rem 0 41 | padding: 0 0.4rem -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 GratisNow Tech. 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 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/api/comment(.*)", 5 | "destination": "/api/comment" 6 | }, 7 | { 8 | "source": "/api/config(.*)", 9 | "destination": "/api/config" 10 | }, 11 | { 12 | "source": "/api/post(.*)", 13 | "destination": "/api/post" 14 | }, 15 | { 16 | "source": "/api/user(.*)", 17 | "destination": "/api/user" 18 | }, 19 | { 20 | "source": "/api(.*)", 21 | "destination": "/api/index" 22 | }, 23 | { 24 | "source": "/(.*)", 25 | "destination": "/index.html" 26 | } 27 | ], 28 | "headers": [ 29 | { 30 | "source": "/api(.*)", 31 | "has": [ 32 | { 33 | "type": "header", 34 | "key": "origin", 35 | "value": "http://localhost:3000" 36 | } 37 | ], 38 | "headers": [ 39 | { 40 | "key": "access-control-allow-origin", 41 | "value": "http://localhost:3000" 42 | }, 43 | { 44 | "key": "access-control-allow-credentials", 45 | "value": "true" 46 | } 47 | ] 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /.vscode/serverless.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | "Init": { 9 | "scope": "typescript", 10 | "prefix": "vercel-node", 11 | "description": "Init serverless module", 12 | "body": [ 13 | "import { router } from './utils'", 14 | "import {} from './config'", 15 | "", 16 | "export default (req, res) => {", 17 | " router.endpoint('/api')", 18 | " // router.beforeEach((ctx) => initCol(ctx, ''))", 19 | "", 20 | " $0", 21 | "", 22 | " return router.init(req, res)", 23 | "}" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/images/wordpress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/Lazyload.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 61 | 62 | 72 | -------------------------------------------------------------------------------- /src/components/GlobalAside.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 33 | 34 | 64 | -------------------------------------------------------------------------------- /src/view/archives.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 54 | 55 | 59 | -------------------------------------------------------------------------------- /src/styles/highlight.sass: -------------------------------------------------------------------------------- 1 | // Customs 2 | pre.hljs 3 | padding: 0.5rem 4 | 5 | pre code, 6 | .hljs code, 7 | code.hljs 8 | background-color: unset 9 | padding: 0 10 | 11 | pre[class^="language-"] 12 | display: block 13 | overflow-x: auto 14 | padding: 0.5em 15 | background: #fdf6e3 16 | color: #657b83 17 | 18 | .hljs 19 | display: block 20 | overflow-x: auto 21 | padding: 0.5em 22 | background: #fdf6e3 23 | color: #657b83 24 | 25 | &-comment, 26 | 27 | &-quote 28 | color: #93a1a1 29 | 30 | &-keyword, 31 | 32 | &-selector-tag, 33 | 34 | &-addition 35 | color: #859900 36 | 37 | &-number, 38 | 39 | &-string, 40 | 41 | &-meta .hljs-meta-string, 42 | 43 | &-literal, 44 | 45 | &-doctag, 46 | 47 | &-regexp 48 | color: #2aa198 49 | 50 | &-title, 51 | 52 | &-section, 53 | 54 | &-name, 55 | 56 | &-selector-id, 57 | 58 | &-selector-class 59 | color: #268bd2 60 | 61 | &-attribute, 62 | 63 | &-attr, 64 | 65 | &-variable, 66 | 67 | &-template-variable, 68 | 69 | &-class .hljs-title, 70 | 71 | &-type 72 | color: #b58900 73 | 74 | &-symbol, 75 | 76 | &-bullet, 77 | 78 | &-subst, 79 | 80 | &-meta, 81 | 82 | &-meta .hljs-keyword, 83 | 84 | &-selector-attr, 85 | 86 | &-selector-pseudo, 87 | 88 | &-link 89 | color: #cb4b16 90 | 91 | &-built_in, 92 | 93 | &-deletion 94 | color: #dc322f 95 | 96 | &-formula 97 | background: #eee8d5 98 | 99 | &-emphasis 100 | font-style: italic 101 | 102 | &-strong 103 | font-weight: bold 104 | -------------------------------------------------------------------------------- /src/components/GlobalPlaceholder.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 46 | 47 | 58 | -------------------------------------------------------------------------------- /src/utils/userData.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { ref, computed } from 'vue' 3 | import { getErrMsg, globalInitErrors } from '.' 4 | import { API_BASE } from '../config' 5 | import { DbUserDoc } from '../types/Database' 6 | 7 | export const userData = ref({} as DbUserDoc) 8 | export const isLoggedIn = computed( 9 | () => !!(userData.value && userData.value.uid > 0 && userData.value.uuid) 10 | ) 11 | 12 | export async function userLogin({ 13 | username, 14 | password, 15 | }: { 16 | username: string 17 | password: string 18 | }): Promise { 19 | const { data }: any = await axios.post(`${API_BASE}/user/auth/sign-in`, { 20 | username, 21 | password, 22 | }) 23 | userData.value = data.body.profile 24 | return data 25 | } 26 | 27 | export async function initUserData(): Promise { 28 | console.log('Get userData by token') 29 | return axios.get(`${API_BASE}/user/auth/profile`).then( 30 | ({ data }: any) => { 31 | const profile = data.body.profile 32 | console.info('Current user', profile) 33 | userData.value = profile 34 | return profile 35 | }, 36 | (e) => { 37 | if (e?.response?.status === 401) { 38 | const profile = e?.response?.data?.body?.profile 39 | console.info('Anonymous', profile) 40 | userData.value = profile 41 | return profile 42 | } else { 43 | const content = getErrMsg(e) 44 | console.warn('Failed to get user data', e) 45 | globalInitErrors.value.push({ 46 | title: 'Failed to get user data', 47 | content, 48 | }) 49 | return null 50 | } 51 | } 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-now", 3 | "version": "1.0.0-alpha.4", 4 | "author": "Dragon-Fish <824399619@qq.com>", 5 | "license": "MIT", 6 | "private": true, 7 | "main": "index.js", 8 | "repository": "https://github.com/GratisNow/BlogNow.git", 9 | "scripts": { 10 | "start": "vite", 11 | "serve": "vercel dev", 12 | "build": "vuedx-typecheck . && vite build", 13 | "pretty": "prettier --write ./src ./api", 14 | "preview": "vercel deploy", 15 | "bump": "bump --nopublish" 16 | }, 17 | "dependencies": { 18 | "axios": "^0.24.0", 19 | "highlight.js": "^11.3.1", 20 | "js-cookie": "^3.0.1", 21 | "tslib": "^2.3.1", 22 | "uuid": "^8.3.2", 23 | "vue": "^3.2.20", 24 | "vue-router": "^4.0.11" 25 | }, 26 | "devDependencies": { 27 | "@dragon-fish/bump": "^0.0.15", 28 | "@kangc/v-md-editor": "^2.3.10", 29 | "@prettier/plugin-pug": "^1.16.7", 30 | "@types/js-cookie": "^3.0.0", 31 | "@types/js-yaml": "^4.0.3", 32 | "@types/node": "^16.10.3", 33 | "@types/nprogress": "^0.2.0", 34 | "@types/uuid": "^8.3.0", 35 | "@vercel/node": "^1.11.1", 36 | "@vicons/fa": "^0.11.0", 37 | "@vicons/material": "^0.11.0", 38 | "@vicons/utils": "^0.1.4", 39 | "@vitejs/plugin-vue": "^1.9.3", 40 | "@vue/compiler-sfc": "^3.2.20", 41 | "@vuedx/typecheck": "^0.7.4", 42 | "@vuedx/typescript-plugin-vue": "^0.7.4", 43 | "animated-scroll-to": "^2.2.0", 44 | "check-password-strength": "^2.0.3", 45 | "js-yaml": "^4.1.0", 46 | "mongodb": "^4.1.3", 47 | "nanoid": "^3.1.29", 48 | "nprogress": "^0.2.0", 49 | "prettier": "^2.4.1", 50 | "pug": "^3.0.2", 51 | "sass": "^1.42.1", 52 | "serverless-kit": "^0.2.3", 53 | "slugify": "^1.6.1", 54 | "typescript": "^4.4.4", 55 | "vercel": "^23.1.2", 56 | "vite": "^2.6.5" 57 | } 58 | } -------------------------------------------------------------------------------- /src/components/GlobalFooter.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 45 | 46 | 74 | -------------------------------------------------------------------------------- /src/vue-app-env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Process { 3 | env: ProcessEnv 4 | } 5 | interface ProcessEnv { 6 | /** 7 | * By default, there are two modes in Vite: 8 | * 9 | * * `development` is used by vite and vite serve 10 | * * `production` is used by vite build 11 | * 12 | * You can overwrite the default mode used for a command by passing the --mode option flag. 13 | * 14 | */ 15 | readonly NODE_ENV: 'development' | 'production' 16 | } 17 | } 18 | 19 | declare var process: NodeJS.Process 20 | 21 | declare module '*.gif' { 22 | const src: string 23 | export default src 24 | } 25 | 26 | declare module '*.jpg' { 27 | const src: string 28 | export default src 29 | } 30 | 31 | declare module '*.jpeg' { 32 | const src: string 33 | export default src 34 | } 35 | 36 | declare module '*.png' { 37 | const src: string 38 | export default src 39 | } 40 | 41 | declare module '*.webp' { 42 | const src: string 43 | export default src 44 | } 45 | 46 | declare module '*.svg' { 47 | const src: string 48 | export default src 49 | } 50 | 51 | declare module '*.module.css' { 52 | const classes: { readonly [key: string]: string } 53 | export default classes 54 | } 55 | 56 | declare module '*.module.scss' { 57 | const classes: { readonly [key: string]: string } 58 | export default classes 59 | } 60 | 61 | declare module '*.module.sass' { 62 | const classes: { readonly [key: string]: string } 63 | export default classes 64 | } 65 | 66 | declare module '*.vue' { 67 | import { ComponentOptions } from 'vue' 68 | const componentOptions: ComponentOptions 69 | export default componentOptions 70 | } 71 | 72 | // VMEditor 73 | declare module '@kangc/v-md-editor' 74 | declare module '@kangc/v-md-editor/lib/theme/hljs' 75 | declare module '@kangc/v-md-editor/lib/plugins/tip/index' 76 | declare module '@kangc/v-md-editor/lib/plugins/emoji/index' 77 | -------------------------------------------------------------------------------- /src/types/Database.ts: -------------------------------------------------------------------------------- 1 | export interface DbConfigDoc { 2 | key: string 3 | val: any 4 | } 5 | 6 | export type DbConfigCol = DbConfigDoc[] 7 | 8 | // Post 9 | export interface DbPostDoc { 10 | uuid: string 11 | pid: number 12 | slug: string 13 | title: string 14 | content: string 15 | created_at: Date 16 | author_uuid: string 17 | edited_at: Date 18 | editor_uuid: string 19 | allow_comment: boolean 20 | is_deleted: boolean 21 | deleted_by: string 22 | is_private: boolean 23 | allowed_users: string[] 24 | allowed_authority: number 25 | } 26 | 27 | // User 28 | export interface DbUserDoc { 29 | uuid: string 30 | uid: number 31 | username: string 32 | email: string 33 | created_at: Date 34 | nickname: string 35 | slogan: string 36 | gender: 'male' | 'female' | 'other' 37 | avatar: string 38 | password_hash: string 39 | salt: string 40 | token: string 41 | token_expires: number 42 | authority: number 43 | title: string 44 | allow_comment: boolean 45 | } 46 | 47 | export interface DbAuthorityDoc { 48 | key: DbAuthorityKeys 49 | authority: number 50 | display_name?: string 51 | } 52 | 53 | // Authority 54 | export type AuthorityPost = 'post_create' | 'post_edit_any' | 'post_delete_any' 55 | export type AuthorityComment = 56 | | 'comment_create' 57 | | 'comment_edit_any' 58 | | 'comment_delete_any' 59 | | 'comment_protect' 60 | export type AuthorityUser = 61 | | 'user_register' 62 | | 'user_block' 63 | | 'user_unblock_self' 64 | | 'user_admin' 65 | | 'user_group_edit' 66 | export type AuthoritySite = 'site_admin' 67 | export type DbAuthorityKeys = 68 | | AuthorityComment 69 | | AuthorityPost 70 | | AuthorityUser 71 | | AuthoritySite 72 | 73 | // Comment 74 | export interface DbCommentDoc { 75 | target_type: 'user' | 'post' | 'comment' 76 | target_uuid: string 77 | uuid: string 78 | content: string 79 | created_at: Date 80 | author_uuid: string 81 | edited_at: Date 82 | editor_uuid: string 83 | is_deleted: boolean 84 | } 85 | -------------------------------------------------------------------------------- /src/components/CommentEdit.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 66 | 67 | 74 | -------------------------------------------------------------------------------- /src/components/GlobalSideNav.vue: -------------------------------------------------------------------------------- 1 | 10 | 31 | 79 | -------------------------------------------------------------------------------- /src/components/GlobalHeader.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | 34 | 90 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node' 2 | import { COLNAME } from './config' 3 | import { getPostModel } from './post' 4 | import { router } from './utils' 5 | 6 | export default (req: VercelRequest, res: VercelResponse) => { 7 | router.endpoint('/api') 8 | 9 | // GET /site/meta 10 | router 11 | .addRoute() 12 | .method('GET') 13 | .path('site') 14 | .path('meta') 15 | .action(async (ctx) => { 16 | // Total stats 17 | const total_posts = await ctx.db.collection(COLNAME.POST).countDocuments() 18 | const total_users = await ctx.db.collection(COLNAME.USER).countDocuments() 19 | const total_tags = await ctx.db.collection(COLNAME.TAG).countDocuments() 20 | 21 | // Get found date 22 | const [firstUser] = await ctx.db 23 | .collection(COLNAME.USER) 24 | .find() 25 | .project({ created_at: 1 }) 26 | .sort({ uid: 1 }) 27 | .limit(1) 28 | .toArray() 29 | let founded_at = '' 30 | if (firstUser) { 31 | founded_at = firstUser.created_at 32 | } 33 | 34 | // Get latest update 35 | const [latestPost] = await ctx.db 36 | .collection(COLNAME.POST) 37 | .find() 38 | .sort({ pid: -1 }) 39 | .limit(1) 40 | .toArray() 41 | let latest_post = null 42 | if (latestPost) { 43 | latest_post = getPostModel(latestPost) 44 | delete latest_post.content 45 | } 46 | 47 | ctx.body = { 48 | meta: { total_posts, total_users, total_tags, founded_at, latest_post }, 49 | } 50 | ctx.message = 'Get site meta' 51 | }) 52 | 53 | // Easter eggs 54 | router 55 | .addRoute() 56 | .method('GET') 57 | .path(/(coffee|café|easter[_\-\s]egg)/i) 58 | .action((ctx) => { 59 | ctx.status = 418 60 | ctx.message = `Well, I think I need to remind you that I'm just a blog backend.` 61 | }) 62 | router 63 | .addRoute() 64 | .method('GET') 65 | .path(/(password|secrets?)/i) 66 | .action((ctx) => { 67 | ctx.status = 403 68 | ctx.message = `Hey, don't even think about it.` 69 | }) 70 | 71 | return router.init(req, res) 72 | } 73 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import Cookies from 'js-cookie' 3 | import { SITE_ENV } from './config' 4 | 5 | // Create App 6 | import App from './App.vue' 7 | const app = createApp(App) 8 | 9 | // Router 10 | import { router } from './router' 11 | app.use(router) 12 | 13 | // Style 14 | import './styles/index.sass' 15 | 16 | // Inject axios 17 | import axios from 'axios' 18 | axios.interceptors.request.use( 19 | (req) => { 20 | if (SITE_ENV !== 'prod') { 21 | req.headers = req.headers || {} 22 | try { 23 | req.headers.authorization = window.Cookies.get('BLOG_NOW_TOKEN') || '' 24 | console.info('[Axios]', 'Request with local token') 25 | } catch (err) { 26 | console.warn('[Axios]', 'Inject error', err) 27 | } 28 | } 29 | return req 30 | }, 31 | (e) => Promise.reject(e) 32 | ) 33 | 34 | // Icon 35 | import { Icon } from '@vicons/utils' 36 | app.component('Icon', Icon) 37 | 38 | // ExternamLink 39 | import ExternamLink from './components/ExternalLink.vue' 40 | app.component('ELink', ExternamLink) 41 | 42 | // LazyLoad 43 | import Lazyload from './components/Lazyload.vue' 44 | app.component('Lazyload', Lazyload) 45 | 46 | // Placeholder 47 | import Placeholder from './components/Placeholder.vue' 48 | app.component('Placeholder', Placeholder) 49 | 50 | // highlightjs 51 | import hljs from 'highlight.js' 52 | 53 | // Editor 54 | import VMdEditor from '@kangc/v-md-editor' 55 | import '@kangc/v-md-editor/lib/style/base-editor.css' 56 | // Editor theme 57 | import createHljsTheme from '@kangc/v-md-editor/lib/theme/hljs' 58 | const baseTheme = createHljsTheme({ Hljs: hljs }) 59 | // Editor Plugins 60 | import createTipPlugin from '@kangc/v-md-editor/lib/plugins/tip/index' 61 | import '@kangc/v-md-editor/lib/plugins/tip/tip.css' 62 | import createEmojiPlugin from '@kangc/v-md-editor/lib/plugins/emoji/index' 63 | import '@kangc/v-md-editor/lib/plugins/emoji/emoji.css' 64 | 65 | VMdEditor.vMdParser.theme(baseTheme) 66 | VMdEditor.use(createTipPlugin()) 67 | VMdEditor.use(createEmojiPlugin()) 68 | app.use(VMdEditor) 69 | 70 | // Mount 71 | app.mount('#app') 72 | document.body?.setAttribute('data-env', SITE_ENV) 73 | window.Cookies = Cookies 74 | -------------------------------------------------------------------------------- /src/components/QuickEdit.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 78 | 79 | 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .vercel 107 | 108 | lib/ 109 | dev/ 110 | secret/ 111 | secret*.* 112 | 113 | !*.sample.* 114 | !sample/ -------------------------------------------------------------------------------- /src/components/AuthorCard.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 29 | 30 | 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # BlogNow 4 | 5 | Follow the wizard, start your blog journey right now! 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FFreeNowOrg%2FBlogNow&env=MONGO_URI&envDescription=MONGO_URI%3A%20Your%20MongoDB%20Atlas%20connect%20uri%20with%20username%20and%20password.%20(e.g.%20mongodb%2Bsrv%3A%2F%2Fuser%3Apassword%40yours.mongodb.net)%20%2F%20BLOGNOW_DB(optional)%3A%20Your%20blog's%20db%20name.%20(e.g.%20blog_now)&repo-name=my-blog&demo-title=BlogNow&demo-description=BlogNow%20Offical%20Demo&demo-url=https%3A%2F%2Fblog-now.vercel.app%2F&demo-image=https%3A%2F%2Fi.loli.net%2F2021%2F10%2F12%2FL38zE4oF7ITsqHO.jpg) 8 | 9 | **BlogNow** 是一款完全免费的开源动态博客引擎。 10 | 11 | Free dynamic blog engine with full backend support. Powered by Vercel & MongoDB Atlas. Built with :heart: 12 | 13 |
14 | 15 | ![A8AC1770-76BF-441D-ABAC-A7B2B7887B6E.jpeg](https://i.loli.net/2021/10/12/MB7rNJUpjARfQtw.jpg) 16 | 17 | ## 特色功能 18 | 19 | ### 完全免费且开源 20 | 21 | 一般来说,部署一个基于 BlogNow 的博客——没错,包括后端——是完全可以做到白嫖的! 22 | 23 | 在你的博客内容数据超过 500 MB 以前,均可不花一分钱地使用。 24 | 25 | ### 开箱即用 26 | 27 | 很多时候,个人博客只是极客们的玩具,因为个人博客的安装或者使用都有一定的技术门槛。 28 | 29 | 大部分人都望而却步了——服务器是什么?数据库又是什么?域名怎么注册?诶——怎么还要备案啊! 30 | 31 | 但是 BlogNow 的安装超级简单——呃,这算是安装吗?——你似乎只需要点击几个按钮,然后一路下一步就行了。 32 | 33 | ### 包括完整的后端 34 | 35 | - 文章管理——撰写、编辑、删除博文 36 | - 用户系统——注册、用户权限 37 | - 媒体管理(开发中🚧) 38 | 39 | ### 动态博客系统,更新内容超级快 40 | 41 | 相当长一段时间里,白嫖博客的手段基本都是基于静态网站生成器和持续构建,例如经典的 Hexo + GitHub Actions 组合。 42 | 43 | 然而,BlogNow 敲开了白嫖博客新时代的大门,它是一款**动态博客引擎**,你可以类比传统的博客系统——例如 WordPress。 44 | 45 | 这意味着你不必在每次更新文章时重新构建——现在,你只需编辑文章,点击保存,然后眨眨眼,网页便自己刷新了,你的好点子已经汇入了互联网的川流之中,就是这么迅速。 46 | 47 | ### 支持 markdown,在文章里加点格式吧 48 | 49 | ![7F2D54A2-D68C-42AC-A3B5-9E5897B23789.jpeg](https://i.loli.net/2021/10/12/8hFVSnrCRuJwtI7.jpg) 50 | 51 | BlogNow 采用 markdown 保存文章内容,得益于 md,你可以在文章里插入各种格式。 52 | 53 | **粗体字** _斜体字_ ~~删除线~~ [链接](https://github.com) 54 | 55 | - 一个 56 | - 无序 57 | - 列表 58 | 59 | > 以及更多格式…… 60 | 61 | BlogNow 内置了非常强大的 markdown 编辑器,你可以点击按钮插入元素,然后实时预览你所写的内容。 62 | 63 | ### 另外,超棒的 API 接口——极客们也一定会喜欢 64 | 65 | 非常 RESTful 风格的 API 设计,请看《一篇文章的一生》: 66 | 67 | 1. 新建一篇博客 68 | `POST /post/create` 69 | 2. 获取博客内容 70 | `GET /post/uuid/foo-bar-baz` 71 | 3. 修改博客内容 72 | `PATCH /post/uuid/foo-bar-baz` 73 | 4. 最后再删掉它 74 | `DELETE /post/uuid/foo-bar-baz` 75 | 76 | ——顺便一提,[API 文档](https://blog-now.vercel.app/-/api-references)也很详尽! 77 | -------------------------------------------------------------------------------- /src/components/CommentList.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | 80 | -------------------------------------------------------------------------------- /src/styles/formats.sass: -------------------------------------------------------------------------------- 1 | .align-center, 2 | .loading 3 | text-align: center 4 | 5 | .align-left 6 | text-align: left 7 | 8 | .align-right 9 | text-align: right 10 | 11 | .position-center 12 | text-align: left 13 | position: relative 14 | left: 50% 15 | transform: translateX(-50%) 16 | 17 | .flex-center 18 | display: flex 19 | align-items: center 20 | 21 | .pre, 22 | .poem 23 | white-space: pre-wrap 24 | 25 | .flex 26 | display: flex 27 | 28 | .flex-column 29 | flex-direction: column 30 | 31 | .flex-auto 32 | display: flex 33 | flex-direction: row 34 | @media screen and (max-width: 800px) 35 | .flex-auto 36 | flex-direction: column 37 | 38 | .flex-1 39 | flex: 1 40 | 41 | .gap-1 42 | gap: 1rem 43 | 44 | .flex-list 45 | .list-item 46 | display: flex 47 | gap: 0.5rem 48 | 49 | &:not(:first-of-type) 50 | margin-top: 4px 51 | 52 | &.header 53 | position: sticky 54 | top: 50px 55 | background-color: #f8f8f8 56 | font-weight: 600 57 | font-size: 1.24rem 58 | z-index: 10 59 | 60 | > div:not(:last-of-type) 61 | box-shadow: 2px 0 #dedede 62 | 63 | > div 64 | flex: 1 65 | 66 | .key 67 | font-weight: 600 68 | box-shadow: 2px 0 #dedede 69 | 70 | .main-flex 71 | display: flex 72 | gap: 1.5rem 73 | margin-bottom: 2rem 74 | article 75 | flex: 1 76 | aside 77 | width: 250px 78 | 79 | @media screen and(max-width: 900px) 80 | .main-flex 81 | flex-direction: column 82 | aside 83 | width: 100% 84 | 85 | .pointer 86 | cursor: pointer 87 | 88 | .bread-crumb 89 | margin-bottom: 1.5rem 90 | 91 | // Info 92 | .info 93 | --border-color: rgba(0, 0, 0, 0.1) 94 | --bg-color: rgba(0, 0, 0, 0.04) 95 | background-color: #fff 96 | box-shadow: 0 0 6px rgba(0, 0, 0, 0.1) 97 | border-left: 6px solid var(--border-color) 98 | border-radius: 4px 99 | & > * 100 | padding: 1rem 101 | margin: 0 102 | & .title 103 | font-weight: 600 104 | font-size: 1.2rem 105 | padding: 0.4rem 1rem 106 | background-color: var(--bg-color) 107 | &.tips 108 | --border-color: #30a0ff 109 | --bg-color: rgba(0, 140, 255, 0.1) 110 | &.warn 111 | --border-color: #ffa500 112 | --bg-color: rgba(231, 139, 0, 0.1) 113 | &.error 114 | --border-color: #e00000 115 | --bg-color: rgba(233, 0, 0, 0.1) 116 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import scrollTo from 'animated-scroll-to' 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(), 6 | routes: [], 7 | scrollBehavior(to, from, savedPosition) { 8 | if (savedPosition) { 9 | return savedPosition 10 | } else { 11 | scrollTo(0, { maxDuration: 800 }) 12 | } 13 | }, 14 | }) 15 | 16 | router.afterEach(({ name }) => { 17 | document.body.setAttribute('data-route', name as string) 18 | document.body.style.overflow = 'visible' 19 | document.documentElement.style.overflow = 'visible' 20 | }) 21 | 22 | // Home 23 | router.addRoute({ 24 | path: '/', 25 | name: 'home', 26 | component: () => import('./view/index.vue'), 27 | }) 28 | 29 | // Archive 30 | router.addRoute({ 31 | path: '/archives', 32 | name: 'archives', 33 | alias: ['/archive'], 34 | component: () => import('./view/archives.vue'), 35 | }) 36 | 37 | // Post 38 | router.addRoute({ 39 | path: '/post/:uuid', 40 | name: 'post-uuid', 41 | component: () => import('./view/post.vue'), 42 | }) 43 | router.addRoute({ 44 | path: '/pid/:pid', 45 | alias: ['/p-:pid'], 46 | name: 'post-pid', 47 | component: () => import('./view/post.vue'), 48 | }) 49 | router.addRoute({ 50 | path: '/-/:slug', 51 | name: 'post-slug', 52 | component: () => import('./view/post.vue'), 53 | }) 54 | 55 | // Post edit 56 | router.addRoute({ 57 | path: '/post/:uuid/edit', 58 | name: 'edit-post', 59 | component: () => import('./view/edit-post.vue'), 60 | }) 61 | router.addRoute({ 62 | path: '/post/new', 63 | name: 'edit-post-create', 64 | component: () => import('./view/edit-post.vue'), 65 | }) 66 | 67 | // Auth 68 | router.addRoute({ 69 | path: '/auth', 70 | alias: ['/login', '/sign-in', '/logout'], 71 | name: 'auth', 72 | component: () => import('./view/auth.vue'), 73 | }) 74 | 75 | // User 76 | router.addRoute({ 77 | path: '/user/:uuid', 78 | name: 'user-uuid', 79 | component: () => import('./view/user.vue'), 80 | }) 81 | router.addRoute({ 82 | path: '/u/:uid', 83 | name: 'user-uid', 84 | component: () => import('./view/user.vue'), 85 | }) 86 | router.addRoute({ 87 | path: '/@:username', 88 | name: 'user-username', 89 | component: () => import('./view/user.vue'), 90 | }) 91 | 92 | // Search 93 | // router.addRoute({ 94 | // path: '/search', 95 | // name: 'search-index-redirect', 96 | // component: () => import('./view/search.vue'), 97 | // }) 98 | 99 | // 404 100 | router.addRoute({ 101 | path: '/:pathMatch(.*)*', 102 | name: 'not-found', 103 | component: () => import('./view/404.vue'), 104 | }) 105 | 106 | export { router } 107 | -------------------------------------------------------------------------------- /src/utils/siteCache.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { ref } from 'vue' 3 | import { API_BASE } from '../config' 4 | import type { ApiResponsePost, ApiResponseUser } from '../types' 5 | 6 | export const siteCache = ref({ 7 | meta: {} as Record, 8 | posts: [] as ApiResponsePost[], 9 | users: [] as ApiResponseUser[], 10 | recents: [] as string[], 11 | }) 12 | 13 | // Post 14 | export function setPostCache(post: ApiResponsePost) { 15 | const index = siteCache.value.posts.findIndex( 16 | ({ uuid }) => uuid === post.uuid 17 | ) 18 | if (index < 0) { 19 | console.info('[CACHE]', 'Update post cache') 20 | siteCache.value.posts.push(post) 21 | } else { 22 | console.info('[CACHE]', 'Set post cache') 23 | siteCache.value.posts[index] = post 24 | } 25 | return true 26 | } 27 | 28 | export async function getPost( 29 | selector: 'uuid' | 'pid' | 'slug', 30 | target: string | number, 31 | noCache?: boolean 32 | ): Promise { 33 | const cache = siteCache.value.posts.find((i) => i[selector] === target) 34 | if (cache && !noCache) { 35 | console.info('[CACHE]', 'Get post from cache') 36 | return cache 37 | } 38 | console.info('[CACHE]', 'Get post from origin') 39 | const { data }: any = await axios.get( 40 | `${API_BASE}/post/${selector}/${target}` 41 | ) 42 | setPostCache(data.body.post) 43 | return data.body.post 44 | } 45 | 46 | export async function getPostList({ limit = 25, offset = 0 }) { 47 | const { data }: any = await axios.get(`${API_BASE}/post/list/recent`, { 48 | params: { limit, offset }, 49 | }) 50 | 51 | const posts: ApiResponsePost[] = data.body.posts || [] 52 | 53 | if (offset === 0 && posts.length > 0) { 54 | console.info('[CACHE]', 'Set recents') 55 | siteCache.value.recents = posts.map(({ uuid }) => uuid) 56 | } 57 | 58 | posts.forEach(setPostCache) 59 | 60 | return posts 61 | } 62 | 63 | export async function getRecentPosts( 64 | noCache?: boolean 65 | ): Promise { 66 | const list: ApiResponsePost[] = [] 67 | if (siteCache.value.recents.length > 0 && !noCache) { 68 | console.info('[CACHE]', 'Get recents from cache') 69 | siteCache.value.recents.forEach((uuid) => { 70 | const post = siteCache.value.posts.find( 71 | ({ uuid: uuid1 }) => uuid === uuid1 72 | ) 73 | if (post) return list.push(post) 74 | console.warn( 75 | '[CACHE]', 76 | `Post ${uuid} is not in cache, but was required by recents list` 77 | ) 78 | }) 79 | return list 80 | } 81 | return getPostList({ limit: 25, offset: 0 }) 82 | } 83 | 84 | // User 85 | export function setUserCache(user: ApiResponseUser) { 86 | const index = siteCache.value.users.findIndex( 87 | ({ uuid }) => uuid === user.uuid 88 | ) 89 | if (index < 0) { 90 | console.info('[CACHE]', 'Update user cache') 91 | siteCache.value.users.push(user) 92 | } else { 93 | console.info('[CACHE]', 'Set user cache') 94 | siteCache.value.users[index] = user 95 | } 96 | return true 97 | } 98 | 99 | export async function getUser( 100 | selector: 'uuid' | 'uid' | 'username', 101 | target: string | number, 102 | noCache?: boolean 103 | ): Promise { 104 | const cache = siteCache.value.users.find((i) => i[selector] === target) 105 | if (cache && !noCache) { 106 | console.info('[CACHE]', 'Get user from cache') 107 | return cache 108 | } 109 | console.info('[CACHE]', 'Get user from origin') 110 | const { data }: any = await axios.get( 111 | `${API_BASE}/user/${selector}/${target}` 112 | ) 113 | setUserCache(data.body.user) 114 | return data.body.user 115 | } 116 | 117 | export async function getUserList( 118 | selector: 'uuid' | 'uid' | 'username', 119 | users: string[] 120 | ) { 121 | const { data }: any = await axios.get( 122 | `${API_BASE}/users/${selector}/${users.join(',')}` 123 | ) 124 | 125 | const list: ApiResponseUser[] = data.body.users || [] 126 | 127 | list.forEach(setUserCache) 128 | 129 | return list 130 | } 131 | -------------------------------------------------------------------------------- /src/components/PostList.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 51 | 52 | 125 | -------------------------------------------------------------------------------- /api/comment.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node' 2 | import { DbCommentDoc, DbUserDoc } from '../src/types' 3 | import { COLNAME } from './config' 4 | import { v4 as UUID } from 'uuid' 5 | import { attachUsers, router } from './utils' 6 | import { Db } from 'mongodb' 7 | import { RouteContextDefaults } from 'serverless-kit' 8 | import { getUserModel } from './user' 9 | 10 | const COMMENT_DEFAULTS: DbCommentDoc = { 11 | target_type: 'post', 12 | target_uuid: '', 13 | uuid: '', 14 | content: '', 15 | author_uuid: '', 16 | editor_uuid: '', 17 | created_at: new Date(0), 18 | edited_at: new Date(0), 19 | is_deleted: false, 20 | } 21 | 22 | export function getCommentModel(payload: Partial) { 23 | return { 24 | ...COMMENT_DEFAULTS, 25 | ...payload, 26 | } 27 | } 28 | 29 | export default (req: VercelRequest, res: VercelResponse) => { 30 | router.endpoint('/api/comment') 31 | router.setCollection(COLNAME.COMMENT) 32 | 33 | // Get comments for target 34 | router 35 | .addRoute() 36 | .method('GET') 37 | .path(['user', 'post', 'comment'], 'target_type') 38 | .path(/.+/, 'target_uuid') 39 | .parseOffsetLimitSort() 40 | .check(checkCanCreate) 41 | .action(async (ctx) => { 42 | const filter = { 43 | target_type: ctx.params.target_type, 44 | target_uuid: ctx.params.target_uuid, 45 | } 46 | 47 | const total_comments = await ctx.col.find(filter).count() 48 | const comments = await ctx.col 49 | .find(filter) 50 | .skip(ctx.offset) 51 | .sort(ctx.sort) 52 | .limit(ctx.limit + 1) 53 | .toArray() 54 | 55 | let has_next = false 56 | if (comments.length > ctx.limit) { 57 | has_next = true 58 | comments.pop() 59 | } 60 | 61 | ctx.message = 'Get comments by filter' 62 | ctx.body = { 63 | comments: await attachUsers(ctx, comments), 64 | filter, 65 | total_comments, 66 | offset: ctx.offset, 67 | limit: ctx.limit, 68 | sort: ctx.sort, 69 | has_next, 70 | } 71 | }) 72 | 73 | // Add comment 74 | router 75 | .addRoute() 76 | .method('POST') 77 | .path(['user', 'post', 'comment'], 'target_type') 78 | .path(/.+/, 'target_uuid') 79 | .checkLogin() 80 | .checkAuth(1) 81 | .check<{ 82 | content: string 83 | }>((ctx) => { 84 | const content: string = ctx.req.body?.content || '' 85 | if (!content.trim()) { 86 | ctx.status = 400 87 | ctx.message = 'Missing content' 88 | return false 89 | } 90 | if (content.length > 1000) { 91 | ctx.status = 413 92 | ctx.message = 'The content should be less than 1000 words' 93 | return false 94 | } 95 | ctx.content = content 96 | }) 97 | .check(checkCanCreate) 98 | .action(async (ctx) => { 99 | const comment = getCommentModel({ 100 | author_uuid: ctx.user.uuid, 101 | content: ctx.content, 102 | created_at: new Date(), 103 | target_type: ctx.params.target_type as 'user' | 'post' | 'comment', 104 | target_uuid: ctx.params.target_uuid, 105 | uuid: UUID(), 106 | }) 107 | const dbRes = await ctx.col.insertOne(comment) 108 | 109 | ctx.message = 'Comment created.' 110 | ctx.body = { 111 | comment: { 112 | ...comment, 113 | author: getUserModel(ctx.user, true), 114 | editor: getUserModel(null, true), 115 | }, 116 | ...dbRes, 117 | } 118 | }) 119 | 120 | return router.init(req, res) 121 | } 122 | 123 | async function checkCanCreate( 124 | ctx: RouteContextDefaults & { db: Db; user: DbUserDoc } 125 | ) { 126 | const target = await ctx.db 127 | .collection(COLNAME[ctx.params.target_type.toUpperCase()]) 128 | .findOne({ uuid: ctx.params.target_uuid }) 129 | if (!target || target.is_deleted) { 130 | ctx.status = 404 131 | ctx.message = `Requested ${ctx.params.target_type} not found.` 132 | return false 133 | } else if ( 134 | target.is_private && 135 | !target.allowed_user.includes(ctx.user.uuid) 136 | ) { 137 | ctx.status = 403 138 | ctx.message = 'Permision denied' 139 | return false 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/utils/FileUploader.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' 2 | import { nanoid } from 'nanoid' 3 | 4 | export class FileUploader { 5 | private repo!: string 6 | private token!: string 7 | request!: AxiosInstance 8 | 9 | /** 10 | * @param token e.g. `ghp_ABCabc123` 11 | * @param repo e.g. `username/reponame` 12 | */ 13 | constructor(token: string, repo: string) { 14 | this.setToken(token) 15 | this.setRepo(repo) 16 | } 17 | 18 | private getRequest() { 19 | return axios.create({ 20 | baseURL: 'https://api.github.com', 21 | headers: { 22 | Authorization: `Bearer ${this.token}`, 23 | }, 24 | }) 25 | } 26 | 27 | setToken(token: string) { 28 | this.token = token 29 | this.request = this.getRequest() 30 | return this 31 | } 32 | 33 | setRepo(repo: string) { 34 | this.repo = repo 35 | return this 36 | } 37 | 38 | private generateFilePath(name: string, ext: string) { 39 | const now = new Date() 40 | const yyyy = now.getFullYear() 41 | const mm = `0${now.getMonth() + 1}`.slice(-2) 42 | const dd = `0${now.getDate()}`.slice(-2) 43 | return `${yyyy}/${mm}/${dd}/${name}.${ext}` 44 | } 45 | 46 | async getFileBase64( 47 | file: File 48 | ): Promise<{ base64: string; url: string; ext: string }> { 49 | return new Promise((resolve) => { 50 | const reader = new FileReader() 51 | reader.readAsDataURL(file) 52 | reader.addEventListener( 53 | 'load', 54 | function () { 55 | const url = reader.result as string 56 | const base64 = url?.split(',')[1] 57 | const ext = file.type.split('/')[1] 58 | return resolve({ url, base64, ext }) 59 | }, 60 | false 61 | ) 62 | }) 63 | } 64 | 65 | async getFileList(): Promise { 66 | const { data }: any = await this.request.get( 67 | `/repos/${this.repo}/git/trees/HEAD:uploads`, 68 | { 69 | params: { 70 | recursive: 1, 71 | _random: Math.random(), 72 | }, 73 | } 74 | ) 75 | const list = (data.tree as FilePayload[]) 76 | .filter(({ type }) => type === 'blob') 77 | .map((i) => { 78 | i.html_url = `https://github.com/${this.repo}/tree/HEAD/uploads/${i.path}` 79 | i.name = i.path.split('/').pop() as string 80 | return i 81 | }) 82 | console.log(list) 83 | return list 84 | } 85 | 86 | /** 87 | * @desc Get file raw url and proxy urls 88 | */ 89 | getFileUrl(payload: FilePayload) { 90 | const file = `${this.repo}/HEAD/uploads/${payload.path}` 91 | return { 92 | raw: `https://raw.githubusercontent.com/${file}`, 93 | fastgit: `https://raw.fastgit.org/${file}`, 94 | ghproxy: `https://ghproxy.com/https://raw.githubusercontent.com/${file}`, 95 | jsdelivr: `https://cdn.jsdelivr.net/gh/${this.repo}/uploads/${payload.path}`, 96 | zwc365: `https://pd.zwc365.com/https://raw.githubusercontent.com/${file}`, 97 | } 98 | } 99 | 100 | async upload(file: File): Promise> { 101 | const name = nanoid(8) 102 | const { base64, ext } = await this.getFileBase64(file) 103 | const path = this.generateFilePath(name, ext) 104 | return this.request.put(`/repos/${this.repo}/contents/uploads/${path}`, { 105 | message: `upload file "${file.name}"`, 106 | content: base64, 107 | }) 108 | } 109 | 110 | async delete(payload: FilePayload): Promise< 111 | AxiosResponse<{ 112 | commit: { 113 | sha: string 114 | node_id: string 115 | url: string 116 | html_url: string 117 | committer: { 118 | name: string 119 | date: string 120 | } 121 | } 122 | }> 123 | > { 124 | return this.request.delete( 125 | `/repos/${this.repo}/contents/uploads/${payload.path}`, 126 | { 127 | data: { 128 | message: `remove file "${payload.name}"`, 129 | sha: payload.sha, 130 | }, 131 | } 132 | ) 133 | } 134 | } 135 | 136 | export interface FilePayload { 137 | name: string 138 | path: string 139 | sha: string 140 | size: number 141 | url: string 142 | html_url: string 143 | git_url: string 144 | download_url: string 145 | type: 'tree' | 'blob' 146 | } 147 | -------------------------------------------------------------------------------- /src/view/auth.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 134 | 135 | 175 | -------------------------------------------------------------------------------- /src/components/FloatToolbox.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 93 | 94 | 168 | 169 | 180 | -------------------------------------------------------------------------------- /src/view/index.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 65 | 66 | 175 | 176 | 186 | -------------------------------------------------------------------------------- /src/components/GlobalHeaderUserDropdown.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 70 | 71 | 166 | 167 | 192 | -------------------------------------------------------------------------------- /src/view/user.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 114 | 115 | 175 | 176 | 188 | -------------------------------------------------------------------------------- /src/styles/elements.sass: -------------------------------------------------------------------------------- 1 | // Header 2 | @mixin header-shared($shadow-color, $bg-width) 3 | position: relative 4 | text-shadow: 0px 2px 0 var(--theme-text-shadow-color) 5 | z-index: 1 6 | &::after 7 | content: "" 8 | width: $bg-width 9 | height: 0.5em 10 | display: block 11 | position: absolute 12 | background-color: $shadow-color 13 | pointer-events: none 14 | border-radius: 1em 15 | left: -2px 16 | bottom: 0px 17 | z-index: -1 18 | 19 | h1, h2, h3, h4, h5, h6 20 | font-weight: 600 21 | h1 22 | font-size: 2.2rem 23 | h2 24 | font-size: 1.8rem 25 | h3 26 | font-size: 1.5rem 27 | h4, h5, h6 28 | font-size: 1.2rem 29 | 30 | // Links 31 | a 32 | --color: var(--theme-link-color) 33 | color: var(--color) 34 | text-decoration: none 35 | position: relative 36 | display: inline-block 37 | 38 | &.plain 39 | display: unset 40 | 41 | &:not(.plain)::after 42 | content: '' 43 | display: block 44 | position: absolute 45 | width: 100% 46 | height: 0.1em 47 | bottom: -0.1em 48 | left: 0 49 | background-color: var(--color) 50 | visibility: hidden 51 | transform: scaleX(0) 52 | transition: all 0.4s ease-in-out 53 | 54 | &:not(.plain):hover::after, 55 | &.router-link-active::after, 56 | &.tab-active::after, 57 | &.is-active::after 58 | visibility: visible 59 | transform: scaleX(1) 60 | 61 | &.button 62 | padding: 0.2rem 0.4rem 63 | background-color: var(--theme-tag-color) 64 | transition: all .4s ease 65 | cursor: pointer 66 | 67 | &:hover 68 | background-color: rgba(var(--theme-link-color--rgb), 1) 69 | color: var(--theme-accent-link-color) 70 | 71 | hr, 72 | .hr 73 | border: none 74 | border-bottom: 4px dotted rgba(var(--theme-accent-color--rgb), 0.5) 75 | margin: 1rem 0 76 | 77 | // Button 78 | button 79 | display: inline-block 80 | padding: 0.4rem 0.8rem 81 | font-size: 1rem 82 | border: none 83 | border-radius: 4px 84 | background-color: var(--theme-accent-color) 85 | color: #fff 86 | cursor: pointer 87 | 88 | &:hover 89 | background-color: var(--theme-accent-color-darken) 90 | 91 | &:focus 92 | background-color: var(--theme-accent-color) 93 | box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px var(--theme-accent-color) 94 | 95 | &:disabled 96 | background-color: #ccc 97 | cursor: not-allowed 98 | 99 | &:active 100 | box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px #ccc 101 | 102 | // Card 103 | .card 104 | background-color: #fff 105 | border-radius: 8px 106 | padding: 1rem 107 | transition: box-shadow .6s ease 108 | box-shadow: var(--theme-box-shadow) 109 | 110 | &.gap 111 | margin-top: 1rem 112 | margin-bottom: 1.4rem 113 | 114 | &:hover 115 | box-shadow: var(--theme-box-shadow-hover) 116 | 117 | // Tags 118 | .tags-list 119 | line-height: 1.6 120 | .tag 121 | line-height: 1em 122 | display: inline-block 123 | padding: 2px 4px 124 | margin-right: 0.4rem 125 | background-color: var(--theme-tag-color) 126 | &.router-link-active 127 | color: #fff 128 | background-color: var(--theme-link-color) 129 | 130 | input.site-style, 131 | textarea.site-style 132 | width: 100% 133 | padding: 0.4em 0.75em 134 | font-size: 1rem 135 | line-height: 1em 136 | border: none 137 | border-radius: 0.5em 138 | background-color: rgba(0, 0, 0, 0.04) 139 | outline: none 140 | &:hover 141 | background-color: rgba(0, 0, 0, 0.08) 142 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25) 143 | &:focus 144 | background-color: rgba(0, 0, 0, 0.04) 145 | box-shadow: 0 0 0 2px var(--theme-accent-color) 146 | 147 | // Responsive 148 | .responsive, 149 | .body-inner 150 | padding-left: 5% 151 | padding-right: 5% 152 | max-width: 1200px 153 | margin-left: auto 154 | margin-right: auto 155 | 156 | // Table 157 | table 158 | width: 100% 159 | max-width: 100% 160 | margin-bottom: 20px 161 | border-spacing: 0 162 | border-collapse: collapse 163 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.1) 164 | border-radius: 8px 165 | 166 | tr:nth-of-type(2n) 167 | background-color: rgba(var(--theme-accent-color--rgb), 0.1) 168 | 169 | td 170 | padding: 0.5rem 171 | border-bottom: 1px solid #ccc 172 | 173 | // Loading 174 | .loading-cover 175 | position: relative 176 | &::before,&::after 177 | content: "" 178 | width: 100% 179 | height: 100% 180 | display: block 181 | position: absolute 182 | &::before 183 | background-image: url(/images/spinner.svg) 184 | background-size: 75px 185 | background-repeat: no-repeat 186 | background-position: center 187 | top: 50% 188 | left: 50% 189 | transform: translateX(-50%) translateY(-50%) 190 | z-index: 6 191 | &::after 192 | top: 0 193 | left: 0 194 | background-color: rgba(200,200,200,0.2) 195 | z-index: 5 196 | 197 | .xicon 198 | vertical-align: middle 199 | 200 | // Code 201 | pre:not(.hljs) 202 | overflow: auto 203 | background: rgba(172, 232, 255, 0.2) 204 | padding: 0.4rem 205 | border: 1px solid #cccccc 206 | border-radius: 6px 207 | 208 | code 209 | background-color: #efefef 210 | display: inline 211 | border-radius: 2px 212 | padding: .1rem .2rem 213 | color: #e02080 214 | word-break: break-word 215 | 216 | // Tabber 217 | .tabber 218 | .tabber-tabs 219 | margin-bottom: 1rem 220 | display: flex 221 | gap: 1rem 222 | a 223 | cursor: pointer 224 | -------------------------------------------------------------------------------- /src/view/edit-post.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 161 | 162 | 196 | -------------------------------------------------------------------------------- /api/utils.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node' 2 | import { Collection, Db, MongoClient } from 'mongodb' 3 | import { 4 | HandleRouter, 5 | HandleResponse, 6 | Route, 7 | getProjectSrotFromStr, 8 | } from 'serverless-kit' 9 | import { SITE_ENV } from '../src/config' 10 | import { ApiAttachedUser } from '../src/types' 11 | import { DbPostDoc, DbUserDoc } from '../src/types/Database' 12 | import { COLNAME, getLocalConfig } from './config' 13 | import { getUserModel, TOKEN_COOKIE_NAME } from './user' 14 | 15 | // type gymnastics 16 | declare module '../node_modules/serverless-kit/lib/modules/HandleRouter' { 17 | interface HandleRouter { 18 | setCollection: (col: string) => HandleRouter<{ col: Collection }> 19 | } 20 | interface Route { 21 | checkAuth: (required: number) => Route 22 | checkLogin: () => Route 23 | parseOffsetLimitSort: () => Route< 24 | RouteContextDefaults & 25 | ContextT & { 26 | offset: number 27 | limit: number 28 | sort: Record 29 | } 30 | > 31 | } 32 | } 33 | 34 | HandleRouter.prototype.setCollection = function (colName) { 35 | return this.beforeEach((ctx) => { 36 | ctx.col = ctx.db.collection(colName) 37 | }) 38 | } 39 | 40 | Route.prototype.checkAuth = function (required) { 41 | return this.check((ctx) => { 42 | if (ctx.user.authority < required) { 43 | ctx.status = 403 44 | ctx.message = 'Permission denied' 45 | ctx.body = { 46 | authcheck: { 47 | required, 48 | recived: ctx.user.authority, 49 | }, 50 | } 51 | return false 52 | } 53 | }) 54 | } 55 | 56 | Route.prototype.checkLogin = function () { 57 | return this.check((ctx) => { 58 | if (!ctx.user.uuid || ctx.user.uid < 0) { 59 | ctx.status = 401 60 | ctx.message = 'Please login' 61 | return false 62 | } 63 | }) 64 | } 65 | 66 | Route.prototype.parseOffsetLimitSort = function () { 67 | return this.check((ctx) => { 68 | ctx.offset = parseInt((ctx.req.query.offset as string) || '0') 69 | ctx.limit = Math.min(25, parseInt((ctx.req.query.limit as string) || '10')) 70 | ctx.sort = getProjectSrotFromStr((ctx.req.query.sort as string) || '') 71 | }) 72 | } 73 | 74 | // Constuct a router 75 | const router = new HandleRouter<{ 76 | mongoClient: MongoClient 77 | db: Db 78 | col: Collection 79 | user: DbUserDoc 80 | }>() 81 | // Make sure the body exists 82 | router.beforeEach((ctx) => { 83 | ctx.body = ctx.body || {} 84 | ctx.req.body = ctx.req.body || {} 85 | }) 86 | // Connect db 87 | router.beforeEach(initMongo) 88 | // Pre fetch userData 89 | router.beforeEach(initUserData) 90 | // Close db 91 | router.afterEach(closeMongo) 92 | export { router } 93 | export default (req: VercelRequest, res: VercelResponse) => { 94 | return router.init(req, res) 95 | } 96 | 97 | export async function initMongo(ctx: any) { 98 | const client = new MongoClient( 99 | getLocalConfig('MONGO_URI') || 'mongodb://localhost' 100 | ) 101 | const db = client.db(getLocalConfig('BLOGNOW_DB') || 'blog_now') 102 | try { 103 | await client.connect() 104 | } catch (e) { 105 | ctx.status = 501 106 | ctx.message = 107 | 'Unable to connect to the database or the MONGO_URI is incorrectly configured.' 108 | return false 109 | } 110 | console.log('DB connected') 111 | ctx.mongoClient = client 112 | ctx.db = db 113 | } 114 | 115 | export function initCol(ctx: any, colName: string) { 116 | ctx.col = ctx.db.collection(colName) 117 | } 118 | 119 | export async function closeMongo(ctx: any) { 120 | await ctx.mongoClient.close() 121 | console.log('DB closed') 122 | } 123 | 124 | export async function initUserData(ctx: any) { 125 | const token = getTokenFromReq(ctx.req) 126 | const col = (ctx.db as Db).collection(COLNAME.USER) 127 | const user = await col.findOne({ 128 | token, 129 | token_expires: { $gt: Date.now() }, 130 | }) 131 | ctx.user = getUserModel(user) 132 | } 133 | 134 | export async function checkLogin(ctx: any) { 135 | if (!ctx.user.uuid || ctx.user.uid < 0) { 136 | ctx.status = 401 137 | ctx.message = 'Please login' 138 | return false 139 | } 140 | } 141 | 142 | export function checkAuth(required: number, ctx: any) { 143 | if (ctx.user.authority < required) { 144 | ctx.status = 403 145 | ctx.message = 'Permission denied' 146 | ctx.body = { 147 | authcheck: { 148 | required, 149 | recived: ctx.user.authority, 150 | }, 151 | } 152 | return false 153 | } 154 | } 155 | 156 | export function handleInvalidController(http: HandleResponse) { 157 | return http.send(400, `Invalid controller: ${http.req.query.CONTROLLER}`, {}) 158 | } 159 | 160 | export function handleInvalidScope(http: HandleResponse) { 161 | return http.send(400, `Invalid scope: ${http.req.query.SCOPE}`, {}) 162 | } 163 | 164 | export function handleMissingParams(http: HandleResponse) { 165 | http.send(400, 'Missing params') 166 | } 167 | 168 | export function getTokenFromReq(req: VercelRequest) { 169 | return ( 170 | (req.query.token as string) || 171 | req.headers.authorization || 172 | req.cookies[TOKEN_COOKIE_NAME] || 173 | '' 174 | ) 175 | } 176 | 177 | export async function attachUsers( 178 | ctx: { db: Db }, 179 | docs: T[] 180 | ): Promise<(T & ApiAttachedUser)[]> { 181 | if (docs.length < 1) return [] 182 | const findList: string[] = [] 183 | docs.forEach(({ author_uuid, editor_uuid }) => { 184 | findList.push(author_uuid, editor_uuid) 185 | }) 186 | const users = (await ctx.db 187 | .collection(COLNAME.USER) 188 | .find({ 189 | $or: unique(findList) 190 | .filter((i) => !!i) 191 | .map((uuid) => ({ uuid })), 192 | }) 193 | .toArray()) as DbUserDoc[] 194 | return docs.map((i: any) => { 195 | i.author = getUserModel( 196 | users.find(({ uuid }) => uuid === i.author_uuid), 197 | true 198 | ) 199 | i.editor = getUserModel( 200 | users.find(({ uuid }) => uuid === i.editor_uuid), 201 | true 202 | ) 203 | return i 204 | }) 205 | } 206 | 207 | export function sortKeys(obj: T): T { 208 | const copy = {} as T 209 | const allKeys = Object.keys(obj).sort() 210 | allKeys.forEach((key) => { 211 | copy[key] = obj[key] 212 | }) 213 | return copy 214 | } 215 | 216 | export function unique(arr: T[]) { 217 | return Array.from(new Set(arr)) 218 | } 219 | -------------------------------------------------------------------------------- /public/images/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 295 | -------------------------------------------------------------------------------- /api/post.ts: -------------------------------------------------------------------------------- 1 | import { v4 as UUID } from 'uuid' 2 | import { DbPostDoc, DbUserDoc } from '../src/types/Database' 3 | import { COLNAME } from './config' 4 | import { attachUsers, router, sortKeys } from './utils' 5 | import slugify from 'slugify' 6 | import { VercelRequest, VercelResponse } from '@vercel/node' 7 | import { RouteContextDefaults } from 'serverless-kit' 8 | import { Db } from 'mongodb' 9 | 10 | export const POSTDATA_DEFAULTS: DbPostDoc = { 11 | uuid: '', 12 | pid: 0, 13 | slug: '', 14 | title: '', 15 | content: '', 16 | created_at: new Date(0), 17 | author_uuid: '', 18 | edited_at: new Date(0), 19 | editor_uuid: '', 20 | allow_comment: true, 21 | is_deleted: false, 22 | deleted_by: '', 23 | is_private: false, 24 | allowed_users: [], 25 | allowed_authority: 0, 26 | } 27 | 28 | export function getPostModel(payload: Partial) { 29 | const post = sortKeys({ 30 | ...POSTDATA_DEFAULTS, 31 | ...payload, 32 | }) 33 | post.slug = slugify(post.slug, { lower: true }) 34 | post.created_at = new Date(post.created_at) 35 | post.edited_at = 36 | new Date(post.edited_at).getTime() !== 0 37 | ? new Date(post.edited_at) 38 | : new Date(post.created_at) 39 | return post 40 | } 41 | 42 | export default (req: VercelRequest, res: VercelResponse) => { 43 | router.endpoint('/api/post') 44 | router.setCollection(COLNAME.POST) 45 | 46 | // GET /post/:selector/:scope 47 | router 48 | .addRoute() 49 | .method('GET') 50 | .path(['uuid', 'pid', 'slug'], 'selector') 51 | .path(/.+/, 'target') 52 | .action(async (ctx) => { 53 | const filter = { 54 | [ctx.params.selector]: 55 | ctx.params.selector === 'pid' 56 | ? parseInt(ctx.params.target) 57 | : ctx.params.target, 58 | } 59 | 60 | const post = await ctx.col.findOne(filter) 61 | 62 | if (!post) { 63 | ctx.status = 404 64 | ctx.message = 'Post not found' 65 | ctx.body = { 66 | filter, 67 | post: null, 68 | } 69 | return 70 | } else { 71 | ctx.message = 'Get post by filter' 72 | } 73 | 74 | const [post1] = await attachUsers(ctx, [post]) 75 | 76 | ctx.body = { post: post1, filter } 77 | }) 78 | 79 | // GET /post/list/recent 80 | router 81 | .addRoute() 82 | .method('GET') 83 | .path('list') 84 | .path(/recents?/) 85 | .check<{ 86 | offset: number 87 | limit: number 88 | }>((ctx) => { 89 | ctx.offset = parseInt((ctx.req.query.offset as string) || '0') 90 | ctx.limit = Math.min( 91 | 25, 92 | parseInt((ctx.req.query.limit as string) || '10') 93 | ) 94 | }) 95 | .action(async (ctx) => { 96 | const posts = await ctx.col 97 | .find() 98 | .sort({ pid: -1 }) 99 | .skip(ctx.offset) 100 | .limit(ctx.limit) 101 | .toArray() 102 | 103 | let has_next = false 104 | if (posts.length > ctx.limit) { 105 | has_next = true 106 | posts.pop() 107 | } 108 | 109 | ctx.body = { 110 | posts: await attachUsers(ctx, posts.map(getPostModel)), 111 | has_next, 112 | limit: ctx.limit, 113 | offset: ctx.offset, 114 | } 115 | }) 116 | 117 | // POST /post/create 118 | router 119 | .addRoute() 120 | .method('POST') 121 | .path(['create', 'new']) 122 | .checkLogin() 123 | .checkAuth(2) 124 | .check((ctx) => { 125 | ctx.req.body = ctx.req.body || {} 126 | const { title, content } = ctx.req.body 127 | if (title === undefined || content === undefined) { 128 | ctx.status = 400 129 | ctx.message = 'Missing params' 130 | return false 131 | } 132 | }) 133 | .check(async (ctx) => { 134 | ctx.req.body.slug = slugify(ctx.req.body.slug || '', { lower: true }) 135 | const slug = ctx.req.body.slug 136 | if (slug && (await ctx.col.findOne({ slug }))) { 137 | ctx.status = 409 138 | ctx.message = 'Slug has been taken' 139 | return false 140 | } 141 | }) 142 | .action(async (ctx) => { 143 | const { title, content, slug } = ctx.req.body 144 | const now = new Date() 145 | 146 | const [lastPost] = await ctx.col 147 | .find() 148 | .project({ pid: 1 }) 149 | .sort({ pid: -1 }) 150 | .limit(1) 151 | .toArray() 152 | 153 | const pid = isNaN(lastPost?.pid) 154 | ? (await ctx.col.countDocuments()) + 1 155 | : (lastPost.pid as number) + 1 156 | const uuid = UUID() 157 | 158 | const insert: DbPostDoc = getPostModel({ 159 | uuid, 160 | pid, 161 | title, 162 | content, 163 | slug, 164 | author_uuid: ctx.user.uuid, 165 | created_at: now, 166 | }) 167 | const dbRes = await ctx.col.insertOne(insert) 168 | 169 | ctx.message = 'Post created' 170 | ctx.body = { ...dbRes, uuid } 171 | }) 172 | 173 | // PATCH /post/:selector/:scope 174 | router 175 | .addRoute() 176 | .method('PATCH') 177 | .path(['uuid', 'pid', 'slug'], 'selector') 178 | .path(/.+/, 'target') 179 | .checkLogin() 180 | // Validate body 181 | .check((ctx) => { 182 | ctx.req.body = ctx.req.body || {} 183 | const { title, content } = ctx.req.body 184 | if (title === undefined || content === undefined) { 185 | ctx.status = 400 186 | ctx.message = 'Missing params' 187 | return false 188 | } 189 | }) 190 | // Find target post 191 | .check<{ 192 | post: DbPostDoc 193 | }>(async (ctx) => { 194 | const filter = { 195 | [ctx.params.selector]: 196 | ctx.params.selector === 'pid' 197 | ? parseInt(ctx.params.target) 198 | : ctx.params.target, 199 | } 200 | ctx.post = (await ctx.col.findOne(filter)) as DbPostDoc 201 | 202 | if (!ctx.post) { 203 | ctx.status = 404 204 | ctx.message = 'Post not found' 205 | return false 206 | } 207 | }) 208 | // User authentication 209 | .check((ctx) => { 210 | if (ctx.post.author_uuid !== ctx.user.uuid && ctx.user.authority < 4) { 211 | ctx.status = 403 212 | ctx.message = 'Permission denied' 213 | return false 214 | } 215 | }) 216 | // Check slug conflict 217 | .check(async (ctx) => { 218 | ctx.req.body.slug = slugify(ctx.req.body.slug || '', { lower: true }) 219 | const slug = ctx.req.body.slug 220 | 221 | if (slug && slug !== ctx.post.slug && (await ctx.col.findOne({ slug }))) { 222 | ctx.status = 409 223 | ctx.message = 'Slug has been taken' 224 | return false 225 | } 226 | }) 227 | // Update db 228 | .action(async (ctx) => { 229 | const { title, content, slug } = ctx.req.body 230 | const now = new Date() 231 | 232 | const dbRes = await ctx.col.updateOne( 233 | { uuid: ctx.post.uuid }, 234 | { 235 | $set: { 236 | title, 237 | content, 238 | slug, 239 | edited_at: now.toISOString(), 240 | editor_uuid: ctx.user.uuid, 241 | }, 242 | } 243 | ) 244 | 245 | ctx.message = 'Post updated' 246 | ctx.body = { 247 | ...dbRes, 248 | uuid: ctx.post.uuid, 249 | pid: ctx.post.pid, 250 | slug: slug ?? ctx.post.slug, 251 | } 252 | }) 253 | 254 | return router.init(req, res) 255 | } 256 | 257 | async function checkCanView( 258 | ctx: RouteContextDefaults & { db: Db; user: DbUserDoc; post: DbPostDoc } 259 | ) { 260 | let display_reason 261 | 262 | if (ctx.post.is_deleted) { 263 | if ( 264 | ctx.user.uuid === ctx.post.author_uuid && 265 | ctx.user.uuid === ctx.post.deleted_by 266 | ) { 267 | display_reason = 'deleted_by_self' 268 | } else if (ctx.user.authority >= 3) { 269 | display_reason = 'moderator' 270 | } else { 271 | ctx.status = 404 272 | ctx.message = 'Post not found' 273 | return false 274 | } 275 | } 276 | 277 | if (ctx.post.is_private) { 278 | if (ctx.user.uuid === ctx.post.author_uuid) { 279 | display_reason = 'author' 280 | } else if (ctx.post.allowed_users.includes(ctx.user.uuid)) { 281 | display_reason = 'allowed_user' 282 | } else if (ctx.user.authority = 4) { 283 | display_reason = 'moderator' 284 | } else { 285 | ctx.status = 404 286 | ctx.message = 'Private post' 287 | return false 288 | } 289 | } 290 | 291 | ctx.customBody = { 292 | display_reason, 293 | } 294 | } 295 | 296 | async function checkCanDelete( 297 | ctx: RouteContextDefaults & { db: Db; user: DbUserDoc; post: DbPostDoc } 298 | ) { 299 | if (ctx.post.author_uuid === ctx.user.uuid) { 300 | // Is author 301 | } else { 302 | // Not author 303 | if (ctx.user.authority < 3) { 304 | ctx.status = 403 305 | ctx.message = 'Permission denied' 306 | return false 307 | } 308 | } 309 | 310 | if (ctx.post.is_deleted) { 311 | ctx.status = 406 312 | ctx.message = 'The post has already been deleted' 313 | return false 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /api/user.ts: -------------------------------------------------------------------------------- 1 | import { DbAuthorityKeys, DbUserDoc } from '../src/types/Database' 2 | import { COLNAME } from './config' 3 | import { attachUsers, router, unique } from './utils' 4 | import * as crypto from 'crypto' 5 | import { v4 as UUID } from 'uuid' 6 | import { nanoid } from 'nanoid' 7 | import { 8 | passwordStrength, 9 | defaultOptions as passwordStrengthOptions, 10 | } from 'check-password-strength' 11 | import { VercelRequest, VercelResponse } from '@vercel/node' 12 | import { getPostModel } from './post' 13 | 14 | /** 15 | * @desc authority ``` 16 | * 0: Everyone 17 | * 1: Member 18 | * 2: Editor 19 | * 3: Moderator 20 | * 4: System Operator 21 | * ``` 22 | */ 23 | export const AUTHORITY_DEFAULTS: Record = { 24 | post_create: 2, 25 | post_delete_any: 3, 26 | post_edit_any: 4, 27 | comment_create: 0, 28 | comment_delete_any: 3, 29 | comment_edit_any: 4, 30 | comment_protect: 3, 31 | user_admin: 4, 32 | user_block: 3, 33 | user_group_edit: 4, 34 | user_register: 0, 35 | user_unblock_self: 4, 36 | site_admin: 4, 37 | } 38 | 39 | export const TOKEN_COOKIE_NAME = 'BLOG_NOW_TOKEN' 40 | 41 | export const USERDATA_DEFAULTS: DbUserDoc = { 42 | allow_comment: true, 43 | authority: 0, 44 | avatar: '', 45 | created_at: new Date(0), 46 | gender: 'other', 47 | nickname: '', 48 | slogan: '', 49 | title: '', 50 | uid: -1, 51 | username: '', 52 | uuid: '', 53 | // sensitive 54 | email: '', 55 | password_hash: '', 56 | salt: '', 57 | token: '', 58 | token_expires: 0, 59 | } 60 | 61 | export const PASSWORD_STRENGTH: 0 | 1 | 2 | 3 = 1 62 | 63 | export function getUserModel( 64 | payload: Partial, 65 | removeSensitive?: boolean 66 | ) { 67 | const data: DbUserDoc & { avatar: string; not_exist?: boolean } = { 68 | ...USERDATA_DEFAULTS, 69 | ...payload, 70 | } 71 | 72 | data.created_at = new Date(data.created_at) 73 | 74 | // Handle avatar 75 | const email_hash = crypto 76 | .createHash('md5') 77 | .update(data.email || '') 78 | .digest('hex') 79 | data.avatar = `https://gravatar.loli.net/avatar/${email_hash}` 80 | 81 | if (data.uid < 0) data.not_exist = true 82 | 83 | if (removeSensitive) { 84 | delete data.email 85 | delete data.password_hash 86 | delete data.token 87 | delete data.token_expires 88 | delete data.salt 89 | } 90 | return data 91 | } 92 | 93 | // Utils 94 | function getPasswordHash(salt: string, password: string) { 95 | return crypto 96 | .createHash('sha256') 97 | .update(`salt=${salt},password=${password}`) 98 | .digest('hex') 99 | } 100 | 101 | function trimUsername(str: string) { 102 | return str 103 | .replace(/^[\s_\-\.~]+/, '') 104 | .replace(/[\s_\-\.~]+$/, '') 105 | .replace(/\s+/g, ' ') 106 | } 107 | 108 | export default (req: VercelRequest, res: VercelResponse) => { 109 | router.endpoint('/api/user') 110 | router.setCollection(COLNAME.USER) 111 | 112 | // Get single user 113 | router 114 | .addRoute() 115 | .method('GET') 116 | .path(['uuid', 'uid', 'username'], 'selector') 117 | .path(/.+/, 'target') 118 | .action(async (ctx) => { 119 | const filter = { 120 | [ctx.params.selector]: 121 | ctx.params.selector === 'uid' 122 | ? parseInt(ctx.params.target) 123 | : ctx.params.target, 124 | } 125 | 126 | const user = await ctx.col.findOne(filter) 127 | 128 | if (!user) { 129 | ctx.status = 404 130 | ctx.message = 'User not found' 131 | ctx.body = { 132 | filter, 133 | user: null, 134 | } 135 | return 136 | } 137 | 138 | ctx.status = 200 139 | ctx.message = 'Get user by filter' 140 | ctx.body = { 141 | user: getUserModel(user, true), 142 | filter, 143 | } 144 | }) 145 | 146 | // Get users list 147 | router 148 | .addRoute() 149 | .method('GET') 150 | .endpoint('/api/users') 151 | .path(['uuid', 'uid', 'username'], 'selector') 152 | .path(/.+/, 'rawList') 153 | .action(async (ctx) => { 154 | const list = unique(ctx.params.rawList.split(/[|,]/).map((i) => i.trim())) 155 | if (list.length > 25) { 156 | ctx.customBody = { 157 | info: 'Too many requests, only the first 25 users are returned', 158 | has_next: true, 159 | next_uuids: list.slice(25 - list.length), 160 | } 161 | } 162 | const find = { 163 | $or: list.slice(0, 25).map((i) => ({ 164 | [ctx.params.selector]: 165 | ctx.params.selector === 'uid' ? parseInt(i) : i, 166 | })), 167 | } 168 | const users = await ctx.col.find(find).toArray() 169 | ctx.message = 'Get users' 170 | ctx.body = { 171 | users: users.map((i) => getUserModel(i, true)), 172 | uuids: list.slice(0, 25), 173 | } 174 | }) 175 | 176 | // Verify current user 177 | router 178 | .addRoute() 179 | .method('GET') 180 | .path('auth') 181 | .path('profile') 182 | .check((ctx) => { 183 | if (!ctx.user.uuid || ctx.user.uid < 0) { 184 | ctx.status = 401 185 | ctx.message = 'Please login' 186 | ctx.body = { 187 | profile: getUserModel(ctx.user, true), 188 | } 189 | return false 190 | } 191 | }) 192 | .action(async (ctx) => { 193 | ctx.body = { 194 | profile: getUserModel(ctx.user, true), 195 | } 196 | }) 197 | 198 | router 199 | .addRoute() 200 | .method('POST') 201 | .path('auth') 202 | .path('register') 203 | .check((ctx) => { 204 | const { username, password } = ctx.req.body || {} 205 | if (!username || !password) { 206 | ctx.status = 400 207 | ctx.message = 'Missing params' 208 | return false 209 | } 210 | }) 211 | .check<{ 212 | username: string 213 | password: string 214 | }>((ctx) => { 215 | let { username, password } = ctx.req.body || {} 216 | 217 | // Trim username 218 | username = trimUsername(username) 219 | 220 | const usernameTest = /^[_\-\.~\s0-9A-Za-z\u0080-\uFFFF]+$/ 221 | if (!usernameTest.test(username) || username.length <= 5) { 222 | ctx.status = 400 223 | ctx.message = 'Invalid username.' 224 | ctx.body = { 225 | invalid_item: 'username', 226 | item_standard: username, 227 | item_test: false, 228 | item_required: { 229 | allowed_symbols: ['_', '-', '.', '~', ' '], 230 | regexp: usernameTest.toString(), 231 | min_length: 5, 232 | }, 233 | } 234 | return false 235 | } 236 | 237 | const passwordTest = passwordStrength(password) 238 | if (passwordTest.id < PASSWORD_STRENGTH) { 239 | ctx.status = 400 240 | ctx.message = 'Password is too weak.' 241 | ctx.body = { 242 | invalid_item: 'password', 243 | item_test: passwordTest, 244 | item_required: passwordStrengthOptions[PASSWORD_STRENGTH], 245 | } 246 | return false 247 | } 248 | 249 | ctx.username = username 250 | ctx.password = password 251 | }) 252 | .check(async (ctx) => { 253 | const { username } = ctx 254 | const already = await ctx.col.findOne({ 255 | username: new RegExp(username, 'i'), 256 | }) 257 | if (already) { 258 | ctx.status = 409 259 | ctx.message = 'Username has been taken' 260 | return false 261 | } 262 | }) 263 | .action(async (ctx) => { 264 | const { username, password } = ctx 265 | 266 | const [lastUser] = (await ctx.col 267 | .find() 268 | .sort({ uid: -1 }) 269 | .project({ uid: 1 }) 270 | .limit(1) 271 | .toArray()) as DbUserDoc[] 272 | const uid = isNaN(lastUser?.uid) 273 | ? (await ctx.col.countDocuments()) + 10000 274 | : lastUser.uid + 1 275 | 276 | const salt = nanoid(32) 277 | const insert: DbUserDoc = getUserModel({ 278 | authority: 1, 279 | username, 280 | uid, 281 | uuid: UUID(), 282 | password_hash: getPasswordHash(salt, password), 283 | salt, 284 | created_at: new Date(), 285 | }) 286 | 287 | const dbRes = await ctx.col.insertOne(insert) 288 | 289 | ctx.message = 'User created' 290 | ctx.body = { 291 | ...dbRes, 292 | username, 293 | } 294 | }) 295 | 296 | router 297 | .addRoute() 298 | .method('POST') 299 | .path('auth') 300 | .path(/(log-?in|sign-?in)/) 301 | .check((ctx) => { 302 | const { username, password } = ctx.req.body || {} 303 | if (!username || !password) { 304 | ctx.status = 400 305 | ctx.message = 'Missing params' 306 | return false 307 | } 308 | }) 309 | .action(async (ctx) => { 310 | const { username, password } = ctx.req.body || {} 311 | const profile = await ctx.col.findOne({ 312 | username, 313 | }) 314 | if (!profile) { 315 | ctx.status = 403 316 | ctx.message = 'Invalid username' 317 | return false 318 | } 319 | const password_hash = getPasswordHash(profile.salt, password) 320 | if (password_hash !== profile.password_hash) { 321 | ctx.status = 403 322 | ctx.message = 'Invalid password' 323 | return false 324 | } 325 | const token = 326 | profile.token_expires - Date.now() < 0 ? nanoid(32) : profile.token 327 | const token_expires = Date.now() + 7 * 24 * 60 * 60 * 1000 328 | await ctx.col.updateOne( 329 | { uuid: profile.uuid }, 330 | { $set: { token, token_expires } } 331 | ) 332 | 333 | ctx.res.setHeader( 334 | 'set-cookie', 335 | `${TOKEN_COOKIE_NAME}=${token}; expires=${new Date( 336 | token_expires 337 | ).toUTCString()}; path=/` 338 | ) 339 | 340 | ctx.body = { token, profile: getUserModel(profile, true) } 341 | }) 342 | 343 | // Get user posts 344 | router 345 | .addRoute() 346 | .method('GET') 347 | .path(['uuid', 'uid', 'username'], 'selector') 348 | .path(/.+/, 'target') 349 | .path(/posts?/) 350 | .check<{ 351 | offset: number 352 | limit: number 353 | }>((ctx) => { 354 | ctx.offset = parseInt((ctx.req.query.offset as string) || '0') 355 | ctx.limit = Math.min( 356 | 25, 357 | parseInt((ctx.req.query.limit as string) || '10') 358 | ) 359 | }) 360 | .check<{ author_uuid: string }>(async (ctx) => { 361 | const user = (await ctx.db.collection(COLNAME.USER).findOne({ 362 | [ctx.params.selector]: 363 | ctx.params.selector === 'uid' 364 | ? parseInt(ctx.params.target) 365 | : ctx.params.target, 366 | })) as DbUserDoc 367 | if (!user) { 368 | ctx.status = 404 369 | ctx.message = 'Reqested user not found' 370 | ctx.body = { 371 | posts: [], 372 | } 373 | return false 374 | } 375 | ctx.author_uuid = user.uuid 376 | }) 377 | .action(async (ctx) => { 378 | const posts = await ctx.db 379 | .collection(COLNAME.POST) 380 | .find({ author_uuid: ctx.author_uuid }) 381 | .sort({ pid: -1 }) 382 | .skip(ctx.offset) 383 | .limit(ctx.limit) 384 | .toArray() 385 | 386 | let has_next = false 387 | if (posts.length > ctx.limit) { 388 | has_next = true 389 | posts.pop() 390 | } 391 | 392 | ctx.body = { 393 | posts: await attachUsers(ctx, posts.map(getPostModel)), 394 | has_next, 395 | limit: ctx.limit, 396 | offset: ctx.offset, 397 | } 398 | }) 399 | 400 | return router.init(req, res) 401 | } 402 | -------------------------------------------------------------------------------- /src/view/post.vue: -------------------------------------------------------------------------------- 1 | 140 | 141 | 306 | 307 | 448 | 449 | 459 | --------------------------------------------------------------------------------