feat(theme): 新增 加密 功能

This commit is contained in:
pengzhanbo 2024-02-27 01:04:13 +08:00
parent ba899ec7ca
commit 254eb7a9ea
15 changed files with 764 additions and 27 deletions

21
theme/LICENSE Normal file
View File

@ -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.

View File

@ -1,8 +1,20 @@
# `vuepress-theme-plume` # vuepress-theme-plume
一款基于 VuePress@2 的博客皮肤。 <p align="center">
<img src="https://github.com/pengzhanbo/vuepress-theme-plume/raw/main/docs/plume.svg" width="200px" />
</p>
[查看使用文档](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 ## 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)

View File

@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSiteLocaleData } from 'vuepress/client'
import { useThemeLocaleData } from '../composables/index.js'
import { useGlobalEncrypt } from '../composables/encrypt.js'
import IconLock from './icons/IconLock.vue'
import VFooter from './VFooter.vue'
const theme = useThemeLocaleData()
const siteData = useSiteLocaleData()
const { compareGlobal } = useGlobalEncrypt()
const avatar = computed(() => theme.value.avatar)
const title = computed(() => avatar.value?.name || siteData.value.title)
const password = ref('')
const errorCode = ref(0) // 0: no error, 1: wrong password
function compare() {
const result = compareGlobal(password.value)
if (!result) {
errorCode.value = 1
}
else {
errorCode.value = 0
password.value = ''
}
}
</script>
<template>
<div class="global-encrypt-wrapper">
<div class="global-encrypt-container">
<div v-if="avatar || title" class="profile">
<p v-if="avatar" class="avatar" :class="{ circle: avatar.circle }">
<img :src="avatar.url" :alt="avatar.name">
</p>
<h3 v-if="title">
{{ title }}
</h3>
</div>
<div class="encrypt">
<p class="encrypt-text" v-html="theme.encryptGlobalText ?? 'Only Password can access this site'" />
<p class="encrypt-input-wrapper">
<IconLock class="icon icon-lock" />
<input
v-model="password"
class="encrypt-input"
:class="{ error: errorCode === 1 }"
type="password"
:placeholder="theme.encryptPlaceholder ?? 'Enter Password'"
@keyup.enter="compare"
@input="password && (errorCode = 0)"
>
</p>
<button class="encrypt-button" @click="compare">
{{ theme.encryptButtonText ?? 'Confirm' }}
</button>
</div>
</div>
</div>
<VFooter />
</template>
<style scoped>
.global-encrypt-wrapper {
display: flex;
flex: 1;
width: 100%;
overflow-y: auto;
background-color: var(--vp-c-bg);
transition: background-color var(--t-color);
}
@media (min-width: 768px) {
.global-encrypt-wrapper {
align-items: center;
justify-content: center;
background-color: var(--vp-c-bg-soft);
}
}
.global-encrypt-container {
width: 100%;
padding: 0 20px;
margin-top: 40px;
}
.profile {
padding-bottom: 20px;
margin-bottom: 60px;
border-bottom: solid 1px var(--vp-c-divider);
}
@media (min-width: 768px) {
.global-encrypt-container {
width: 400px;
padding: 20px;
margin-top: -40px;
background-color: var(--vp-c-bg);
border-radius: 8px;
box-shadow: var(--vp-shadow-2);
}
.profile {
margin-bottom: 40px;
}
}
.avatar {
margin-bottom: 16px;
}
.avatar img {
width: 120px;
margin: auto;
object-fit: cover;
}
.avatar.circle img {
overflow: hidden;
border-radius: 50%;
}
.profile h3 {
font-weight: 500;
line-height: 1.5;
color: var(--vp-c-text-1);
text-align: center;
transition: color var(--t-color);
}
.encrypt {
margin-top: 20px;
}
.encrypt-text {
margin-top: 40px;
margin-bottom: 30px;
color: var(--vp-c-text-1);
text-align: center;
}
.encrypt-input-wrapper {
position: relative;
}
.icon-lock {
position: absolute;
top: 10px;
left: 10px;
color: var(--vp-c-border);
}
.encrypt-input {
width: 100%;
padding: 8px 12px 8px 32px;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-border);
border-radius: 4px;
outline: none;
transition: border-color var(--t-color), background-color var(--t-color);
}
.encrypt-input:focus {
border-color: var(--vp-c-brand-1);
}
.encrypt-input.error {
border-color: var(--vp-c-danger-3);
}
.encrypt-button {
width: 100%;
padding: 8px 12px;
margin-top: 20px;
font-weight: 500;
color: var(--vp-c-white);
cursor: pointer;
background-color: var(--vp-c-brand-1);
border: none;
border-radius: 4px;
outline: none;
transition: background-color var(--t-color);
}
.encrypt-button:hover {
background-color: var(--vp-c-brand-2);
}
</style>

