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