perf(theme): 优化 站点加密交互

This commit is contained in:
pengzhanbo 2024-07-08 07:02:45 +08:00
parent 31a5c01d2d
commit d49e70f48e
11 changed files with 187 additions and 114 deletions

View File

@ -58,4 +58,5 @@ export default defineThemeConfig({
'/article/enx7c9s/': '123456',
},
},
autoFrontmatter: { exclude: ['**/*.snippet.*'] },
})

View File

@ -5,7 +5,6 @@ import type { Theme } from 'vuepress'
export const theme: Theme = plumeTheme({
hostname: process.env.SITE_HOST || 'https://plume.pengzhanbo.cn',
plugins: {
frontmatter: { exclude: ['**/*.snippet.*'] },
shiki: { twoslash: true },

View File

@ -6,7 +6,7 @@ import VPDocAside from '@theme/VPDocAside.vue'
import VPDocFooter from '@theme/VPDocFooter.vue'
import VPEncryptPage from '@theme/VPEncryptPage.vue'
import VPDocMeta from '@theme/VPDocMeta.vue'
import { usePageEncrypt } from '../composables/encrypt.js'
import { useEncrypt } from '../composables/encrypt.js'
import { useSidebar } from '../composables/sidebar.js'
import { useData } from '../composables/data.js'
@ -14,7 +14,7 @@ const { page, theme, frontmatter, isDark } = useData()
const route = useRoute()
const { hasSidebar, hasAside, leftAside } = useSidebar()
const { isPageDecrypted } = usePageEncrypt()
const { isPageDecrypted } = useEncrypt()
const hasComments = computed(() => {
return page.value.frontmatter.comments !== false

View File

@ -1,20 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useData } from '../composables/data.js'
import { useEncryptCompare } from '../composables/encrypt.js'
const props = defineProps<{
compare: (password: string) => boolean
global?: boolean
info?: string
}>()
const { theme } = useData()
const { compareGlobal, comparePage } = useEncryptCompare()
const password = ref('')
const errorCode = ref(0) // 0: no error, 1: wrong password
const unlocking = ref(false)
function onSubmit() {
const result = props.compare(password.value)
async function onSubmit() {
if (unlocking.value)
return
const compare = props.global ? compareGlobal : comparePage
unlocking.value = true
const result = await compare(password.value)
unlocking.value = false
if (!result) {
errorCode.value = 1
}
@ -40,8 +48,9 @@ function onSubmit() {
@input="password && (errorCode = 0)"
>
</p>
<button class="encrypt-button" @click="onSubmit">
{{ theme.encryptButtonText ?? 'Confirm' }}
<button class="encrypt-button" :class="{ unlocking }" @click="onSubmit">
<span v-if="!unlocking">{{ theme.encryptButtonText ?? 'Confirm' }}</span>
<span v-else class="vpi-loading" />
</button>
</div>
</template>
@ -104,4 +113,14 @@ function onSubmit() {
.encrypt-button:hover {
background-color: var(--vp-c-brand-2);
}
.encrypt-button.unlocking {
color: var(--vp-c-brand-1);
background-color: var(--vp-c-gray-1);
}
.vpi-loading {
display: inline-block;
transform: scale(5);
}
</style>

View File

@ -3,10 +3,8 @@ import { computed } from 'vue'
import VPFooter from '@theme/VPFooter.vue'
import VPEncryptForm from '@theme/VPEncryptForm.vue'
import { useData } from '../composables/data.js'
import { useGlobalEncrypt } from '../composables/encrypt.js'
const { theme, site } = useData()
const { compareGlobal } = useGlobalEncrypt()
const profile = computed(() => theme.value.profile)
const title = computed(() => profile.value?.name || site.value.title)
@ -23,7 +21,7 @@ const title = computed(() => profile.value?.name || site.value.title)
{{ title }}
</h3>
</div>
<VPEncryptForm :compare="compareGlobal" :info="theme.encryptGlobalText" />
<VPEncryptForm global :info="theme.encryptGlobalText" />
</div>
</div>
<VPFooter />

View File

@ -1,10 +1,8 @@
<script setup lang="ts">
import VPEncryptForm from '@theme/VPEncryptForm.vue'
import { usePageEncrypt } from '../composables/encrypt.js'
import { useData } from '../composables/data.js'
const { theme } = useData()
const { comparePage } = usePageEncrypt()
</script>
<template>
@ -12,7 +10,7 @@ const { comparePage } = usePageEncrypt()
<div class="logo">
<span class="vpi-lock icon-lock-head" />
</div>
<VPEncryptForm :compare="comparePage" :info="theme.encryptPageText" />
<VPEncryptForm :info="theme.encryptPageText" />
</div>
</template>
@ -33,11 +31,15 @@ const { comparePage } = usePageEncrypt()
width: 400px;
padding: 20px;
margin: 40px auto 0;
background-color: var(--vp-c-bg-alt);
border: solid 1px var(--vp-c-divider);
border-radius: 8px;
box-shadow: var(--vp-shadow-2);
box-shadow: var(--vp-shadow-1);
transition: var(--t-color);
transition-property: box-shadow, background-color;
transition-property: box-shadow, border-color;
}
.vp-page-encrypt:hover {
box-shadow: var(--vp-shadow-2);
}
}
</style>

View File

@ -1,6 +1,4 @@
import {
encrypt as rawEncrypt,
} from '@internal/encrypt'
import { encrypt as rawEncrypt } from '@internal/encrypt'
import { ref } from 'vue'
import type { Ref } from 'vue'
@ -12,16 +10,18 @@ export type EncryptConfig = readonly [
Record<string, string>, // rules
]
export interface EncryptDataRule {
key: string
match: string
rules: string[]
}
export interface EncryptData {
global: boolean
separator: string
admins: string[]
matches: string[]
ruleList: {
key: string
match: string
rules: string[]
}[]
ruleList: EncryptDataRule[]
}
export type EncryptRef = Ref<EncryptData>

View File

@ -1,9 +1,21 @@
import { compareSync, genSaltSync } from 'bcrypt-ts/browser'
import { type Ref, computed } from 'vue'
import { compare, genSaltSync } from 'bcrypt-ts/browser'
import type { InjectionKey, Ref } from 'vue'
import { computed, inject, provide } from 'vue'
import { hasOwn, useSessionStorage } from '@vueuse/core'
import { useRoute } from 'vuepress/client'
import { useData } from './data.js'
import { useEncryptData } from './encrypt-data.js'
import { type EncryptDataRule, useEncryptData } from './encrypt-data.js'
export interface Encrypt {
hasPageEncrypt: Ref<boolean>
isGlobalDecrypted: Ref<boolean>
isPageDecrypted: Ref<boolean>
hashList: Ref<EncryptDataRule[]>
}
export const EncryptSymbol: InjectionKey<Encrypt> = Symbol(
__VUEPRESS_DEV__ ? 'Encrypt' : '',
)
const storage = useSessionStorage('2a0a3d6afb2fdf1f', () => ({
s: [genSaltSync(10), genSaltSync(10)] as const,
@ -24,23 +36,58 @@ function splitHash(hash: string) {
return hash.slice(left.length, hash.length - right.length)
}
const cache = new Map<string, boolean>()
function compare(content: string, hash: string, separator = ':') {
const compareCache = new Map<string, boolean>()
async function compareDecrypt(content: string, hash: string, separator = ':'): Promise<boolean> {
const key = [content, hash].join(separator)
if (cache.has(key))
return cache.get(key)
if (compareCache.has(key))
return compareCache.get(key)!
const result = compareSync(content, hash)
cache.set(key, result)
return result
try {
const result = await compare(content, hash)
compareCache.set(key, result)
return result
}
catch {
compareCache.set(key, false)
return false
}
}
export function useGlobalEncrypt(): {
isGlobalDecrypted: Ref<boolean>
compareGlobal: (password: string) => boolean
} {
const matchCache = new Map<string, RegExp>()
function createMatchRegex(match: string) {
if (matchCache.has(match))
return matchCache.get(match)!
const regex = new RegExp(match)
matchCache.set(match, regex)
return regex
}
function toMatch(match: string, pagePath: string, filePathRelative: string | null) {
const relativePath = filePathRelative || ''
if (match[0] === '^') {
const regex = createMatchRegex(match)
return regex.test(pagePath) || (relativePath && regex.test(relativePath))
}
if (match.endsWith('.md'))
return relativePath && relativePath.endsWith(match)
return pagePath.startsWith(match) || relativePath.startsWith(match)
}
export function setupEncrypt() {
const { page } = useData()
const route = useRoute()
const encrypt = useEncryptData()
const hasPageEncrypt = computed(() => {
const pagePath = route.path
const filePathRelative = page.value.filePathRelative
return encrypt.value.ruleList.length
? encrypt.value.matches.some(match => toMatch(match, pagePath, filePathRelative))
: false
})
const isGlobalDecrypted = computed(() => {
if (!encrypt.value.global)
return true
@ -50,37 +97,14 @@ export function useGlobalEncrypt(): {
return !!hash && encrypt.value.admins.includes(hash)
})
function compareGlobal(password: string) {
if (!password)
return false
for (const admin of encrypt.value.admins) {
if (compare(password, admin, encrypt.value.separator)) {
storage.value.g = mergeHash(admin)
return true
}
}
return false
}
return {
isGlobalDecrypted,
compareGlobal,
}
}
export function usePageEncrypt() {
const { page } = useData()
const route = useRoute()
const encrypt = useEncryptData()
const hasPageEncrypt = computed(() => encrypt.value.ruleList.length ? encrypt.value.matches.some(toMatch) : false)
const hashList = computed(() => encrypt.value.ruleList.length
? encrypt.value.ruleList
.filter(item => toMatch(item.match))
: [])
const hashList = computed(() => {
const pagePath = route.path
const filePathRelative = page.value.filePathRelative
return encrypt.value.ruleList.length
? encrypt.value.ruleList
.filter(item => toMatch(item.match, pagePath, filePathRelative))
: []
})
const isPageDecrypted = computed(() => {
if (!hasPageEncrypt.value)
@ -101,60 +125,75 @@ export function usePageEncrypt() {
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)
provide(EncryptSymbol, {
hasPageEncrypt,
isGlobalDecrypted,
isPageDecrypted,
hashList,
})
}
return route.path.startsWith(match) || relativePath.startsWith(match)
}
export function useEncrypt(): Encrypt {
const result = inject(EncryptSymbol)
function comparePage(password: string) {
if (!result)
throw new Error('useEncrypt() is called without setup')
return result
}
export function useEncryptCompare() {
const encrypt = useEncryptData()
const { page } = useData()
const route = useRoute()
const { hashList } = useEncrypt()
async function compareGlobal(password: string) {
if (!password)
return false
let decrypted = false
// check global
for (const admin of encrypt.value.admins) {
if (compare(password, admin, encrypt.value.separator)) {
decrypted = true
storage.value.p = {
...storage.value.p,
__GLOBAL__: mergeHash(admin),
}
break
if (await compareDecrypt(password, admin, encrypt.value.separator)) {
storage.value.g = mergeHash(admin)
return true
}
}
// check page
if (!decrypted) {
for (const { match, key, rules } of hashList.value) {
if (toMatch(match)) {
for (const rule of rules) {
if (compare(password, rule, encrypt.value.separator)) {
decrypted = true
storage.value.p = {
...storage.value.p,
[key]: mergeHash(rule),
}
break
return false
}
async function comparePage(password: string) {
if (!password)
return false
const pagePath = route.path
const filePathRelative = page.value.filePathRelative
let decrypted = false
for (const { match, key, rules } of hashList.value) {
if (toMatch(match, pagePath, filePathRelative)) {
for (const rule of rules) {
if (await compareDecrypt(password, rule, encrypt.value.separator)) {
decrypted = true
storage.value.p = {
...storage.value.p,
[key]: mergeHash(rule),
}
}
if (decrypted)
break
}
}
if (decrypted)
break
}
}
if (!decrypted) {
decrypted = await compareGlobal(password)
}
return decrypted
}
return {
isPageDecrypted,
comparePage,
}
return { compareGlobal, comparePage }
}

View File

@ -2,7 +2,14 @@ import './styles/index.css'
import { defineClientConfig } from 'vuepress/client'
import type { ClientConfig } from 'vuepress/client'
import { enhanceScrollBehavior, setupDarkMode, setupSidebar, setupThemeData, setupWatermark } from './composables/index.js'
import {
enhanceScrollBehavior,
setupDarkMode,
setupEncrypt,
setupSidebar,
setupThemeData,
setupWatermark,
} from './composables/index.js'
import { globalComponents } from './globalComponents.js'
import Layout from './layouts/Layout.vue'
import NotFound from './layouts/NotFound.vue'
@ -16,6 +23,7 @@ export default defineClientConfig({
},
setup() {
setupSidebar()
setupEncrypt()
setupWatermark()
},
layouts: { Layout, NotFound },

View File

@ -11,7 +11,7 @@ import VPFooter from '@theme/VPFooter.vue'
import VPBackToTop from '@theme/VPBackToTop.vue'
import VPEncryptGlobal from '@theme/VPEncryptGlobal.vue'
import { useCloseSidebarOnEscape, useSidebar } from '../composables/sidebar.js'
import { useGlobalEncrypt, usePageEncrypt } from '../composables/encrypt.js'
import { useEncrypt } from '../composables/encrypt.js'
import { useData } from '../composables/data.js'
const {
@ -21,8 +21,7 @@ const {
} = useSidebar()
const { frontmatter } = useData()
const { isGlobalDecrypted } = useGlobalEncrypt()
const { isPageDecrypted } = usePageEncrypt()
const { isGlobalDecrypted, isPageDecrypted } = useEncrypt()
const route = useRoute()
watch(() => route.path, closeSidebar)
@ -65,7 +64,11 @@ useCloseSidebarOnEscape(isSidebarOpen, closeSidebar)
</template>
</VPNav>
<VPLocalNav :open="isSidebarOpen" :show-outline="isPageDecrypted" @open-menu="openSidebar" />
<VPLocalNav
:open="isSidebarOpen"
:show-outline="isPageDecrypted"
@open-menu="openSidebar"
/>
<VPSidebar :open="isSidebarOpen">
<template #sidebar-nav-before>

View File

@ -89,6 +89,10 @@
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E %3Cpath 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' /%3E %3C/svg%3E");
}
.vpi-loading {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Ccircle cx='18' cy='12' r='0' fill='%23000'%3E%3Canimate attributeName='r' begin='.67' calcMode='spline' dur='1.5s' keySplines='0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8' repeatCount='indefinite' values='0;2;0;0'/%3E%3C/circle%3E%3Ccircle cx='12' cy='12' r='0' fill='%23000'%3E%3Canimate attributeName='r' begin='.33' calcMode='spline' dur='1.5s' keySplines='0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8' repeatCount='indefinite' values='0;2;0;0'/%3E%3C/circle%3E%3Ccircle cx='6' cy='12' r='0' fill='%23000'%3E%3Canimate attributeName='r' begin='0' calcMode='spline' dur='1.5s' keySplines='0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8' repeatCount='indefinite' values='0;2;0;0'/%3E%3C/circle%3E%3C/svg%3E");
}
.vpi-print {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M16 8V5H8v3H6V3h12v5zM4 10h16zm14 2.5q.425 0 .713-.288T19 11.5q0-.425-.288-.712T18 10.5q-.425 0-.712.288T17 11.5q0 .425.288.713T18 12.5M16 19v-4H8v4zm2 2H6v-4H2v-6q0-1.275.875-2.137T5 8h14q1.275 0 2.138.863T22 11v6h-4zm2-6v-4q0-.425-.288-.712T19 10H5q-.425 0-.712.288T4 11v4h2v-2h12v2z' /%3E%3C/svg%3E");
}