View File

@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref } from 'vue'
import { usePageEncrypt } from '../composables/encrypt.js'
import { useThemeLocaleData } from '../composables/index.js'
import IconLock from './icons/IconLock.vue'
const theme = useThemeLocaleData()
const { comparePage } = usePageEncrypt()
const password = ref('')
const errorCode = ref(0) // 0: no error, 1: wrong password
function compare() {
const result = comparePage(password.value)
if (!result) {
errorCode.value = 1
}
else {
errorCode.value = 0
password.value = ''
}
}
</script>
<template>
<div class="page-encrypt-wrapper">
<div class="logo">
<IconLock class="icon icon-lock-head" />
</div>
<div class="encrypt">
<p class="encrypt-text" v-html="theme.encryptPageText ?? 'Only Password can access this page'" />
<p class="encrypt-input-wrapper">
<IconLock class="icon icon-lock" />
<input
v-model="password"
class="encrypt-input"
:class="{ error: errorCode === 1 }"
type="password"
:placeholder="theme.encryptPlaceholder ?? 'Enter Password'"
@keyup.enter="compare"
@input="password && (errorCode = 0)"
>
</p>
<button class="encrypt-button" @click="compare">
{{ theme.encryptButtonText ?? 'Confirm' }}
</button>
</div>
</div>
</template>
<style scoped>
.icon-lock-head {
width: 64px;
height: 64px;
margin: auto;
}
@media (min-width: 768px) {
.page-encrypt-wrapper {
width: 400px;
padding: 20px;
margin: 40px auto 0;
background-color: var(--vp-c-bg-alt);
border-radius: 8px;
box-shadow: var(--vp-shadow-2);
transition: var(--t-color);
transition-property: box-shadow, background-color;
}
}
.encrypt {
margin-top: 20px;
}
.encrypt-text {
margin-top: 20px;
margin-bottom: 30px;
color: var(--vp-c-text-1);
text-align: center;
transition: color var(--t-color);
}
.encrypt-input-wrapper {
position: relative;
}
.icon-lock {
position: absolute;
top: 10px;
left: 10px;
color: var(--vp-c-border);
transition: color var(--t-color);
}
.encrypt-input {
width: 100%;
padding: 8px 12px 8px 32px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-border);
border-radius: 4px;
outline: none;
transition: border-color var(--t-color), background-color var(--t-color);
}
.encrypt-input:focus {
border-color: var(--vp-c-brand-1);
}
.encrypt-input.error {
border-color: var(--vp-c-danger-3);
}
.encrypt-button {
width: 100%;
padding: 8px 12px;
margin-top: 20px;
font-weight: 500;
color: var(--vp-c-white);
cursor: pointer;
background-color: var(--vp-c-brand-1);
border: none;
border-radius: 4px;
outline: none;
transition: background-color var(--t-color);
}
.encrypt-button:hover {
background-color: var(--vp-c-brand-2);
}
</style>

View File

