feat(theme): 新增 加密 功能
This commit is contained in:
parent
ba899ec7ca
commit
254eb7a9ea
21
theme/LICENSE
Normal file
21
theme/LICENSE
Normal 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.
|
||||
@ -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)
|
||||
[](https://www.npmjs.com/package/vuepress-theme-plume)
|
||||
[](https://www.npmjs.com/package/vuepress-theme-plume)
|
||||

|
||||
|
||||
一个简约的,干净的,容易上手的 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)
|
||||
|
||||
192
theme/src/client/components/EncryptGlobal.vue
Normal file
192
theme/src/client/components/EncryptGlobal.vue
Normal 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>
|
||||
131
theme/src/client/components/EncryptPage.vue
Normal file
131
theme/src/client/components/EncryptPage.vue
Normal 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>
|
||||
@ -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<PlumeThemePageData>()
|
||||
|
||||
const { isPageDecrypted } = usePageEncrypt()
|
||||
|
||||
const hasComments = computed(() => {
|
||||
return page.value.frontmatter.comments !== false
|
||||
})
|
||||
|
||||
const zoom = useMediumZoom()
|
||||
|
||||
onContentUpdated(() => zoom?.refresh())
|
||||
</script>
|
||||
|
||||
@ -29,10 +32,11 @@ onContentUpdated(() => zoom?.refresh())
|
||||
'has-sidebar': hasSidebar,
|
||||
'has-aside': hasAside,
|
||||
'is-blog': page.isBlogPost,
|
||||
'with-encrypt': !isPageDecrypted,
|
||||
}"
|
||||
>
|
||||
<div class="container">
|
||||
<div v-if="hasAside" class="aside">
|
||||
<div v-if="hasAside && isPageDecrypted" class="aside">
|
||||
<div class="aside-container">
|
||||
<div class="aside-content">
|
||||
<PageAside />
|
||||
@ -43,9 +47,12 @@ onContentUpdated(() => zoom?.refresh())
|
||||
<div class="content-container">
|
||||
<main class="main">
|
||||
<PageMeta />
|
||||
<Content class="plume-content" />
|
||||
<PageFooter />
|
||||
<PageComment v-if="hasComments" :darkmode="isDark" />
|
||||
<EncryptPage v-if="!isPageDecrypted" />
|
||||
<template v-else>
|
||||
<Content class="plume-content" />
|
||||
<PageFooter />
|
||||
<PageComment v-if="hasComments" :darkmode="isDark" />
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
</div>
|
||||
<IconLock v-if="post.encrypt" class="icon-lock" />
|
||||
<AutoLink :href="post.path">
|
||||
{{ post.title }}
|
||||
</AutoLink>
|
||||
@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
8
theme/src/client/components/icons/IconLock.vue
Normal file
8
theme/src/client/components/icons/IconLock.vue
Normal 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>
|
||||
176
theme/src/client/composables/encrypt.ts
Normal file
176
theme/src/client/composables/encrypt.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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<PlumeThemePageData>()
|
||||
|
||||
@ -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)
|
||||
|
||||
<template>
|
||||
<div class="theme-plume">
|
||||
<SkipLink />
|
||||
<Backdrop :show="isSidebarOpen" @click="closeSidebar" />
|
||||
<Nav />
|
||||
<LocalNav :open="isSidebarOpen" @open-menu="openSidebar" />
|
||||
<Sidebar :open="isSidebarOpen" />
|
||||
<LayoutContent>
|
||||
<Home v-if="page.frontmatter.home" />
|
||||
<Friends v-else-if="page.frontmatter.friends" />
|
||||
<Blog v-else-if="isBlogLayout" />
|
||||
<Page v-else />
|
||||
<BackToTop />
|
||||
<VFooter />
|
||||
</LayoutContent>
|
||||
<EncryptGlobal v-if="!isGlobalDecrypted" />
|
||||
<template v-else>
|
||||
<SkipLink />
|
||||
<Backdrop :show="isSidebarOpen" @click="closeSidebar" />
|
||||
<Nav />
|
||||
<LocalNav
|
||||
:open="isSidebarOpen"
|
||||
:show-outline="isPageDecrypted"
|
||||
@open-menu="openSidebar"
|
||||
/>
|
||||
<Sidebar :open="isSidebarOpen" />
|
||||
<LayoutContent>
|
||||
<Home v-if="page.frontmatter.home" />
|
||||
<Friends v-else-if="page.frontmatter.friends" />
|
||||
<Blog v-else-if="isBlogLayout" />
|
||||
<Page v-else />
|
||||
<BackToTop />
|
||||
<VFooter />
|
||||
</LayoutContent>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -12,6 +12,11 @@ const defaultLocales: NonNullable<PlumeThemeLocaleOptions['locales']> = {
|
||||
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<PlumeThemeLocaleOptions['locales']> = {
|
||||
quote: '但是,如果你不改变方向,并且一直寻找,最终可能会到达你要去的地方。',
|
||||
linkText: '返回首页',
|
||||
},
|
||||
|
||||
encryptButtonText: '确认',
|
||||
encryptPlaceholder: '请输入密码',
|
||||
encryptGlobalText: '本站只允许密码访问',
|
||||
encryptPageText: '本页面只允许密码访问',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, any> = {
|
||||
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
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
59
theme/src/node/resolveEncrypt.ts
Normal file
59
theme/src/node/resolveEncrypt.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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<PlumeThemePageData>, 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, ''),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ export interface PlumeThemeBlogPostItem extends BlogPostDataItem {
|
||||
sticky: boolean
|
||||
categoryLost: PageCategoryData[]
|
||||
createTime: string
|
||||
lang: string
|
||||
encrypt?: boolean
|
||||
}
|
||||
|
||||
export type PlumeThemeBlogPostData = PlumeThemeBlogPostItem[]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user