From 254eb7a9ead1d256de3bcae894130e081a68caed Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Tue, 27 Feb 2024 01:04:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=E6=96=B0=E5=A2=9E=20=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- theme/LICENSE | 21 ++ theme/README.md | 28 ++- theme/src/client/components/EncryptGlobal.vue | 192 ++++++++++++++++++ theme/src/client/components/EncryptPage.vue | 131 ++++++++++++ theme/src/client/components/Page.vue | 31 ++- theme/src/client/components/PostItem.vue | 13 ++ .../src/client/components/icons/IconLock.vue | 8 + theme/src/client/composables/encrypt.ts | 176 ++++++++++++++++ theme/src/client/layouts/Layout.vue | 38 ++-- theme/src/node/defaultOptions.ts | 10 + theme/src/node/plugins.ts | 7 +- theme/src/node/resolveEncrypt.ts | 59 ++++++ theme/src/node/theme.ts | 17 +- theme/src/shared/blog.ts | 2 + theme/src/shared/options/locale.ts | 58 ++++++ 15 files changed, 764 insertions(+), 27 deletions(-) create mode 100644 theme/LICENSE create mode 100644 theme/src/client/components/EncryptGlobal.vue create mode 100644 theme/src/client/components/EncryptPage.vue create mode 100644 theme/src/client/components/icons/IconLock.vue create mode 100644 theme/src/client/composables/encrypt.ts create mode 100644 theme/src/node/resolveEncrypt.ts diff --git a/theme/LICENSE b/theme/LICENSE new file mode 100644 index 00000000..9f677c90 --- /dev/null +++ b/theme/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (C) 2021 - PRESENT by pengzhanbo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/theme/README.md b/theme/README.md index 0625da47..fe9c0321 100644 --- a/theme/README.md +++ b/theme/README.md @@ -1,8 +1,20 @@ -# `vuepress-theme-plume` +# vuepress-theme-plume -一款基于 VuePress@2 的博客皮肤。 +

+ +