@ -5,20 +5,23 @@ import { useMediumZoom } from '@vuepress/plugin-medium-zoom/client'
import { onContentUpdated } from '@vuepress-plume/plugin-content-update/client' import { onContentUpdated } from '@vuepress-plume/plugin-content-update/client'
import type { PlumeThemePageData } from '../../shared/index.js' import type { PlumeThemePageData } from '../../shared/index.js'
import { useDarkMode, useSidebar } from '../composables/index.js' import { useDarkMode, useSidebar } from '../composables/index.js'
import { usePageEncrypt } from '../composables/encrypt.js'
import PageAside from './PageAside.vue' import PageAside from './PageAside.vue'
import PageFooter from './PageFooter.vue' import PageFooter from './PageFooter.vue'
import PageMeta from './PageMeta.vue' import PageMeta from './PageMeta.vue'
import EncryptPage from './EncryptPage.vue'
const { hasSidebar, hasAside } = useSidebar() const { hasSidebar, hasAside } = useSidebar()
const isDark = useDarkMode() const isDark = useDarkMode()
const page = usePageData<PlumeThemePageData>() const page = usePageData<PlumeThemePageData>()
const { isPageDecrypted } = usePageEncrypt()
const hasComments = computed(() => { const hasComments = computed(() => {
return page.value.frontmatter.comments !== false return page.value.frontmatter.comments !== false
}) })
const zoom = useMediumZoom() const zoom = useMediumZoom()
onContentUpdated(() => zoom?.refresh()) onContentUpdated(() => zoom?.refresh())
</script> </script>
@ -29,10 +32,11 @@ onContentUpdated(() => zoom?.refresh())
'has-sidebar': hasSidebar, 'has-sidebar': hasSidebar,
'has-aside': hasAside, 'has-aside': hasAside,
'is-blog': page.isBlogPost, 'is-blog': page.isBlogPost,
'with-encrypt': !isPageDecrypted,
}" }"
> >
<div class="container"> <div class="container">
<div v-if="hasAside" class="aside"> <div v-if="hasAside && isPageDecrypted" class="aside">
<div class="aside-container"> <div class="aside-container">
<div class="aside-content"> <div class="aside-content">
<PageAside /> <PageAside />
@ -43,9 +47,12 @@ onContentUpdated(() => zoom?.refresh())
<div class="content-container"> <div class="content-container">
<main class="main"> <main class="main">
<PageMeta /> <PageMeta />
<EncryptPage v-if="!isPageDecrypted" />
<template v-else>
<Content class="plume-content" /> <Content class="plume-content" />
<PageFooter /> <PageFooter />
<PageComment v-if="hasComments" :darkmode="isDark" /> <PageComment v-if="hasComments" :darkmode="isDark" />
</template>
</main> </main>
</div> </div>
</div> </div>
@ -61,9 +68,14 @@ onContentUpdated(() => zoom?.refresh())
.plume-page { .plume-page {
width: 100%; width: 100%;
min-height: calc(100vh - var(--vp-nav-height) - var(--vp-footer-height, 0px) - 49px);
padding: 32px 24px 96px; padding: 32px 24px 96px;
} }
.plume-page.with-encrypt {
padding: 32px 24px;
}
.container { .container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
@ -82,7 +94,8 @@ onContentUpdated(() => zoom?.refresh())
.aside-container { .aside-container {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; min-height: calc(100vh - var(--vp-footer-height, 0px));
max-height: 100vh;
padding-top: padding-top:
calc( calc(
var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 32px var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 32px
@ -105,7 +118,7 @@ onContentUpdated(() => zoom?.refresh())
flex-direction: column; flex-direction: column;
min-height: min-height:
calc( 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; padding-bottom: 32px;
} }
@ -135,6 +148,10 @@ onContentUpdated(() => zoom?.refresh())
} }
@media (min-width: 960px) { @media (min-width: 960px) {
.plume-page {
min-height: calc(100vh - var(--vp-nav-height) - var(--vp-footer-height, 0px));
}
.plume-page, .plume-page,
.plume-page.is-blog { .plume-page.is-blog {
padding: 32px 32px 0; padding: 32px 32px 0;

View File

@ -6,6 +6,7 @@ import AutoLink from './AutoLink.vue'
import IconClock from './icons/IconClock.vue' import IconClock from './icons/IconClock.vue'
import IconFolder from './icons/IconFolder.vue' import IconFolder from './icons/IconFolder.vue'
import IconTag from './icons/IconTag.vue' import IconTag from './icons/IconTag.vue'
import IconLock from './icons/IconLock.vue'
const props = defineProps<{ const props = defineProps<{
post: PlumeThemeBlogPostItem post: PlumeThemeBlogPostItem
@ -40,6 +41,7 @@ const createTime = computed(() =>
> >
TOP TOP
</div> </div>
<IconLock v-if="post.encrypt" class="icon-lock" />
<AutoLink :href="post.path"> <AutoLink :href="post.path">
{{ post.title }} {{ post.title }}
</AutoLink> </AutoLink>
@ -96,6 +98,16 @@ const createTime = computed(() =>
transition-property: color, background-color; 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 { .post-item h3 {
display: flex; display: flex;
align-items: center; align-items: center;
@ -205,5 +217,6 @@ const createTime = computed(() =>
.plume-content :deep(p strong) { .plume-content :deep(p strong) {
color: var(--vp-c-text-2); color: var(--vp-c-text-2);
transition: color var(--t-color);
} }
</style> </style>

View File

@ -0,0 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M18 8h-1V7c0-2.757-2.243-5-5-5S7 4.243 7 7v1H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2M9 7c0-1.654 1.346-3 3-3s3 1.346 3 3v1H9zm4 8.723V18h-2v-2.277c-.595-.346-1-.984-1-1.723a2 2 0 1 1 4 0c0 .738-.405 1.376-1 1.723"
/>
</svg>
</template>

View File

@ -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<string, string>
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<string, string>,
}))
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<string, boolean>()
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<boolean>
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<PlumeThemePageData>()
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,
}
}

View File

@ -14,10 +14,12 @@ import Sidebar from '../components/Sidebar.vue'
import SkipLink from '../components/SkipLink.vue' import SkipLink from '../components/SkipLink.vue'
import VFooter from '../components/VFooter.vue' import VFooter from '../components/VFooter.vue'
import BackToTop from '../components/BackToTop.vue' import BackToTop from '../components/BackToTop.vue'
import EncryptGlobal from '../components/EncryptGlobal.vue'
import { import {
useCloseSidebarOnEscape, useCloseSidebarOnEscape,
useSidebar, useSidebar,
} from '../composables/index.js' } from '../composables/index.js'
import { useGlobalEncrypt, usePageEncrypt } from '../composables/encrypt.js'
const page = usePageData<PlumeThemePageData>() const page = usePageData<PlumeThemePageData>()
@ -27,6 +29,9 @@ const {
close: closeSidebar, close: closeSidebar,
} = useSidebar() } = useSidebar()
const { isGlobalDecrypted } = useGlobalEncrypt()
const { isPageDecrypted } = usePageEncrypt()
const route = useRoute() const route = useRoute()
watch(() => route.path, closeSidebar) watch(() => route.path, closeSidebar)
@ -46,10 +51,16 @@ provide('is-sidebar-open', isSidebarOpen)
<template> <template>
<div class="theme-plume"> <div class="theme-plume">
<EncryptGlobal v-if="!isGlobalDecrypted" />
<template v-else>
<SkipLink /> <SkipLink />
<Backdrop :show="isSidebarOpen" @click="closeSidebar" /> <Backdrop :show="isSidebarOpen" @click="closeSidebar" />
<Nav /> <Nav />
<LocalNav :open="isSidebarOpen" @open-menu="openSidebar" /> <LocalNav
:open="isSidebarOpen"
:show-outline="isPageDecrypted"
@open-menu="openSidebar"
/>
<Sidebar :open="isSidebarOpen" /> <Sidebar :open="isSidebarOpen" />
<LayoutContent> <LayoutContent>
<Home v-if="page.frontmatter.home" /> <Home v-if="page.frontmatter.home" />
@ -59,6 +70,7 @@ provide('is-sidebar-open', isSidebarOpen)
<BackToTop /> <BackToTop />
<VFooter /> <VFooter />
</LayoutContent> </LayoutContent>
</template>
</div> </div>
</template> </template>

View File

@ -12,6 +12,11 @@ const defaultLocales: NonNullable<PlumeThemeLocaleOptions['locales']> = {
lastUpdatedText: 'Last Updated', lastUpdatedText: 'Last Updated',
contributorsText: 'Contributors', contributorsText: 'Contributors',
appearanceText: 'Appearance', appearanceText: 'Appearance',
encryptButtonText: 'Confirm',
encryptPlaceholder: 'Enter password',
encryptGlobalText: 'Only password can access this site',
encryptPageText: 'Only password can access this page',
}, },
'zh-CN': { 'zh-CN': {
selectLanguageName: '简体中文', selectLanguageName: '简体中文',
@ -34,6 +39,11 @@ const defaultLocales: NonNullable<PlumeThemeLocaleOptions['locales']> = {
quote: '但是,如果你不改变方向,并且一直寻找,最终可能会到达你要去的地方。', quote: '但是,如果你不改变方向,并且一直寻找,最终可能会到达你要去的地方。',
linkText: '返回首页', linkText: '返回首页',
}, },
encryptButtonText: '确认',
encryptPlaceholder: '请输入密码',
encryptGlobalText: '本站只允许密码访问',
encryptPageText: '本页面只允许密码访问',
}, },
} }

View File

@ -23,6 +23,7 @@ import { sitemapPlugin } from '@vuepress/plugin-sitemap'
import { contentUpdatePlugin } from '@vuepress-plume/plugin-content-update' import { contentUpdatePlugin } from '@vuepress-plume/plugin-content-update'
import { searchPlugin } from '@vuepress-plume/plugin-search' import { searchPlugin } from '@vuepress-plume/plugin-search'
import type { import type {
PlumeThemeEncrypt,
PlumeThemeLocaleOptions, PlumeThemeLocaleOptions,
PlumeThemePluginOptions, PlumeThemePluginOptions,
} from '../shared/index.js' } from '../shared/index.js'
@ -33,11 +34,13 @@ import { resolveNotesList } from './resolveNotesList.js'
import { resolvedDocsearchOption, resolvedSearchOptions } from './searchPluginOptions.js' import { resolvedDocsearchOption, resolvedSearchOptions } from './searchPluginOptions.js'
import { customContainers } from './container.js' import { customContainers } from './container.js'
import { BLOG_TAGS_COLORS_PRESET, generateBlogTagsColors } from './blogTags.js' import { BLOG_TAGS_COLORS_PRESET, generateBlogTagsColors } from './blogTags.js'
import { isEncryptPage } from './resolveEncrypt.js'
export function setupPlugins( export function setupPlugins(
app: App, app: App,
options: PlumeThemePluginOptions, options: PlumeThemePluginOptions,
localeOptions: PlumeThemeLocaleOptions, localeOptions: PlumeThemeLocaleOptions,
encrypt?: PlumeThemeEncrypt,
): PluginConfig { ): PluginConfig {
const isProd = !app.env.isDev const isProd = !app.env.isDev
@ -76,13 +79,15 @@ export function setupPlugins(
extendBlogData: (page: any, extra) => { extendBlogData: (page: any, extra) => {
const tags = page.frontmatter.tags const tags = page.frontmatter.tags
generateBlogTagsColors(extra.tagsColors, tags) generateBlogTagsColors(extra.tagsColors, tags)
return { const data: Record<string, any> = {
categoryList: page.data.categoryList, categoryList: page.data.categoryList,
tags, tags,
sticky: page.frontmatter.sticky, sticky: page.frontmatter.sticky,
createTime: page.data.frontmatter.createTime, createTime: page.data.frontmatter.createTime,
lang: page.lang, lang: page.lang,
} }
isEncryptPage(page, encrypt) && (data.encrypt = true)
return data
}, },
}), }),

View File

@ -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<string, string> = {}
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<PlumeThemePageData>, 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)
})
}

