mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-21 11:54:05 -06:00
Clean up the plugin configuration types and make fields nullable
This commit is contained in:
parent
78d4fb5faa
commit
127e5c3c03
@ -17,132 +17,90 @@ This plugin enables content encryption for sensitive pages in your Quartz site.
|
||||
|
||||
```typescript
|
||||
Plugin.Encrypt({
|
||||
algorithm: "aes-256-cbc", // Encryption algorithm (key length auto-inferred)
|
||||
algorithm: "aes-256-cbc", // Encryption algorithm
|
||||
ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days)
|
||||
message: "This content is encrypted.", // Default message shown
|
||||
encryptedFolders: {
|
||||
// Folder-level encryption with simple passwords
|
||||
// Simple password for a folder
|
||||
"private/": "folder-password",
|
||||
"work/confidential/": "work-password",
|
||||
// Advanced per-folder configuration
|
||||
|
||||
// Advanced configuration for a folder
|
||||
"secure/": {
|
||||
password: "advanced-password",
|
||||
algorithm: "aes-256-gcm",
|
||||
ttl: 3600 * 24 * 30, // 30 days
|
||||
message: "Authorized access only",
|
||||
},
|
||||
},
|
||||
ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days)
|
||||
})
|
||||
```
|
||||
|
||||
> [!warning]
|
||||
> It is very important to note that:
|
||||
> Important security notes:
|
||||
>
|
||||
> - All non-markdown files will be left unencrypted in the final build.
|
||||
> - Marking something as encrypted will only encrypt the page in the final build. The file's content can still be viewed if your repository is public.
|
||||
> - All non-markdown files remain unencrypted in the final build
|
||||
> - Encrypted content is still visible in your source repository if it's public
|
||||
> - Use this for access control, not for storing highly sensitive secrets
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `algorithm`: Encryption algorithm to use. Supported values:
|
||||
- `algorithm`: Encryption algorithm to use
|
||||
- `"aes-256-cbc"` (default): AES-256 in CBC mode
|
||||
- `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption)
|
||||
- `"aes-256-ecb"`: AES-256 in ECB mode (not recommended for security)
|
||||
- Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes)
|
||||
- `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption
|
||||
- `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only)
|
||||
- `message`: Message to be displayed in the decryption page
|
||||
- `"aes-256-ecb"`: AES-256 in ECB mode (not recommended)
|
||||
- `ttl`: Time-to-live for cached passwords in seconds (default: 7 days)
|
||||
- `message`: Default message shown on encrypted pages
|
||||
- `encryptedFolders`: Configure folder-level encryption
|
||||
|
||||
### Advanced Folder Configuration
|
||||
## How Configuration Works
|
||||
|
||||
You can provide detailed configuration for each encrypted folder:
|
||||
### Configuration Inheritance
|
||||
|
||||
Settings cascade down through your folder structure:
|
||||
|
||||
```typescript
|
||||
encryptedFolders: {
|
||||
"basic/": "simple-password", // Simple string password
|
||||
"advanced/": {
|
||||
password: "complex-password",
|
||||
algorithm: "aes-256-gcm", // Override global algorithm
|
||||
ttl: 3600 * 24 * 30, // Override global TTL (30 days)
|
||||
message: "This content is encrypted", // Message to be displayed
|
||||
"docs/": {
|
||||
password: "docs-password",
|
||||
algorithm: "aes-256-gcm"
|
||||
},
|
||||
"docs/internal/": {
|
||||
password: "internal-password"
|
||||
// Inherits algorithm from parent folder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
In this example:
|
||||
|
||||
### Folder-level Encryption
|
||||
- `docs/page.md` uses `"docs-password"` with `"aes-256-gcm"`
|
||||
- `docs/internal/report.md` uses `"internal-password"` but still uses `"aes-256-gcm"` (inherited)
|
||||
|
||||
Configure folders to be encrypted by adding them to the `encryptedFolders` option:
|
||||
### Configuration Priority
|
||||
|
||||
```typescript
|
||||
Plugin.Encrypt({
|
||||
encryptedFolders: {
|
||||
"private/": "my-secret-password",
|
||||
"work/": "work-password",
|
||||
"personal/diary/": "diary-password",
|
||||
},
|
||||
})
|
||||
```
|
||||
When multiple configurations apply, the priority is:
|
||||
|
||||
All pages within these folders will be encrypted with the specified password. Nested folders inherit passwords from parent folders, with deeper paths taking precedence.
|
||||
|
||||
### Page-level Encryption
|
||||
|
||||
Use frontmatter to encrypt individual pages or override folder passwords.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "My Secret Page"
|
||||
password: "page-specific-password"
|
||||
encryptConfig:
|
||||
password: "password-also-allowed-here"
|
||||
message: "Sorry, this one is only for my eyes"
|
||||
algorithm: "aes-256-gcm" # Optional: override algorithm
|
||||
ttl: 86400 # Optional: override TTL (1 day)
|
||||
---
|
||||
This content will be encrypted and require a password to view.
|
||||
```
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
#### encryptConfig object fields:
|
||||
|
||||
- `password`: (required) The password required to decrypt this page
|
||||
- `message`: (optional) Custom message to show on the unlock page
|
||||
- `algorithm`: (optional) Override the encryption algorithm for this page
|
||||
- `ttl`: (optional) Override password cache TTL for this page
|
||||
|
||||
#### Legacy fields (still supported):
|
||||
|
||||
- `encrypt`: Set to `true` to enable encryption for this page
|
||||
- `password`: The password required to decrypt this page
|
||||
|
||||
If a page is in an encrypted folder but has its own password configuration, the page-specific settings will be used instead of the folder settings.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- Use strong passwords for sensitive content
|
||||
- Consider using AES-256-GCM mode for authenticated encryption
|
||||
- ECB mode is provided for compatibility but is not recommended for security-critical applications
|
||||
- Key lengths are automatically determined by the algorithm (no manual configuration needed)
|
||||
1. **Page frontmatter** (highest priority)
|
||||
2. **Deepest matching folder**
|
||||
3. **Parent folders** (inherited settings)
|
||||
4. **Global defaults** (lowest priority)
|
||||
|
||||
## Security Features
|
||||
|
||||
### Password Cache
|
||||
### Password Caching
|
||||
|
||||
The plugin implements intelligent password caching:
|
||||
- Passwords are stored in browser localStorage
|
||||
- Automatic expiration based on TTL settings
|
||||
- Cached passwords are tried automatically when navigating
|
||||
|
||||
- Passwords are cached in browser localStorage
|
||||
- Configurable TTL (time-to-live) for automatic cache expiration
|
||||
- Passwords are automatically tried when navigating to encrypted pages
|
||||
### Protection Levels
|
||||
|
||||
### Content Protection
|
||||
|
||||
- **Full Content Encryption**: The entire HTML content is encrypted, not just hidden
|
||||
- **SEO Protection**: Search engines and RSS feeds see generic placeholder descriptions
|
||||
- **Client-side Decryption**: Content is never transmitted in plain text
|
||||
- **Password Verification**: Fast password hash verification before attempting decryption
|
||||
- **Content**: Entire page HTML is encrypted
|
||||
- **Search/RSS**: Only generic descriptions are exposed
|
||||
- **Navigation**: Encrypted pages appear in navigation but require passwords to view
|
||||
|
||||
## API
|
||||
|
||||
- Category: Transformer
|
||||
- Function name: `Plugin.Encrypt()`.
|
||||
- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts).
|
||||
- Function name: `Plugin.Encrypt()`
|
||||
- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts)
|
||||
|
||||
1
index.d.ts
vendored
1
index.d.ts
vendored
@ -8,6 +8,7 @@ interface CustomEventMap {
|
||||
prenav: CustomEvent<{}>
|
||||
nav: CustomEvent<{ url: FullSlug }>
|
||||
render: CustomEvent<{ htmlElement: HTMLElement }>
|
||||
decrypt: CustomEvent<{ filePath: FullSlug; password: string }>
|
||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
||||
}
|
||||
|
||||
@ -121,7 +121,6 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
</div>
|
||||
<template id="template-file">
|
||||
<li>
|
||||
<span class="file-title"></span>
|
||||
<a href="#"></a>
|
||||
</li>
|
||||
</template>
|
||||
@ -144,7 +143,6 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
</svg>
|
||||
<div>
|
||||
<button class="folder-button">
|
||||
<span class="folder-title folder-title-icon"></span>
|
||||
<span class="folder-title folder-title-text"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -54,14 +54,13 @@ export default ((opts?: Partial<Options>) => {
|
||||
</svg>
|
||||
</button>
|
||||
<OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
|
||||
{!fileData.encryptionResult &&
|
||||
fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
@ -71,7 +70,7 @@ export default ((opts?: Partial<Options>) => {
|
||||
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
|
||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
if (!fileData.toc || fileData.encryptionResult) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
verifyPasswordHash,
|
||||
addPasswordToCache,
|
||||
Hash,
|
||||
EncryptionConfig,
|
||||
CompleteCryptoConfig,
|
||||
searchForValidPassword,
|
||||
EncryptionResult,
|
||||
} from "../../util/encryption"
|
||||
@ -25,6 +25,16 @@ const showLoading = (container: Element, show: boolean) => {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchDecryptEvent(filePath: FullSlug, password: string) {
|
||||
const event = new CustomEvent("decrypt", {
|
||||
detail: {
|
||||
filePath,
|
||||
password,
|
||||
},
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const decryptWithPassword = async (
|
||||
container: Element,
|
||||
password: string,
|
||||
@ -33,7 +43,7 @@ const decryptWithPassword = async (
|
||||
const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement>
|
||||
const containerElement = container as HTMLElement
|
||||
|
||||
const config = JSON.parse(containerElement.dataset.config!) as EncryptionConfig
|
||||
const config = JSON.parse(containerElement.dataset.config!) as CompleteCryptoConfig
|
||||
const encrypted = JSON.parse(containerElement.dataset.encrypted!) as EncryptionResult
|
||||
const hash = JSON.parse(containerElement.dataset.hash!) as Hash
|
||||
|
||||
@ -137,13 +147,14 @@ function updateTitle(container: HTMLElement | null) {
|
||||
|
||||
const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise<boolean> => {
|
||||
const fullSlug = container.dataset.slug as FullSlug
|
||||
const config = JSON.parse(container.dataset.config!) as EncryptionConfig
|
||||
const config = JSON.parse(container.dataset.config!) as CompleteCryptoConfig
|
||||
const hash = JSON.parse(container.dataset.hash!) as Hash
|
||||
|
||||
const password = await searchForValidPassword(fullSlug, hash, config)
|
||||
|
||||
if (password && (await decryptWithPassword(container, password, false))) {
|
||||
dispatchRenderEvent(parent)
|
||||
dispatchDecryptEvent(fullSlug, password)
|
||||
updateTitle(parent)
|
||||
return true
|
||||
}
|
||||
@ -151,6 +162,7 @@ const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Prom
|
||||
}
|
||||
|
||||
const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => {
|
||||
const fullSlug = container.dataset.slug as FullSlug
|
||||
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
|
||||
const password = passwordInput.value
|
||||
|
||||
@ -161,6 +173,7 @@ const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => {
|
||||
|
||||
if (await decryptWithPassword(container, password, true)) {
|
||||
dispatchRenderEvent(parent)
|
||||
dispatchDecryptEvent(fullSlug, password)
|
||||
updateTitle(parent)
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,18 +87,15 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
|
||||
const a = li.querySelector("a") as HTMLAnchorElement
|
||||
a.href = resolveRelative(currentSlug, node.slug)
|
||||
a.dataset.for = node.slug
|
||||
a.textContent = node.displayName
|
||||
|
||||
const span = li.querySelector("span") as HTMLSpanElement
|
||||
|
||||
if (span && node.data?.encryptionResult) {
|
||||
span.textContent = "🔒 "
|
||||
if (node.data?.encryptionResult) {
|
||||
a.textContent = "🔒 " + node.displayName
|
||||
|
||||
contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => {
|
||||
span.textContent = "🔓 "
|
||||
a.textContent = "🔓 " + node.displayName
|
||||
})
|
||||
} else if (span) {
|
||||
span.remove()
|
||||
} else {
|
||||
a.textContent = node.displayName
|
||||
}
|
||||
|
||||
if (currentSlug === node.slug) {
|
||||
@ -124,14 +121,7 @@ function createFolderNode(
|
||||
const folderPath = node.slug
|
||||
folderContainer.dataset.folderpath = folderPath
|
||||
|
||||
const span = titleContainer.querySelector("span.folder-title-icon") as HTMLElement
|
||||
|
||||
if (span && node.data?.encryptionResult) {
|
||||
span.textContent = "🔒 "
|
||||
contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => {
|
||||
span.textContent = "🔓 "
|
||||
})
|
||||
}
|
||||
let titleElement: HTMLElement
|
||||
|
||||
if (opts.folderClickBehavior === "link") {
|
||||
// Replace button with link for link behavior
|
||||
@ -140,12 +130,20 @@ function createFolderNode(
|
||||
a.href = resolveRelative(currentSlug, folderPath)
|
||||
a.dataset.for = folderPath
|
||||
a.className = "folder-title"
|
||||
a.textContent = node.displayName
|
||||
titleElement = a
|
||||
button.replaceWith(a)
|
||||
titleContainer.insertBefore(span, a)
|
||||
} else {
|
||||
const span = titleContainer.querySelector(".folder-title-text") as HTMLElement
|
||||
span.textContent = node.displayName
|
||||
titleElement = span
|
||||
}
|
||||
|
||||
if (node.data?.encryptionResult) {
|
||||
titleElement.textContent = "🔒 " + node.displayName
|
||||
contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => {
|
||||
titleElement.textContent = "🔓 " + node.displayName
|
||||
})
|
||||
} else {
|
||||
titleElement.textContent = node.displayName
|
||||
}
|
||||
|
||||
// if the saved state is collapsed or the default state is collapsed
|
||||
|
||||
@ -49,9 +49,9 @@
|
||||
font-family: var(--bodyFont);
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
color: var(--dark);
|
||||
transition: border-color 0.2s ease;
|
||||
// background: var(--backgroundPrimary);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
@ -7,7 +7,7 @@ import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
import { Hash, EncryptionResult, EncryptionConfig } from "../../util/encryption"
|
||||
import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption"
|
||||
|
||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||
|
||||
@ -22,7 +22,7 @@ export type ContentDetails = {
|
||||
richContent?: string
|
||||
date?: Date
|
||||
description?: string
|
||||
encryptionConfig?: EncryptionConfig
|
||||
encryptionConfig?: CompleteCryptoConfig
|
||||
hash?: Hash
|
||||
encryptionResult?: EncryptionResult
|
||||
}
|
||||
|
||||
@ -2,78 +2,238 @@ import { QuartzTransformerPlugin } from "../types"
|
||||
import { Root } from "hast"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { fromHtml } from "hast-util-from-html"
|
||||
import { toString } from "hast-util-to-string"
|
||||
import { VFile } from "vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import {
|
||||
EncryptionOptions,
|
||||
DirectoryConfig,
|
||||
defaultEncryptionConfig,
|
||||
SUPPORTED_ALGORITHMS,
|
||||
SupportedEncryptionAlgorithm,
|
||||
encryptContent,
|
||||
getEncryptionConfigForPath,
|
||||
Hash,
|
||||
hashString,
|
||||
EncryptionResult,
|
||||
CompleteCryptoConfig,
|
||||
BaseCryptoConfig,
|
||||
EncryptionConfig,
|
||||
DirectoryConfig,
|
||||
} from "../../util/encryption"
|
||||
|
||||
// @ts-ignore
|
||||
import encryptScript from "../../components/scripts/encrypt.inline.ts"
|
||||
import encryptStyle from "../../components/styles/encrypt.scss"
|
||||
|
||||
export interface Options extends EncryptionOptions {}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
...defaultEncryptionConfig,
|
||||
encryptedFolders: {},
|
||||
// =============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// =============================================================================
|
||||
// Plugin configuration
|
||||
export interface PluginConfig extends CompleteCryptoConfig {
|
||||
encryptedFolders: Record<string, DirectoryConfig>
|
||||
}
|
||||
|
||||
export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
// User-provided options (all optional)
|
||||
export type PluginOptions = Partial<PluginConfig>
|
||||
|
||||
// Validate algorithm at build time
|
||||
if (
|
||||
opts.algorithm &&
|
||||
!SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm)
|
||||
) {
|
||||
// Internal normalized folder configuration
|
||||
interface NormalizedFolderConfig extends CompleteCryptoConfig {
|
||||
password: string
|
||||
path: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION MANAGEMENT
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_CONFIG: CompleteCryptoConfig = {
|
||||
algorithm: "aes-256-cbc",
|
||||
ttl: 3600 * 24 * 7, // 1 week
|
||||
message: "This content is encrypted.",
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a directory configuration into a complete configuration object
|
||||
*/
|
||||
function normalizeDirectoryConfig(
|
||||
dirConfig: DirectoryConfig,
|
||||
defaults: CompleteCryptoConfig,
|
||||
): EncryptionConfig {
|
||||
if (typeof dirConfig === "string") {
|
||||
return {
|
||||
...defaults,
|
||||
password: dirConfig,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm: dirConfig.algorithm ?? defaults.algorithm,
|
||||
ttl: dirConfig.ttl ?? defaults.ttl,
|
||||
message: dirConfig.message ?? defaults.message,
|
||||
password: dirConfig.password,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sorted list of folder configurations by path depth
|
||||
* This ensures parent configurations are processed before children
|
||||
*/
|
||||
function createFolderConfigHierarchy(
|
||||
folders: Record<string, DirectoryConfig>,
|
||||
globalDefaults: CompleteCryptoConfig,
|
||||
): NormalizedFolderConfig[] {
|
||||
const configs: NormalizedFolderConfig[] = []
|
||||
|
||||
for (const [path, config] of Object.entries(folders)) {
|
||||
const normalized = normalizeDirectoryConfig(config, globalDefaults)
|
||||
configs.push({
|
||||
...normalized,
|
||||
path: path.endsWith("/") ? path : path + "/",
|
||||
depth: path.split("/").filter(Boolean).length,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by depth (shallow to deep) to ensure proper inheritance
|
||||
return configs.sort((a, b) => a.depth - b.depth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges configurations following the inheritance chain
|
||||
* Deeper paths override shallower ones
|
||||
*/
|
||||
function mergeConfigurations(
|
||||
base: CompleteCryptoConfig,
|
||||
override: Partial<BaseCryptoConfig>,
|
||||
): CompleteCryptoConfig {
|
||||
return {
|
||||
algorithm: override.algorithm ?? base.algorithm,
|
||||
ttl: override.ttl ?? base.ttl,
|
||||
message: override.message ?? base.message,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the encryption configuration for a specific file path
|
||||
* Respects the directory hierarchy and inheritance
|
||||
*/
|
||||
function getConfigurationForPath(
|
||||
filePath: string,
|
||||
folderConfigs: NormalizedFolderConfig[],
|
||||
globalDefaults: CompleteCryptoConfig,
|
||||
): EncryptionConfig | undefined {
|
||||
let currentConfig: CompleteCryptoConfig = globalDefaults
|
||||
let password: string | undefined
|
||||
|
||||
// Apply configurations in order (shallow to deep)
|
||||
for (const folderConfig of folderConfigs) {
|
||||
if (filePath.startsWith(folderConfig.path)) {
|
||||
// Merge the configuration, inheriting from parent
|
||||
currentConfig = mergeConfigurations(currentConfig, folderConfig)
|
||||
password = folderConfig.password
|
||||
}
|
||||
}
|
||||
|
||||
// If no password was found in any matching folder, return undefined
|
||||
if (!password) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...currentConfig,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the plugin configuration
|
||||
*/
|
||||
function validateConfig(config: PluginConfig): void {
|
||||
if (!SUPPORTED_ALGORITHMS.includes(config.algorithm)) {
|
||||
throw new Error(
|
||||
`[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`,
|
||||
`[EncryptPlugin] Unsupported encryption algorithm: ${config.algorithm}. ` +
|
||||
`Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
const getEncryptionConfig = (file: VFile): DirectoryConfig | undefined => {
|
||||
if (config.ttl <= 0) {
|
||||
throw new Error(`[EncryptPlugin] TTL must be a positive number`)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PLUGIN IMPLEMENTATION
|
||||
// =============================================================================
|
||||
|
||||
export const Encrypt: QuartzTransformerPlugin<PluginOptions> = (userOpts) => {
|
||||
// Merge user options with defaults
|
||||
const pluginConfig: PluginConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...userOpts,
|
||||
encryptedFolders: userOpts?.encryptedFolders ?? {},
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
validateConfig(pluginConfig)
|
||||
|
||||
// Pre-process folder configurations for efficient lookup
|
||||
const folderConfigs = createFolderConfigHierarchy(pluginConfig.encryptedFolders, pluginConfig)
|
||||
|
||||
/**
|
||||
* Determines the final encryption configuration for a file
|
||||
* Priority: frontmatter > deepest matching folder > global defaults
|
||||
*/
|
||||
const getEncryptionConfig = (file: VFile): EncryptionConfig | undefined => {
|
||||
const frontmatter = file.data?.frontmatter
|
||||
const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig
|
||||
const relativePath = file.data?.relativePath
|
||||
|
||||
const folderConfig = relativePath ? getEncryptionConfigForPath(relativePath, opts) : null
|
||||
// Check if file should be encrypted via frontmatter
|
||||
const shouldEncryptViaFrontmatter = frontmatter?.encrypt === true
|
||||
const frontmatterConfig = frontmatter?.encryptConfig as
|
||||
| (BaseCryptoConfig & { password?: string })
|
||||
| undefined
|
||||
|
||||
if (!folderConfig && !frontmatterConfig.password) {
|
||||
return undefined
|
||||
} else if (!folderConfig && !frontmatter?.encrypt) {
|
||||
// Get folder-based configuration
|
||||
const folderConfig = relativePath
|
||||
? getConfigurationForPath(relativePath, folderConfigs, pluginConfig)
|
||||
: undefined
|
||||
|
||||
// If neither folder config nor frontmatter indicates encryption, skip
|
||||
if (!folderConfig && !shouldEncryptViaFrontmatter) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const config = {
|
||||
algorithm: frontmatterConfig.algorithm || folderConfig?.algorithm || opts.algorithm,
|
||||
password: frontmatterConfig.password || folderConfig?.password || "",
|
||||
message: frontmatterConfig.message || folderConfig?.message || opts.message,
|
||||
ttl: frontmatterConfig.ttl || folderConfig?.ttl || opts.ttl,
|
||||
// Build final configuration with proper precedence
|
||||
let finalConfig: EncryptionConfig | undefined
|
||||
|
||||
if (folderConfig && frontmatterConfig) {
|
||||
// Merge folder and frontmatter configs
|
||||
finalConfig = {
|
||||
algorithm: frontmatterConfig.algorithm ?? folderConfig.algorithm,
|
||||
ttl: frontmatterConfig.ttl ?? folderConfig.ttl,
|
||||
message: frontmatterConfig.message ?? folderConfig.message,
|
||||
password: frontmatterConfig.password ?? folderConfig.password,
|
||||
}
|
||||
} else if (folderConfig) {
|
||||
// Use folder config only
|
||||
finalConfig = folderConfig
|
||||
} else if (frontmatterConfig?.password) {
|
||||
// Use frontmatter config with global defaults
|
||||
finalConfig = {
|
||||
algorithm: frontmatterConfig.algorithm ?? pluginConfig.algorithm,
|
||||
ttl: frontmatterConfig.ttl ?? pluginConfig.ttl,
|
||||
message: frontmatterConfig.message ?? pluginConfig.message,
|
||||
password: frontmatterConfig.password,
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.password) {
|
||||
// Validate final configuration
|
||||
if (!finalConfig?.password) {
|
||||
console.warn(`[EncryptPlugin] No password configured for ${relativePath}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return config
|
||||
return finalConfig
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Encrypt",
|
||||
markdownPlugins() {
|
||||
// If encrypted, prepend lock emoji before the title
|
||||
return [
|
||||
() => {
|
||||
return async (_, file) => {
|
||||
@ -93,34 +253,33 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
|
||||
() => {
|
||||
return async (tree: Root, file) => {
|
||||
const config = getEncryptionConfig(file)
|
||||
|
||||
if (!file.data.hash || !config) {
|
||||
if (!config || !file.data.hash) {
|
||||
return tree
|
||||
}
|
||||
|
||||
const locale = ctx.cfg.configuration.locale
|
||||
const t = i18n(locale).components.encryption
|
||||
|
||||
// Convert html to plaintext and encrypt it
|
||||
file.data.encryptionResult = await encryptContent(
|
||||
toString(tree),
|
||||
config.password,
|
||||
config,
|
||||
)
|
||||
|
||||
// Encrypt the content and generate verification hash
|
||||
// Encrypt the content
|
||||
const encryptionResult = await encryptContent(toHtml(tree), config.password, config)
|
||||
|
||||
// Create individual attributes for each field instead of JSON
|
||||
// Store for later use
|
||||
file.data.encryptionResult = encryptionResult
|
||||
|
||||
// Create attributes for client-side decryption
|
||||
const attributes = [
|
||||
`data-config='${JSON.stringify(config)}'`,
|
||||
`data-config='${JSON.stringify({
|
||||
algorithm: config.algorithm,
|
||||
ttl: config.ttl,
|
||||
message: config.message,
|
||||
})}'`,
|
||||
`data-encrypted='${JSON.stringify(encryptionResult)}'`,
|
||||
`data-hash='${JSON.stringify(file.data.hash)}'`,
|
||||
`data-slug='${file.data.slug}'`,
|
||||
`data-decrypted='false'`,
|
||||
].join(" ")
|
||||
|
||||
// Create a new tree with encrypted content placeholder
|
||||
// Create encrypted content placeholder
|
||||
const encryptedTree = fromHtml(
|
||||
`
|
||||
<div class="encrypted-content" ${attributes}>
|
||||
@ -149,7 +308,6 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
|
||||
|
||||
// Replace the original tree
|
||||
tree.children = encryptedTree.children
|
||||
|
||||
return tree
|
||||
}
|
||||
},
|
||||
@ -170,12 +328,15 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
additionalHead: [],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODULE AUGMENTATION
|
||||
// =============================================================================
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
encryptionConfig: EncryptionConfig
|
||||
|
||||
@ -6,7 +6,7 @@ import toml from "toml"
|
||||
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import { DirectoryConfig } from "../../util/encryption"
|
||||
import { EncryptionConfig } from "../../util/encryption"
|
||||
|
||||
export interface Options {
|
||||
delimiters: string | [string, string]
|
||||
@ -169,7 +169,7 @@ declare module "vfile" {
|
||||
socialImage: string
|
||||
comments: boolean | string
|
||||
encrypt: boolean
|
||||
encryptConfig: DirectoryConfig
|
||||
encryptConfig: EncryptionConfig
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
// =============================================================================
|
||||
// TYPES AND INTERFACES
|
||||
// =============================================================================
|
||||
|
||||
export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const
|
||||
|
||||
export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number]
|
||||
|
||||
// Result of hash operation
|
||||
export interface Hash {
|
||||
hash: string
|
||||
salt: string
|
||||
}
|
||||
|
||||
// Result of encryption operation
|
||||
export interface EncryptionResult {
|
||||
encryptedContent: string
|
||||
encryptionSalt: string
|
||||
@ -18,30 +19,28 @@ export interface EncryptionResult {
|
||||
authTag?: string
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
algorithm: string
|
||||
// Base crypto configuration without password
|
||||
export interface BaseCryptoConfig {
|
||||
algorithm?: SupportedEncryptionAlgorithm
|
||||
ttl?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Directory configuration can be partial or just a password string
|
||||
export type DirectoryConfig = (BaseCryptoConfig & { password: string }) | string
|
||||
|
||||
// Complete crypto configuration with all required fields
|
||||
export interface CompleteCryptoConfig {
|
||||
algorithm: SupportedEncryptionAlgorithm
|
||||
ttl: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface DirectoryConfig extends EncryptionConfig {
|
||||
// Encryption configuration includes password
|
||||
export interface EncryptionConfig extends CompleteCryptoConfig {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface EncryptionOptions extends EncryptionConfig {
|
||||
encryptedFolders: { [folderPath: string]: string | DirectoryConfig }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS AND CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
export const defaultEncryptionConfig: EncryptionConfig = {
|
||||
algorithm: "aes-256-cbc",
|
||||
ttl: 3600 * 24 * 7,
|
||||
message: "This content is encrypted.",
|
||||
}
|
||||
|
||||
const ENCRYPTION_CACHE_KEY = "quartz-encrypt-passwords"
|
||||
|
||||
// =============================================================================
|
||||
@ -197,7 +196,7 @@ export async function verifyPasswordHash(
|
||||
export async function encryptContent(
|
||||
content: string,
|
||||
password: string,
|
||||
config: EncryptionConfig,
|
||||
config: CompleteCryptoConfig,
|
||||
): Promise<EncryptionResult> {
|
||||
checkCryptoSupport()
|
||||
|
||||
@ -290,7 +289,7 @@ export async function encryptContent(
|
||||
|
||||
export async function decryptContent(
|
||||
encrypted: EncryptionResult,
|
||||
config: EncryptionConfig,
|
||||
config: CompleteCryptoConfig,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
checkCryptoSupport()
|
||||
@ -364,72 +363,27 @@ export async function decryptContent(
|
||||
return arrayBufferToString(decryptedBuffer)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION MANAGEMENT
|
||||
// =============================================================================
|
||||
|
||||
export function normalizeDirectoryConfig(
|
||||
folderConfig: string | DirectoryConfig,
|
||||
globalConfig: EncryptionConfig,
|
||||
): DirectoryConfig {
|
||||
if (typeof folderConfig === "string") {
|
||||
return {
|
||||
...globalConfig,
|
||||
password: folderConfig,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm: folderConfig.algorithm || globalConfig.algorithm,
|
||||
ttl: folderConfig.ttl || globalConfig.ttl,
|
||||
password: folderConfig.password,
|
||||
message: folderConfig.message || globalConfig.message,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEncryptionConfigForPath(
|
||||
filePath: string,
|
||||
options: EncryptionOptions,
|
||||
): DirectoryConfig | undefined {
|
||||
if (!options.encryptedFolders) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let deepestFolder = ""
|
||||
let deepestConfig: string | DirectoryConfig | undefined
|
||||
|
||||
for (const [folder, config] of Object.entries(options.encryptedFolders)) {
|
||||
if (filePath.startsWith(folder) && deepestFolder.length < folder.length) {
|
||||
deepestFolder = folder
|
||||
deepestConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
if (deepestConfig) {
|
||||
const globalConfig = {
|
||||
algorithm: options.algorithm || defaultEncryptionConfig.algorithm,
|
||||
ttl: options.ttl || defaultEncryptionConfig.ttl,
|
||||
message: options.message || defaultEncryptionConfig.message,
|
||||
}
|
||||
|
||||
return normalizeDirectoryConfig(deepestConfig, globalConfig)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function searchForValidPassword(
|
||||
filePath: string,
|
||||
hash: Hash,
|
||||
config: EncryptionConfig,
|
||||
config: CompleteCryptoConfig,
|
||||
blacklist: Set<string> | null = null,
|
||||
): Promise<string | undefined> {
|
||||
const passwords = getRelevantPasswords(filePath)
|
||||
|
||||
for (const password of passwords) {
|
||||
if (blacklist && blacklist.has(password)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (await verifyPasswordHash(password, hash)) {
|
||||
addPasswordToCache(password, filePath, config.ttl)
|
||||
return password
|
||||
}
|
||||
|
||||
if (blacklist) {
|
||||
blacklist.add(password)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
@ -566,27 +520,36 @@ export function getSharedDirectoryDepth(path1: string, path2: string): number {
|
||||
export async function contentDecryptedEventListener(
|
||||
filePath: string,
|
||||
hash: Hash,
|
||||
config: EncryptionConfig,
|
||||
config: CompleteCryptoConfig,
|
||||
callback: (password: string) => void,
|
||||
once: boolean = true,
|
||||
) {
|
||||
const checkForValidPassword: () => Promise<boolean> = async () => {
|
||||
const password = await searchForValidPassword(filePath, hash, config)
|
||||
if (password) {
|
||||
callback(password)
|
||||
return true
|
||||
const blacklist = new Set<string>()
|
||||
|
||||
async function decryptionSuccessful(password: string) {
|
||||
addPasswordToCache(password, filePath, config.ttl)
|
||||
callback(password)
|
||||
}
|
||||
|
||||
async function listener(e: CustomEventMap["decrypt"]) {
|
||||
const password = e.detail.password
|
||||
|
||||
if (blacklist.has(password)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await verifyPasswordHash(password, hash)) {
|
||||
document.removeEventListener("decrypt", listener)
|
||||
await decryptionSuccessful(password)
|
||||
return
|
||||
} else {
|
||||
blacklist.add(password)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await checkForValidPassword()
|
||||
|
||||
if (!result || !once) {
|
||||
document.addEventListener("nav", async function listener() {
|
||||
const result = await checkForValidPassword()
|
||||
if (result && once) {
|
||||
document.removeEventListener("nav", listener)
|
||||
}
|
||||
})
|
||||
const password = await searchForValidPassword(filePath, hash, config, blacklist)
|
||||
if (password) {
|
||||
callback(password)
|
||||
}
|
||||
|
||||
document.addEventListener("decrypt", listener)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user