-[查看使用文档](https://pengzhanbo.cn/note/vuepress-theme-plume) +[![npm version](https://img.shields.io/npm/v/vuepress-theme-plume?color=32A9C3&labelColor=1B3C4A&label=npm)](https://www.npmjs.com/package/vuepress-theme-plume) +[![npm download](https://img.shields.io/npm/dy/vuepress-theme-plume?color=32A9C3&labelColor=1B3C4A&label=downloads)](https://www.npmjs.com/package/vuepress-theme-plume) +![GitHub License](https://img.shields.io/github/license/pengzhanbo/vuepress-theme-plume?color=32A9C3&labelColor=1B3C4A) + +一个简约的,干净的,容易上手的 vuepress 主题,适用于博客和文档。 + +开箱即用,仅需少量配置即可使用,让您更专注于 内容的创作,更好的表达你的想法,形成你的知识笔记。 + +内置了丰富的强大的功能,旨在让内容更具有表现力。 + +### [查看文档](https://pengzhanbo.cn/note/vuepress-theme-plume) ## Install @@ -28,3 +40,13 @@ export default defineUserConfig({ }) }) ``` + +### `plumeTheme(options)` + +__options__ : `PlumeThemeOptions` + +[查看 options 详细说明](https://pengzhanbo.cn/note/vuepress-theme-plume/theme-config/) + +## LICENSE + +[MIT](https://github.com/pengzhanbo/vuepress-theme-plume/blob/main/LICENSE) diff --git a/theme/src/client/components/EncryptGlobal.vue b/theme/src/client/components/EncryptGlobal.vue new file mode 100644 index 00000000..2b8a26b6 --- /dev/null +++ b/theme/src/client/components/EncryptGlobal.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/theme/src/client/components/EncryptPage.vue b/theme/src/client/components/EncryptPage.vue new file mode 100644 index 00000000..5464a54d --- /dev/null +++ b/theme/src/client/components/EncryptPage.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/theme/src/client/components/Page.vue b/theme/src/client/components/Page.vue index fe89761a..19b3bc27 100644 --- a/theme/src/client/components/Page.vue +++ b/theme/src/client/components/Page.vue @@ -5,20 +5,23 @@ import { useMediumZoom } from '@vuepress/plugin-medium-zoom/client' import { onContentUpdated } from '@vuepress-plume/plugin-content-update/client' import type { PlumeThemePageData } from '../../shared/index.js' import { useDarkMode, useSidebar } from '../composables/index.js' +import { usePageEncrypt } from '../composables/encrypt.js' import PageAside from './PageAside.vue' import PageFooter from './PageFooter.vue' import PageMeta from './PageMeta.vue' +import EncryptPage from './EncryptPage.vue' const { hasSidebar, hasAside } = useSidebar() const isDark = useDarkMode() const page = usePageData() +const { isPageDecrypted } = usePageEncrypt() + const hasComments = computed(() => { return page.value.frontmatter.comments !== false }) const zoom = useMediumZoom() - onContentUpdated(() => zoom?.refresh()) @@ -29,10 +32,11 @@ onContentUpdated(() => zoom?.refresh()) 'has-sidebar': hasSidebar, 'has-aside': hasAside, 'is-blog': page.isBlogPost, + 'with-encrypt': !isPageDecrypted, }" >
-
+
@@ -43,9 +47,12 @@ onContentUpdated(() => zoom?.refresh())
- - - + +
@@ -61,9 +68,14 @@ onContentUpdated(() => zoom?.refresh()) .plume-page { width: 100%; + min-height: calc(100vh - var(--vp-nav-height) - var(--vp-footer-height, 0px) - 49px); padding: 32px 24px 96px; } +.plume-page.with-encrypt { + padding: 32px 24px; +} + .container { width: 100%; margin: 0 auto; @@ -82,7 +94,8 @@ onContentUpdated(() => zoom?.refresh()) .aside-container { position: sticky; top: 0; - height: 100vh; + min-height: calc(100vh - var(--vp-footer-height, 0px)); + max-height: 100vh; padding-top: calc( var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 32px @@ -105,7 +118,7 @@ onContentUpdated(() => zoom?.refresh()) flex-direction: column; min-height: calc( - 100vh - (var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 32px) + 100vh - (var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 32px + var(--vp-footer-height, 0px)) ); padding-bottom: 32px; } @@ -135,6 +148,10 @@ onContentUpdated(() => zoom?.refresh()) } @media (min-width: 960px) { + .plume-page { + min-height: calc(100vh - var(--vp-nav-height) - var(--vp-footer-height, 0px)); + } + .plume-page, .plume-page.is-blog { padding: 32px 32px 0; diff --git a/theme/src/client/components/PostItem.vue b/theme/src/client/components/PostItem.vue index e9cdef6a..1978bc7c 100644 --- a/theme/src/client/components/PostItem.vue +++ b/theme/src/client/components/PostItem.vue @@ -6,6 +6,7 @@ import AutoLink from './AutoLink.vue' import IconClock from './icons/IconClock.vue' import IconFolder from './icons/IconFolder.vue' import IconTag from './icons/IconTag.vue' +import IconLock from './icons/IconLock.vue' const props = defineProps<{ post: PlumeThemeBlogPostItem @@ -40,6 +41,7 @@ const createTime = computed(() => > TOP
+ {{ post.title }} @@ -96,6 +98,16 @@ const createTime = computed(() => transition-property: color, background-color; } +.post-item .icon-lock { + width: 1em; + height: 1em; + margin-right: 8px; + margin-left: 3px; + color: var(--vp-c-text-3); + transition: var(--t-color); + transition-property: color; +} + .post-item h3 { display: flex; align-items: center; @@ -205,5 +217,6 @@ const createTime = computed(() => .plume-content :deep(p strong) { color: var(--vp-c-text-2); + transition: color var(--t-color); } diff --git a/theme/src/client/components/icons/IconLock.vue b/theme/src/client/components/icons/IconLock.vue new file mode 100644 index 00000000..05cdc7a8 --- /dev/null +++ b/theme/src/client/components/icons/IconLock.vue @@ -0,0 +1,8 @@ + diff --git a/theme/src/client/composables/encrypt.ts b/theme/src/client/composables/encrypt.ts new file mode 100644 index 00000000..e52cfb28 --- /dev/null +++ b/theme/src/client/composables/encrypt.ts @@ -0,0 +1,176 @@ +import { compareSync, genSaltSync } from 'bcrypt-ts/browser' +import { type Ref, computed } from 'vue' +import { hasOwn, useSessionStorage } from '@vueuse/core' +import { usePageData, useRoute } from 'vuepress/client' +import type { PlumeThemePageData } from '../../shared/index.js' + +declare const __PLUME_ENCRYPT_GLOBAL__: boolean +declare const __PLUME_ENCRYPT_SEPARATOR__: string +declare const __PLUME_ENCRYPT_ADMIN__: string +declare const __PLUME_ENCRYPT_KEYS__: string[] +declare const __PLUME_ENCRYPT_RULES__: Record + +const global = __PLUME_ENCRYPT_GLOBAL__ +const separator = __PLUME_ENCRYPT_SEPARATOR__ +const admin = __PLUME_ENCRYPT_ADMIN__ +const matches = __PLUME_ENCRYPT_KEYS__ +const rules = __PLUME_ENCRYPT_RULES__ + +const admins = admin.split(separator) + +const ruleList = Object.keys(rules).map(key => ({ + key, + match: matches[key] as string, + rules: rules[key].split(separator), +})) + +const storage = useSessionStorage('2a0a3d6afb2fdf1f', () => ({ + s: [genSaltSync(10), genSaltSync(10)] as const, + g: '' as string, + p: {} as Record, +})) + +function mergeHash(hash: string) { + const [left, right] = storage.value.s + return left + hash + right +} + +function splitHash(hash: string) { + const [left, right] = storage.value.s + if (!hash.startsWith(left) || !hash.endsWith(right)) + return '' + + return hash.slice(left.length, hash.length - right.length) +} + +const cache = new Map() +function compare(content: string, hash: string) { + const key = [content, hash].join(separator) + if (cache.has(key)) + return cache.get(key) + + const result = compareSync(content, hash) + cache.set(key, result) + return result +} + +export function useGlobalEncrypt(): { + isGlobalDecrypted: Ref + compareGlobal: (password: string) => boolean +} { + const isGlobalDecrypted = computed(() => { + if (!global) + return true + + const hash = splitHash(storage.value.g) + + return !!hash && admins.includes(hash) + }) + + function compareGlobal(password: string) { + if (!password) + return false + + for (const admin of admins) { + if (compare(password, admin)) { + storage.value.g = mergeHash(admin) + return true + } + } + + return false + } + + return { + isGlobalDecrypted, + compareGlobal, + } +} + +export function usePageEncrypt() { + const page = usePageData() + const route = useRoute() + + const hasPageEncrypt = computed(() => ruleList.length ? matches.some(toMatch) : false) + + const hashList = computed(() => ruleList.length + ? ruleList + .filter(item => toMatch(item.match)) + : []) + + const isPageDecrypted = computed(() => { + if (!hasPageEncrypt.value) + return true + + const hash = splitHash(storage.value.p.__GLOBAL__ || '') + if (hash && admins.includes(hash)) + return true + + for (const { key, rules } of hashList.value) { + if (hasOwn(storage.value.p, key)) { + const hash = splitHash(storage.value.p[key]) + if (hash && rules.includes(hash)) + return true + } + } + + return false + }) + + function toMatch(match: string) { + const relativePath = page.value.filePathRelative || '' + if (match[0] === '^') { + const regex = new RegExp(match) + return regex.test(route.path) || (relativePath && regex.test(relativePath)) + } + if (match.endsWith('.md')) + return relativePath && relativePath.endsWith(match) + + return route.path.startsWith(match) || relativePath.startsWith(match) + } + + function comparePage(password: string) { + if (!password) + return false + + let decrypted = false + + // check global + for (const admin of admins) { + if (compare(password, admin)) { + decrypted = true + storage.value.p = { + ...storage.value.p, + __GLOBAL__: mergeHash(admin), + } + break + } + } + // check page + if (!decrypted) { + for (const { match, key, rules } of hashList.value) { + if (toMatch(match)) { + for (const rule of rules) { + if (compare(password, rule)) { + decrypted = true + storage.value.p = { + ...storage.value.p, + [key]: mergeHash(rule), + } + break + } + } + if (decrypted) + break + } + } + } + + return decrypted + } + + return { + isPageDecrypted, + comparePage, + } +} diff --git a/theme/src/client/layouts/Layout.vue b/theme/src/client/layouts/Layout.vue index 4765c262..deb2d5c0 100644 --- a/theme/src/client/layouts/Layout.vue +++ b/theme/src/client/layouts/Layout.vue @@ -14,10 +14,12 @@ import Sidebar from '../components/Sidebar.vue' import SkipLink from '../components/SkipLink.vue' import VFooter from '../components/VFooter.vue' import BackToTop from '../components/BackToTop.vue' +import EncryptGlobal from '../components/EncryptGlobal.vue' import { useCloseSidebarOnEscape, useSidebar, } from '../composables/index.js' +import { useGlobalEncrypt, usePageEncrypt } from '../composables/encrypt.js' const page = usePageData() @@ -27,6 +29,9 @@ const { close: closeSidebar, } = useSidebar() +const { isGlobalDecrypted } = useGlobalEncrypt() +const { isPageDecrypted } = usePageEncrypt() + const route = useRoute() watch(() => route.path, closeSidebar) @@ -46,19 +51,26 @@ provide('is-sidebar-open', isSidebarOpen) diff --git a/theme/src/node/defaultOptions.ts b/theme/src/node/defaultOptions.ts index 1afe8c70..38f1940f 100644 --- a/theme/src/node/defaultOptions.ts +++ b/theme/src/node/defaultOptions.ts @@ -12,6 +12,11 @@ const defaultLocales: NonNullable = { lastUpdatedText: 'Last Updated', contributorsText: 'Contributors', appearanceText: 'Appearance', + + encryptButtonText: 'Confirm', + encryptPlaceholder: 'Enter password', + encryptGlobalText: 'Only password can access this site', + encryptPageText: 'Only password can access this page', }, 'zh-CN': { selectLanguageName: '简体中文', @@ -34,6 +39,11 @@ const defaultLocales: NonNullable = { quote: '但是,如果你不改变方向,并且一直寻找,最终可能会到达你要去的地方。', linkText: '返回首页', }, + + encryptButtonText: '确认', + encryptPlaceholder: '请输入密码', + encryptGlobalText: '本站只允许密码访问', + encryptPageText: '本页面只允许密码访问', }, } diff --git a/theme/src/node/plugins.ts b/theme/src/node/plugins.ts index 74a764d3..7e69e8d4 100644 --- a/theme/src/node/plugins.ts +++ b/theme/src/node/plugins.ts @@ -23,6 +23,7 @@ import { sitemapPlugin } from '@vuepress/plugin-sitemap' import { contentUpdatePlugin } from '@vuepress-plume/plugin-content-update' import { searchPlugin } from '@vuepress-plume/plugin-search' import type { + PlumeThemeEncrypt, PlumeThemeLocaleOptions, PlumeThemePluginOptions, } from '../shared/index.js' @@ -33,11 +34,13 @@ import { resolveNotesList } from './resolveNotesList.js' import { resolvedDocsearchOption, resolvedSearchOptions } from './searchPluginOptions.js' import { customContainers } from './container.js' import { BLOG_TAGS_COLORS_PRESET, generateBlogTagsColors } from './blogTags.js' +import { isEncryptPage } from './resolveEncrypt.js' export function setupPlugins( app: App, options: PlumeThemePluginOptions, localeOptions: PlumeThemeLocaleOptions, + encrypt?: PlumeThemeEncrypt, ): PluginConfig { const isProd = !app.env.isDev @@ -76,13 +79,15 @@ export function setupPlugins( extendBlogData: (page: any, extra) => { const tags = page.frontmatter.tags generateBlogTagsColors(extra.tagsColors, tags) - return { + const data: Record = { categoryList: page.data.categoryList, tags, sticky: page.frontmatter.sticky, createTime: page.data.frontmatter.createTime, lang: page.lang, } + isEncryptPage(page, encrypt) && (data.encrypt = true) + return data }, }), diff --git a/theme/src/node/resolveEncrypt.ts b/theme/src/node/resolveEncrypt.ts new file mode 100644 index 00000000..10b9ce9d --- /dev/null +++ b/theme/src/node/resolveEncrypt.ts @@ -0,0 +1,59 @@ +import { genSaltSync, hashSync } from 'bcrypt-ts' +import { isNumber, isString, random, toArray } from '@pengzhanbo/utils' +import type { Page } from 'vuepress/core' +import type { PlumeThemeEncrypt, PlumeThemePageData } from '../shared/index.js' + +const isStringLike = (value: unknown): boolean => isString(value) || isNumber(value) +const separator = ':' + +export function resolveEncrypt(encrypt?: PlumeThemeEncrypt) { + const salt = () => genSaltSync(random(8, 16)) + + const admin = encrypt?.admin + ? toArray(encrypt.admin) + .filter(isStringLike) + .map(item => hashSync(String(item), salt())) + .join(separator) + : '' + + const rules: Record = {} + const keys = Object.keys(encrypt?.rules ?? {}) + + if (encrypt?.rules) { + Object.keys(encrypt.rules).forEach((key) => { + const index = keys.indexOf(key) + + rules[String(index)] = toArray(encrypt.rules![key]) + .filter(isStringLike) + .map(item => hashSync(String(item), salt())) + .join(separator) + }) + } + + return { + __PLUME_ENCRYPT_GLOBAL__: encrypt?.global ?? false, + __PLUME_ENCRYPT_SEPARATOR__: separator, + __PLUME_ENCRYPT_ADMIN__: admin, + __PLUME_ENCRYPT_KEYS__: keys, + __PLUME_ENCRYPT_RULES__: rules, + } +} + +export function isEncryptPage(page: Page, encrypt?: PlumeThemeEncrypt) { + if (!encrypt) + return false + + const rules = encrypt.rules ?? {} + + return Object.keys(rules).some((match) => { + const relativePath = page.data.filePathRelative || '' + if (match[0] === '^') { + const regex = new RegExp(match) + return regex.test(page.path) || (relativePath && regex.test(relativePath)) + } + if (match.endsWith('.md')) + return relativePath && relativePath.endsWith(match) + + return page.path.startsWith(match) || relativePath.startsWith(match) + }) +} diff --git a/theme/src/node/theme.ts b/theme/src/node/theme.ts index 17c4e0e7..499abb1c 100644 --- a/theme/src/node/theme.ts +++ b/theme/src/node/theme.ts @@ -1,28 +1,39 @@ import type { Page, Theme } from 'vuepress/core' -import { templateRenderer } from 'vuepress/utils' +import { logger, templateRenderer } from 'vuepress/utils' import type { PlumeThemeOptions, PlumeThemePageData } from '../shared/index.js' import { mergeLocaleOptions } from './defaultOptions.js' import { setupPlugins } from './plugins.js' import { extendsPageData, setupPage } from './setupPages.js' import { getThemePackage, resolve, templates } from './utils.js' +import { resolveEncrypt } from './resolveEncrypt.js' const THEME_NAME = 'vuepress-theme-plume' export function plumeTheme({ themePlugins, plugins, + encrypt, ...localeOptions }: PlumeThemeOptions = {}): Theme { const pluginsOptions = plugins ?? themePlugins ?? {} const pkg = getThemePackage() + if (themePlugins) { + logger.warn( + `The 'themePlugins' option is deprecated. Please use 'plugins' instead.`, + ) + } + return (app) => { localeOptions = mergeLocaleOptions(app, localeOptions) return { name: THEME_NAME, + define: { + ...resolveEncrypt(encrypt), + }, templateBuild: templates('build.html'), clientConfigFile: resolve('client/config.js'), - plugins: setupPlugins(app, pluginsOptions, localeOptions), + plugins: setupPlugins(app, pluginsOptions, localeOptions, encrypt), onInitialized: app => setupPage(app, localeOptions), extendsPage: (page) => { extendsPageData(app, page as Page, localeOptions) @@ -44,7 +55,7 @@ export function plumeTheme({ if (um === 'dark' || (um !== 'light' && sm)) { document.documentElement.classList.add('dark'); } - })();`, + })();`.replace(/^\s+|\s+$/gm, '').replace(/\n/g, ''), ]) } diff --git a/theme/src/shared/blog.ts b/theme/src/shared/blog.ts index 7964b67c..8439c230 100644 --- a/theme/src/shared/blog.ts +++ b/theme/src/shared/blog.ts @@ -6,6 +6,8 @@ export interface PlumeThemeBlogPostItem extends BlogPostDataItem { sticky: boolean categoryLost: PageCategoryData[] createTime: string + lang: string + encrypt?: boolean } export type PlumeThemeBlogPostData = PlumeThemeBlogPostItem[] diff --git a/theme/src/shared/options/locale.ts b/theme/src/shared/options/locale.ts index d62c539c..2811ecb5 100644 --- a/theme/src/shared/options/locale.ts +++ b/theme/src/shared/options/locale.ts @@ -68,6 +68,9 @@ export interface PlumeThemeBlog { */ exclude?: string[] + /** + * 分页 + */ pagination?: false | { /** * 每页显示的文章数量 @@ -98,6 +101,37 @@ export interface PlumeThemeBlog { archives?: boolean } +export interface PlumeThemeEncrypt { + /** + * 是否启用全站加密 + * @default false + */ + global?: boolean + /** + * 超级权限密码, 该密码可以解密全站,以及任意加密的文章 + * + */ + admin?: string | string[] + + /** + * 文章密码, 可以通过 文章的 markdown 文件相对路径、页面访问路径、 + * 目录路径 等,对 单个文章 或者 整个目录 进行 加密。 + * 如果是以 `^` 开头,则被认为是类似于正则表达式进行匹配。 + * + * @example + * ```json + * { + * "前端/基础/html.md": "123", + * "/article/23c44c/": ["456", "789"], + * "^/note/(note1|note2)/": "123" + * } + * ``` + */ + rules?: { + [key: string]: string | string[] + } +} + export interface PlumeThemeLocaleData extends LocaleData { /** * 网站站点首页 @@ -296,4 +330,28 @@ export interface PlumeThemeLocaleData extends LocaleData { linkLabel?: string linkText?: string } + /** + * 加密 + */ + encrypt?: PlumeThemeEncrypt + + /** + * 全站加密时的提示 + */ + encryptGlobalText?: string + + /** + * 文章加密时的提示 + */ + encryptPageText?: string + + /** + * 加密确认按钮文本 + */ + encryptButtonText?: string + + /** + * 加密时输入框的 placeholder + */ + encryptPlaceholder?: string }