View File

@ -1,28 +1,39 @@
import type { Page, Theme } from 'vuepress/core' 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 type { PlumeThemeOptions, PlumeThemePageData } from '../shared/index.js'
import { mergeLocaleOptions } from './defaultOptions.js' import { mergeLocaleOptions } from './defaultOptions.js'
import { setupPlugins } from './plugins.js' import { setupPlugins } from './plugins.js'
import { extendsPageData, setupPage } from './setupPages.js' import { extendsPageData, setupPage } from './setupPages.js'
import { getThemePackage, resolve, templates } from './utils.js' import { getThemePackage, resolve, templates } from './utils.js'
import { resolveEncrypt } from './resolveEncrypt.js'
const THEME_NAME = 'vuepress-theme-plume' const THEME_NAME = 'vuepress-theme-plume'
export function plumeTheme({ export function plumeTheme({
themePlugins, themePlugins,
plugins, plugins,
encrypt,
...localeOptions ...localeOptions
}: PlumeThemeOptions = {}): Theme { }: PlumeThemeOptions = {}): Theme {
const pluginsOptions = plugins ?? themePlugins ?? {} const pluginsOptions = plugins ?? themePlugins ?? {}
const pkg = getThemePackage() const pkg = getThemePackage()
if (themePlugins) {
logger.warn(
`The 'themePlugins' option is deprecated. Please use 'plugins' instead.`,
)
}
return (app) => { return (app) => {
localeOptions = mergeLocaleOptions(app, localeOptions) localeOptions = mergeLocaleOptions(app, localeOptions)
return { return {
name: THEME_NAME, name: THEME_NAME,
define: {
...resolveEncrypt(encrypt),
},
templateBuild: templates('build.html'), templateBuild: templates('build.html'),
clientConfigFile: resolve('client/config.js'), clientConfigFile: resolve('client/config.js'),
plugins: setupPlugins(app, pluginsOptions, localeOptions), plugins: setupPlugins(app, pluginsOptions, localeOptions, encrypt),
onInitialized: app => setupPage(app, localeOptions), onInitialized: app => setupPage(app, localeOptions),
extendsPage: (page) => { extendsPage: (page) => {
extendsPageData(app, page as Page<PlumeThemePageData>, localeOptions) extendsPageData(app, page as Page<PlumeThemePageData>, localeOptions)
@ -44,7 +55,7 @@ export function plumeTheme({
if (um === 'dark' || (um !== 'light' && sm)) { if (um === 'dark' || (um !== 'light' && sm)) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
})();`, })();`.replace(/^\s+|\s+$/gm, '').replace(/\n/g, ''),
]) ])
} }

View File

@ -6,6 +6,8 @@ export interface PlumeThemeBlogPostItem extends BlogPostDataItem {
sticky: boolean sticky: boolean
categoryLost: PageCategoryData[] categoryLost: PageCategoryData[]
createTime: string createTime: string
lang: string
encrypt?: boolean
} }
export type PlumeThemeBlogPostData = PlumeThemeBlogPostItem[] export type PlumeThemeBlogPostData = PlumeThemeBlogPostItem[]

View File

@ -68,6 +68,9 @@ export interface PlumeThemeBlog {
*/ */
exclude?: string[] exclude?: string[]
/**
*
*/
pagination?: false | { pagination?: false | {
/** /**
* *
@ -98,6 +101,37 @@ export interface PlumeThemeBlog {
archives?: boolean 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 { export interface PlumeThemeLocaleData extends LocaleData {
/** /**
* *
@ -296,4 +330,28 @@ export interface PlumeThemeLocaleData extends LocaleData {
linkLabel?: string linkLabel?: string
linkText?: string linkText?: string
} }
/**
*
*/
encrypt?: PlumeThemeEncrypt
/**
*
*/
encryptGlobalText?: string
/**
*
*/
encryptPageText?: string
/**
*
*/
encryptButtonText?: string
/**
* placeholder
*/
encryptPlaceholder?: string
} }