From 19abdca7642cd2941564ebcef49a25c2af58d507 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 19:20:32 +0200 Subject: [PATCH 01/18] Created plugin to publish encrypted pages --- quartz.config.ts | 10 +- quartz/components/scripts/encrypt.inline.ts | 361 ++++++++++++++++++++ quartz/components/styles/encrypt.scss | 149 ++++++++ quartz/plugins/emitters/contentIndex.tsx | 9 +- quartz/plugins/transformers/description.ts | 5 + quartz/plugins/transformers/encrypt.ts | 224 ++++++++++++ quartz/plugins/transformers/index.ts | 1 + 7 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 quartz/components/scripts/encrypt.inline.ts create mode 100644 quartz/components/styles/encrypt.scss create mode 100644 quartz/plugins/transformers/encrypt.ts diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..b85e51b74 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -70,8 +70,16 @@ const config: QuartzConfig = { Plugin.GitHubFlavoredMarkdown(), Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), - Plugin.Description(), Plugin.Latex({ renderEngine: "katex" }), + Plugin.EncryptPlugin({ + algorithm: "aes-256-cbc", + keyLength: 32, + iterations: 100000, + encryptedFolders: { + }, + ttl: 3600 * 24 * 7, // A week + }), + Plugin.Description(), ], filters: [Plugin.RemoveDrafts()], emitters: [ diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts new file mode 100644 index 000000000..ca28f30e8 --- /dev/null +++ b/quartz/components/scripts/encrypt.inline.ts @@ -0,0 +1,361 @@ +// Password cache management +const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords" + +function getPasswordCache(): Record { + try { + const cache = localStorage.getItem(PASSWORD_CACHE_KEY) + return cache ? JSON.parse(cache) : {} + } catch { + return {} + } +} + +function savePasswordCache(cache: Record) { + try { + localStorage.setItem(PASSWORD_CACHE_KEY, JSON.stringify(cache)) + } catch { + // Silent fail if localStorage is not available + } +} + +function addPasswordToCache(password: string, filePath: string, ttl: number) { + const cache = getPasswordCache() + const now = Date.now() + + // Store password for exact file path + cache[filePath] = { + password, + ttl: ttl <= 0 ? 0 : now + ttl, + } + + savePasswordCache(cache) +} + +function getRelevantPasswords(filePath: string): string[] { + const cache = getPasswordCache() + const now = Date.now() + const passwords: string[] = [] + + // Clean expired passwords (but keep infinite TTL ones) + Object.keys(cache).forEach((path) => { + if (cache[path].ttl > 0 && cache[path].ttl < now) { + delete cache[path] + } + }) + + // Get passwords by directory hierarchy (closest first) + const pathParts = filePath.split("/") + + // Sort cache keys by how many directory levels they share with current file + const sortedPaths = Object.keys(cache).sort((a, b) => { + const aShared = getSharedDirectoryDepth(a, filePath) + const bShared = getSharedDirectoryDepth(b, filePath) + return bShared - aShared // Descending order (most shared first) + }) + + for (const cachedPath of sortedPaths) { + if (getSharedDirectoryDepth(cachedPath, filePath) > 0) { + passwords.push(cache[cachedPath].password) + } + } + + savePasswordCache(cache) + return passwords +} + +function getSharedDirectoryDepth(path1: string, path2: string): number { + const parts1 = path1.split("/") + const parts2 = path2.split("/") + let sharedDepth = 0 + + const minLength = Math.min(parts1.length, parts2.length) + for (let i = 0; i < minLength - 1; i++) { + // -1 to exclude filename + if (parts1[i] === parts2[i]) { + sharedDepth++ + } else { + break + } + } + + return sharedDepth +} + +// Helper: hex string to ArrayBuffer +function hexToArrayBuffer(hex: string): ArrayBuffer { + if (!hex) return new ArrayBuffer(0) + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16) + } + return bytes.buffer +} + +// Helper: ArrayBuffer to hex string +function arrayBufferToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +// Helper: string to ArrayBuffer +function stringToArrayBuffer(str: string): ArrayBuffer { + const encoder = new TextEncoder() + return encoder.encode(str) +} + +// Helper: ArrayBuffer to string +function arrayBufferToString(buffer: ArrayBuffer): string { + const decoder = new TextDecoder() + return decoder.decode(buffer) +} + +async function verifyPassword(password: string, parsed: any): Promise { + // Hash password with salt for verification using SubtleCrypto + const encoder = new TextEncoder() + const passwordBytes = encoder.encode(password) + const saltBytes = hexToArrayBuffer(parsed.salt) + + // Concatenate password and salt + const combined = new Uint8Array(passwordBytes.byteLength + saltBytes.byteLength) + combined.set(new Uint8Array(passwordBytes), 0) + combined.set(new Uint8Array(saltBytes), passwordBytes.byteLength) + + // Hash using SHA-256 + const hashBuffer = await crypto.subtle.digest("SHA-256", combined) + const passwordHash = arrayBufferToHex(hashBuffer) + + return passwordHash === parsed.passwordHash +} + +async function performDecryption(password: string, parsed: any, config: any): Promise { + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ) + + // Derive key using PBKDF2 + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: hexToArrayBuffer(parsed.salt), + iterations: config.iterations, + hash: "SHA-256", + }, + keyMaterial, + { name: getAlgorithmName(config.algorithm), length: config.keyLength * 8 }, + false, + ["decrypt"], + ) + + const ciphertext = hexToArrayBuffer(parsed.content) + + let decryptedBuffer: ArrayBuffer + + if (config.algorithm.includes("gcm")) { + // GCM mode + const iv = hexToArrayBuffer(parsed.iv) + const authTag = parsed.authTag ? hexToArrayBuffer(parsed.authTag) : null + + // For GCM, concatenate ciphertext + authTag + let fullCiphertext = ciphertext + if (authTag) { + const combined = new Uint8Array(ciphertext.byteLength + authTag.byteLength) + combined.set(new Uint8Array(ciphertext), 0) + combined.set(new Uint8Array(authTag), ciphertext.byteLength) + fullCiphertext = combined.buffer + } + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + fullCiphertext, + ) + } else if (config.algorithm.includes("cbc")) { + // CBC mode + const iv = hexToArrayBuffer(parsed.iv) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: iv, + }, + key, + ciphertext, + ) + } else if (config.algorithm.includes("ecb")) { + // ECB mode - simulate using CBC with zero IV (SubtleCrypto doesn't support ECB directly) + const zeroIv = new ArrayBuffer(16) // 16 bytes of zeros + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: zeroIv, + }, + key, + ciphertext, + ) + } else { + throw new Error("Unsupported algorithm: " + config.algorithm) + } + + return arrayBufferToString(decryptedBuffer) +} + +function getAlgorithmName(algorithm: string): string { + if (algorithm.includes("gcm")) return "AES-GCM" + if (algorithm.includes("cbc")) return "AES-CBC" + if (algorithm.includes("ecb")) return "AES-CBC" // Use CBC for ECB simulation + throw new Error("Unsupported algorithm: " + algorithm) +} + +function showLoading(container: Element, show: boolean) { + const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement + const form = container.querySelector(".decrypt-form") as HTMLElement + + if (loadingDiv && form) { + if (show) { + form.style.display = "none" + loadingDiv.style.display = "flex" + } else { + form.style.display = "flex" + loadingDiv.style.display = "none" + } + } +} + +async function decryptWithPassword( + container: Element, + password: string, + showError = true, +): Promise { + const errorDiv = container.querySelector(".decrypt-error") as HTMLElement + const encryptedData = (container as HTMLElement).dataset.encrypted! + const config = JSON.parse((container as HTMLElement).dataset.config!) + + if (showError) errorDiv.style.display = "none" + + try { + const parsed = JSON.parse(atob(encryptedData)) + + // First verify password hash + const isValidPassword = await verifyPassword(password, parsed) + + if (!isValidPassword) { + if (showError) throw new Error("Incorrect password") + return false + } + + // Show loading indicator when hash passes and give UI time to update + if (showError) { + showLoading(container, true) + // Allow UI to update before starting heavy computation + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + try { + // If hash matches, decrypt content + const decryptedContent = await performDecryption(password, parsed, config) + + if (decryptedContent) { + // Cache the password + const filePath = window.location.pathname + addPasswordToCache(password, filePath, config.ttl) + + // Replace content + const contentWrapper = document.createElement("div") + contentWrapper.className = "decrypted-content-wrapper" + contentWrapper.innerHTML = decryptedContent + container.parentNode!.replaceChild(contentWrapper, container) + return true + } + + if (showError) throw new Error("Decryption failed, check logs") + return false + } catch (decryptError) { + if (showError) showLoading(container, false) + if (showError) throw new Error("Decryption failed, check logs") + return false + } + } catch (error) { + if (showError) { + showLoading(container, false) + errorDiv.style.display = "block" + errorDiv.textContent = error instanceof Error ? error.message : "Decryption failed" + const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + if (passwordInput) { + passwordInput.value = "" + passwordInput.focus() + } + } + return false + } +} + +async function tryAutoDecrypt(container: Element): Promise { + const filePath = window.location.pathname + const passwords = getRelevantPasswords(filePath) + + for (const password of passwords) { + if (await decryptWithPassword(container, password, false)) { + return true + } + } + return false +} + +async function manualDecrypt(container: Element) { + const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + const password = passwordInput.value + + if (!password) { + passwordInput.focus() + return + } + + await decryptWithPassword(container, password, true) +} + +document.addEventListener("nav", async () => { + // Try auto-decryption for all encrypted content + const encryptedElements = document.querySelectorAll(".encrypted-content") + + for (const container of encryptedElements) { + await tryAutoDecrypt(container) + } + + // Manual decryption handlers + const buttons = document.querySelectorAll(".decrypt-button") + + buttons.forEach((button) => { + const handleClick = async function (this: HTMLElement) { + const container = this.closest(".encrypted-content")! + await manualDecrypt(container) + } + + button.addEventListener("click", handleClick) + window.addCleanup(() => button.removeEventListener("click", handleClick)) + }) + + // Enter key handler + document.querySelectorAll(".decrypt-password").forEach((input) => { + const handleKeypress = async function (this: HTMLInputElement, e: Event) { + const keyEvent = e as KeyboardEvent + if (keyEvent.key === "Enter") { + const container = this.closest(".encrypted-content")! + await manualDecrypt(container) + } + } + + input.addEventListener("keypress", handleKeypress) + window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + }) +}) diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss new file mode 100644 index 000000000..4128f6af8 --- /dev/null +++ b/quartz/components/styles/encrypt.scss @@ -0,0 +1,149 @@ +@use "../../styles/variables.scss" as *; + +.encrypted-content { + display: flex; + align-items: center; + justify-content: center; +} + +.encryption-notice { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--lightgray); + border-radius: 8px; + padding: 2rem 1.5rem; + max-width: 450px; + width: 100%; + text-align: center; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + margin-top: 2rem; +} + +.encryption-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; + opacity: 0.7; +} + +.encryption-notice h3 { + margin: 0 0 0.5rem 0; + color: var(--darkgray); + font-size: 1.3rem; + font-weight: 600; +} + +.encryption-notice p { + margin: 0 0 1.5rem 0; + color: var(--gray); + line-height: 1.4; + font-size: 0.95rem; +} + +.decrypted-content-wrapper { + /* Reset any container styles that might interfere */ + display: block; + margin: 0; + padding: 0; +} + +.decrypt-form { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +.decrypt-password { + width: 100%; + box-sizing: border-box; + padding: 0.75rem 1rem; + border-radius: 5px; + font-family: var(--bodyFont); + font-size: 1rem; + border: 1px solid var(--gray); + background: rgba(0, 0, 0, 0); + color: var(--dark); + transition: border-color 0.2s ease; +} + +.decrypt-password:focus { + outline: none; + border-color: var(--secondary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent); +} + +.decrypt-button { + background: var(--secondary); + color: var(--light); + border: none; + border-radius: 5px; + padding: 0.75rem 2rem; + font-family: var(--bodyFont); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; +} + +.decrypt-button:hover { + background: color-mix(in srgb, var(--secondary) 90%, var(--dark)); + transform: translateY(-1px); + box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary) 30%, transparent); +} + +.decrypt-button:active { + transform: translateY(0); +} + +.decrypt-loading { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--secondary) 10%, var(--light)); + border: 1px solid color-mix(in srgb, var(--secondary) 30%, transparent); + border-radius: 5px; + color: var(--secondary); + font-size: 0.9rem; + text-align: center; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid color-mix(in srgb, var(--secondary) 20%, transparent); + border-top: 2px solid var(--secondary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.decrypt-error { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, #ef4444 10%, var(--light)); + border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); + border-radius: 5px; + color: #dc2626; + font-size: 0.9rem; + text-align: center; +} + +@media (max-width: 600px) { + .encryption-notice { + padding: 1.5rem 1rem; + margin: 1rem; + } + + .decrypt-password, + .decrypt-button { + font-size: 16px; /* Prevent zoom on iOS */ + } +} diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..ddfcf752b 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -19,6 +19,7 @@ export type ContentDetails = { richContent?: string date?: Date description?: string + encrypted?: boolean } interface Options { @@ -58,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} - + ${content.date?.toUTCString()} ` @@ -115,6 +116,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { : undefined, date: date, description: file.data.description ?? "", + encrypted: file.data.encrypted, }) } } @@ -143,6 +145,11 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // remove description and from content index as nothing downstream // actually uses it. we only keep it in the index as we need it // for the RSS feed + if (content.encrypted) { + delete content.content + delete content.richContent + } + delete content.description delete content.date return [slug, content] diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 3f8519b32..bc075e5a1 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -28,6 +28,11 @@ export const Description: QuartzTransformerPlugin> = (userOpts) return [ () => { return async (tree: HTMLRoot, file) => { + if (file.data?.encrypted) { + file.data.description = "This file is encrypted. Open it to see the contents." + return; + } + let frontMatterDescription = file.data.frontmatter?.description let text = escapeHTML(toString(tree)) diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts new file mode 100644 index 000000000..2b06881b0 --- /dev/null +++ b/quartz/plugins/transformers/encrypt.ts @@ -0,0 +1,224 @@ +import { createCipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto" +import { QuartzTransformerPlugin } from "../types" +import { Root } from "hast" +import { toHtml } from "hast-util-to-html" +import { fromHtml } from "hast-util-from-html" +import { VFile } from "vfile" + +// @ts-ignore +import encryptScript from "../../components/scripts/encrypt.inline.ts" +import encryptStyle from "../../components/styles/encrypt.scss" + +export interface Options { + algorithm?: string + keyLength?: number + iterations?: number + encryptedFolders?: { [folderPath: string]: string } // json object with folder paths as keys and passwords as values + ttl: number +} + +const defaultOptions: Options = { + algorithm: "aes-256-cbc", + keyLength: 32, + iterations: 100000, + encryptedFolders: {}, + ttl: 3600 * 24 * 7, +} + +const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const + +type SupportedAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] + +function deriveKey(password: string, salt: Buffer, keyLength: number, iterations: number): Buffer { + return pbkdf2Sync(password, salt, iterations, keyLength, "sha256") +} + +function hashPassword(password: string, salt: Buffer): string { + // Create a fast hash for password verification (separate from key derivation) + const hash = createHash("sha256") + hash.update(password) + hash.update(salt) + const result = hash.digest("hex") + return result +} + +function encryptContent(content: string, password: string, options: Options): string { + const { algorithm, keyLength, iterations } = { ...defaultOptions, ...options } + + // Validate algorithm + if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedAlgorithm)) { + throw new Error( + `Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + const salt = randomBytes(16) + const key = deriveKey(password, salt, keyLength!, iterations!) + + let iv: Buffer | undefined + let cipher: any + + // Handle different encryption modes + if (algorithm === "aes-256-ecb") { + // ECB doesn't use IV + cipher = createCipheriv(algorithm!, key, null) + } else { + // CBC and GCM use IV + iv = randomBytes(16) + cipher = createCipheriv(algorithm!, key, iv) + } + + let encrypted = cipher.update(content, "utf8", "hex") + encrypted += cipher.final("hex") + + // Handle auth tag for GCM + let authTag: string | undefined + if (algorithm === "aes-256-gcm") { + authTag = cipher.getAuthTag().toString("hex") + } + + // Create password hash for verification + const passwordHash = hashPassword(password, salt) + + // Build result object + const result: any = { + salt: salt.toString("hex"), + content: encrypted, + passwordHash, + } + + if (iv) { + result.iv = iv.toString("hex") + } + + if (authTag) { + result.authTag = authTag + } + + return Buffer.from(JSON.stringify(result)).toString("base64") +} + +export const EncryptPlugin: QuartzTransformerPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + + // Validate algorithm at build time + if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedAlgorithm)) { + throw new Error( + `[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + const getPassword = (file: VFile): string | undefined => { + const frontmatter = file.data?.frontmatter + + if (frontmatter?.encrypt && frontmatter?.password) { + return frontmatter.password as string + } + + let deepestFolder = ""; + for (const folder of Object.keys(opts.encryptedFolders ?? {})) { + if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { + deepestFolder = folder; + } + } + + if (deepestFolder) { + return String(opts.encryptedFolders[deepestFolder]) + } + } + + return { + name: "EncryptPlugin", + markdownPlugins() { + // If encypted, prepend lock emoji before the title + return [ + () => { + return (_, file) => { + const password = getPassword(file) + if (!password) { + return + } + + file.data.encrypted = true + + if (file.data?.frontmatter?.title) { + file.data.frontmatter.title = `🔒 ${file.data.frontmatter.title}` + } + } + }, + ] + }, + htmlPlugins() { + return [ + () => { + return (tree: Root, file) => { + const password = getPassword(file) + if (!password) { + return tree // No encryption, return original tree + } + + // Convert the HTML tree to string + const htmlContent = toHtml(tree) + + // Encrypt the content + const encryptedContent = encryptContent(htmlContent, password, opts) + + // Create a new tree with encrypted content placeholder + const encryptedTree = fromHtml( + ` +
+
+

🛡️ Restricted Content 🛡️

+

This content is restricted. Enter the password to view:

+
+ + +
+
+
+ Decrypting... +
+
+ Incorrect password. Please try again. +
+
+
+ `, + { fragment: true }, + ) + + // Replace the original tree + tree.children = encryptedTree.children + + return tree + } + }, + ] + }, + externalResources() { + return { + js: [ + { + loadTime: "afterDOMReady", + contentType: "inline", + script: encryptScript, + }, + ], + css: [ + { + content: encryptStyle, + inline: true, + }, + ], + additionalHead: [], + } + }, + } +} + + +declare module "vfile" { + interface DataMap { + encrypted: boolean + } +} diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..74b57ef6c 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,6 +8,7 @@ export { CrawlLinks } from "./links" export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" +export { EncryptPlugin } from "./encrypt" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam" From 33e4e8ea52b10d37fd749beec2c7d1dc312cd286 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 19:44:13 +0200 Subject: [PATCH 02/18] Use frontmatter password if encrypting due to folder --- quartz.config.ts | 3 +-- quartz/plugins/transformers/encrypt.ts | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/quartz.config.ts b/quartz.config.ts index b85e51b74..52ae335f2 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -75,8 +75,7 @@ const config: QuartzConfig = { algorithm: "aes-256-cbc", keyLength: 32, iterations: 100000, - encryptedFolders: { - }, + encryptedFolders: {}, ttl: 3600 * 24 * 7, // A week }), Plugin.Description(), diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index 2b06881b0..64ba7aa38 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -123,7 +123,11 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt } if (deepestFolder) { - return String(opts.encryptedFolders[deepestFolder]) + if (frontmatter?.password) { // if frontmatter has a password, use it + return frontmatter.password as string; + } + + return opts.encryptedFolders[deepestFolder] as string; } } From c0a6fa35ac1d3f89fe6b32fd2f9837b9535a0d15 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 19:52:44 +0200 Subject: [PATCH 03/18] Fix pipeline --- quartz/components/scripts/encrypt.inline.ts | 3 +-- quartz/components/styles/encrypt.scss | 8 ++++++-- quartz/plugins/emitters/contentIndex.tsx | 4 ++-- quartz/plugins/transformers/description.ts | 2 +- quartz/plugins/transformers/encrypt.ts | 16 ++++++++-------- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index ca28f30e8..90f0aed7b 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -44,7 +44,6 @@ function getRelevantPasswords(filePath: string): string[] { }) // Get passwords by directory hierarchy (closest first) - const pathParts = filePath.split("/") // Sort cache keys by how many directory levels they share with current file const sortedPaths = Object.keys(cache).sort((a, b) => { @@ -102,7 +101,7 @@ function arrayBufferToHex(buffer: ArrayBuffer): string { // Helper: string to ArrayBuffer function stringToArrayBuffer(str: string): ArrayBuffer { const encoder = new TextEncoder() - return encoder.encode(str) + return encoder.encode(str).buffer as ArrayBuffer } // Helper: ArrayBuffer to string diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index 4128f6af8..44a74eba5 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -120,8 +120,12 @@ } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } .decrypt-error { diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index ddfcf752b..416a2515c 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -59,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} - + ${content.date?.toUTCString()} ` @@ -146,7 +146,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // actually uses it. we only keep it in the index as we need it // for the RSS feed if (content.encrypted) { - delete content.content + content.description = "" delete content.richContent } diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index bc075e5a1..d6a9e3c1f 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -30,7 +30,7 @@ export const Description: QuartzTransformerPlugin> = (userOpts) return async (tree: HTMLRoot, file) => { if (file.data?.encrypted) { file.data.description = "This file is encrypted. Open it to see the contents." - return; + return } let frontMatterDescription = file.data.frontmatter?.description diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index 64ba7aa38..dbf3a104b 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -115,19 +115,20 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt return frontmatter.password as string } - let deepestFolder = ""; + let deepestFolder = "" for (const folder of Object.keys(opts.encryptedFolders ?? {})) { - if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { - deepestFolder = folder; - } + if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { + deepestFolder = folder + } } if (deepestFolder) { - if (frontmatter?.password) { // if frontmatter has a password, use it - return frontmatter.password as string; + if (frontmatter?.password) { + // if frontmatter has a password, use it + return frontmatter.password as string } - return opts.encryptedFolders[deepestFolder] as string; + return opts.encryptedFolders!![deepestFolder] as string } } @@ -220,7 +221,6 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt } } - declare module "vfile" { interface DataMap { encrypted: boolean From 9309a0f9258ebe9226d14e87d93f507d2b4f4bad Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 20:05:58 +0200 Subject: [PATCH 04/18] Localization --- quartz.config.ts | 2 +- quartz/components/scripts/encrypt.inline.ts | 7 ++++--- quartz/i18n/locales/ar-SA.ts | 10 ++++++++++ quartz/i18n/locales/ca-ES.ts | 10 ++++++++++ quartz/i18n/locales/cs-CZ.ts | 10 ++++++++++ quartz/i18n/locales/de-DE.ts | 10 ++++++++++ quartz/i18n/locales/definition.ts | 10 ++++++++++ quartz/i18n/locales/en-GB.ts | 10 ++++++++++ quartz/i18n/locales/en-US.ts | 10 ++++++++++ quartz/i18n/locales/es-ES.ts | 10 ++++++++++ quartz/i18n/locales/fa-IR.ts | 10 ++++++++++ quartz/i18n/locales/fi-FI.ts | 10 ++++++++++ quartz/i18n/locales/fr-FR.ts | 10 ++++++++++ quartz/i18n/locales/hu-HU.ts | 10 ++++++++++ quartz/i18n/locales/id-ID.ts | 10 ++++++++++ quartz/i18n/locales/it-IT.ts | 10 ++++++++++ quartz/i18n/locales/ja-JP.ts | 10 ++++++++++ quartz/i18n/locales/ko-KR.ts | 10 ++++++++++ quartz/i18n/locales/lt-LT.ts | 10 ++++++++++ quartz/i18n/locales/nb-NO.ts | 10 ++++++++++ quartz/i18n/locales/nl-NL.ts | 10 ++++++++++ quartz/i18n/locales/pl-PL.ts | 10 ++++++++++ quartz/i18n/locales/pt-BR.ts | 10 ++++++++++ quartz/i18n/locales/ro-RO.ts | 10 ++++++++++ quartz/i18n/locales/ru-RU.ts | 10 ++++++++++ quartz/i18n/locales/th-TH.ts | 10 ++++++++++ quartz/i18n/locales/tr-TR.ts | 10 ++++++++++ quartz/i18n/locales/uk-UA.ts | 10 ++++++++++ quartz/i18n/locales/vi-VN.ts | 10 ++++++++++ quartz/i18n/locales/zh-CN.ts | 10 ++++++++++ quartz/i18n/locales/zh-TW.ts | 10 ++++++++++ quartz/plugins/transformers/description.ts | 5 +++-- quartz/plugins/transformers/encrypt.ts | 20 ++++++++++++-------- 33 files changed, 310 insertions(+), 14 deletions(-) diff --git a/quartz.config.ts b/quartz.config.ts index 52ae335f2..5d69855fe 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -15,7 +15,7 @@ const config: QuartzConfig = { analytics: { provider: "plausible", }, - locale: "en-US", + locale: "fi-FI", baseUrl: "quartz.jzhao.xyz", ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "modified", diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index 90f0aed7b..fd01deab4 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -239,6 +239,7 @@ async function decryptWithPassword( const errorDiv = container.querySelector(".decrypt-error") as HTMLElement const encryptedData = (container as HTMLElement).dataset.encrypted! const config = JSON.parse((container as HTMLElement).dataset.config!) + const i18n = JSON.parse((container as HTMLElement).dataset.i18n!) if (showError) errorDiv.style.display = "none" @@ -249,7 +250,7 @@ async function decryptWithPassword( const isValidPassword = await verifyPassword(password, parsed) if (!isValidPassword) { - if (showError) throw new Error("Incorrect password") + if (showError) throw new Error(i18n.incorrectPassword) return false } @@ -277,11 +278,11 @@ async function decryptWithPassword( return true } - if (showError) throw new Error("Decryption failed, check logs") + if (showError) throw new Error(i18n.decryptionFailed) return false } catch (decryptError) { if (showError) showLoading(container, false) - if (showError) throw new Error("Decryption failed, check logs") + if (showError) throw new Error(i18n.decryptionFailed) return false } } catch (error) { diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts index a1979def9..7a6db7650 100644 --- a/quartz/i18n/locales/ar-SA.ts +++ b/quartz/i18n/locales/ar-SA.ts @@ -64,6 +64,16 @@ export default { ? `دقيقتان للقراءة` : `${minutes} دقائق للقراءة`, }, + encryption: { + title: "🛡️ محتوى محدود 🛡️", + restricted: "هذا المحتوى محدود. ادخل كلمة المرور للعرض:", + enterPassword: "ادخل كلمة المرور", + decrypt: "فك التشفير", + decrypting: "جاري فك التشفير...", + incorrectPassword: "كلمة مرور خاطئة. حاول مرة أخرى.", + decryptionFailed: "فشل فك التشفير، تحقق من السجلات", + encryptedDescription: "هذا الملف مشفر. افتحه لرؤية المحتويات.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ca-ES.ts b/quartz/i18n/locales/ca-ES.ts index 1483d9c57..73c07f2f1 100644 --- a/quartz/i18n/locales/ca-ES.ts +++ b/quartz/i18n/locales/ca-ES.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`, }, + encryption: { + title: "🛡️ Contingut Restringit 🛡️", + restricted: "Aquest contingut està restringit. Introduïu la contrasenya per veure:", + enterPassword: "Introduïu la contrasenya", + decrypt: "Desxifrar", + decrypting: "Desxifrant...", + incorrectPassword: "Contrasenya incorrecta. Torneu-ho a intentar.", + decryptionFailed: "Ha fallat el desxifratge, comproveu els registres", + encryptedDescription: "Aquest fitxer està xifrat. Obriu-lo per veure els continguts.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/cs-CZ.ts b/quartz/i18n/locales/cs-CZ.ts index 87f19964f..4648faa6a 100644 --- a/quartz/i18n/locales/cs-CZ.ts +++ b/quartz/i18n/locales/cs-CZ.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min čtení`, }, + encryption: { + title: "🛡️ Omezený obsah 🛡️", + restricted: "Tento obsah je omezen. Zadejte heslo pro zobrazení:", + enterPassword: "Zadejte heslo", + decrypt: "Dešifrovat", + decrypting: "Dešifruji...", + incorrectPassword: "Nesprávné heslo. Zkuste to znovu.", + decryptionFailed: "Dešifrování selhalo, zkontrolujte protokoly", + encryptedDescription: "Tento soubor je zašifrován. Otevřete jej pro zobrazení obsahu.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts index 85a7353a8..1e333de13 100644 --- a/quartz/i18n/locales/de-DE.ts +++ b/quartz/i18n/locales/de-DE.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ Eingeschränkter Inhalt 🛡️", + restricted: "Dieser Inhalt ist eingeschränkt. Geben Sie das Passwort ein, um ihn anzuzeigen:", + enterPassword: "Passwort eingeben", + decrypt: "Entschlüsseln", + decrypting: "Entschlüsselt...", + incorrectPassword: "Falsches Passwort. Bitte versuchen Sie es erneut.", + decryptionFailed: "Entschlüsselung fehlgeschlagen, überprüfen Sie die Protokolle", + encryptedDescription: "Diese Datei ist verschlüsselt. Öffnen Sie sie, um den Inhalt zu sehen.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts index ee1649abc..b51eb94d3 100644 --- a/quartz/i18n/locales/definition.ts +++ b/quartz/i18n/locales/definition.ts @@ -61,6 +61,16 @@ export interface Translation { contentMeta: { readingTime: (variables: { minutes: number }) => string } + encryption: { + title: string + restricted: string + enterPassword: string + decrypt: string + decrypting: string + incorrectPassword: string + decryptionFailed: string + encryptedDescription: string + } } pages: { rss: { diff --git a/quartz/i18n/locales/en-GB.ts b/quartz/i18n/locales/en-GB.ts index 80e52bf3c..f3c1eb52f 100644 --- a/quartz/i18n/locales/en-GB.ts +++ b/quartz/i18n/locales/en-GB.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ Restricted Content 🛡️", + restricted: "This content is restricted. Enter the password to view:", + enterPassword: "Enter password", + decrypt: "Decrypt", + decrypting: "Decrypting...", + incorrectPassword: "Incorrect password. Please try again.", + decryptionFailed: "Decryption failed, check logs", + encryptedDescription: "This file is encrypted. Open it to see the contents.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts index e1111e9ab..1ea4437a4 100644 --- a/quartz/i18n/locales/en-US.ts +++ b/quartz/i18n/locales/en-US.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ Restricted Content 🛡️", + restricted: "This content is restricted. Enter the password to view:", + enterPassword: "Enter password", + decrypt: "Decrypt", + decrypting: "Decrypting...", + incorrectPassword: "Incorrect password. Please try again.", + decryptionFailed: "Decryption failed, check logs", + encryptedDescription: "This file is encrypted. Open it to see the contents.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts index b7e425e81..f79b5a374 100644 --- a/quartz/i18n/locales/es-ES.ts +++ b/quartz/i18n/locales/es-ES.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `Se lee en ${minutes} min`, }, + encryption: { + title: "🛡️ Contenido Restringido 🛡️", + restricted: "Este contenido está restringido. Ingrese la contraseña para ver:", + enterPassword: "Ingrese contraseña", + decrypt: "Desencriptar", + decrypting: "Desencriptando...", + incorrectPassword: "Contraseña incorrecta. Intente de nuevo.", + decryptionFailed: "Desencriptación falló, revise los registros", + encryptedDescription: "Este archivo está encriptado. Ábralo para ver los contenidos.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fa-IR.ts b/quartz/i18n/locales/fa-IR.ts index 0300174f3..1fcef66ee 100644 --- a/quartz/i18n/locales/fa-IR.ts +++ b/quartz/i18n/locales/fa-IR.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`, }, + encryption: { + title: "🛡️ محتوای محدود 🛡️", + restricted: "این محتوا محدود است. رمز عبور را وارد کنید:", + enterPassword: "رمز عبور را وارد کنید", + decrypt: "رمزگشایی", + decrypting: "در حال رمزگشایی...", + incorrectPassword: "رمز عبور اشتباه است. دوباره تلاش کنید.", + decryptionFailed: "رمزگشایی ناموفق، لاگ‌ها را بررسی کنید", + encryptedDescription: "این فایل رمزگذاری شده است. آن را باز کنید تا محتوا را ببینید.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fi-FI.ts b/quartz/i18n/locales/fi-FI.ts index 19e102a31..4375b07f7 100644 --- a/quartz/i18n/locales/fi-FI.ts +++ b/quartz/i18n/locales/fi-FI.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min lukuaika`, }, + encryption: { + title: "🛡️ Rajoitettu Sisältö 🛡️", + restricted: "Tämä sisältö on rajoitettu. Anna salasana nähdäksesi:", + enterPassword: "Anna salasana", + decrypt: "Pura salaus", + decrypting: "Puretaan salausta...", + incorrectPassword: "Väärä salasana. Yritä uudelleen.", + decryptionFailed: "Salauksen purku epäonnistui, tarkista lokit", + encryptedDescription: "Tämä tiedosto on salattu. Avaa se nähdäksesi sisällön.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts index f6df309a6..60e7d1fec 100644 --- a/quartz/i18n/locales/fr-FR.ts +++ b/quartz/i18n/locales/fr-FR.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min de lecture`, }, + encryption: { + title: "🛡️ Contenu Restreint 🛡️", + restricted: "Ce contenu est restreint. Entrez le mot de passe pour l'afficher :", + enterPassword: "Entrez le mot de passe", + decrypt: "Déchiffrer", + decrypting: "Déchiffrement...", + incorrectPassword: "Mot de passe incorrect. Veuillez réessayer.", + decryptionFailed: "Échec du déchiffrement, vérifiez les journaux", + encryptedDescription: "Ce fichier est chiffré. Ouvrez-le pour voir le contenu.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/hu-HU.ts b/quartz/i18n/locales/hu-HU.ts index 2a4f07727..ab022546b 100644 --- a/quartz/i18n/locales/hu-HU.ts +++ b/quartz/i18n/locales/hu-HU.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} perces olvasás`, }, + encryption: { + title: "🛡️ Korlátozott Tartalom 🛡️", + restricted: "Ez a tartalom korlátozott. Adja meg a jelszót a megtekintéshez:", + enterPassword: "Jelszó megadása", + decrypt: "Visszafejtés", + decrypting: "Visszafejtés...", + incorrectPassword: "Helytelen jelszó. Kérjük, próbálja újra.", + decryptionFailed: "A visszafejtés sikertelen, ellenőrizze a naplókat", + encryptedDescription: "Ez a fájl titkosított. Nyissa meg a tartalom megtekintéséhez.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/id-ID.ts b/quartz/i18n/locales/id-ID.ts index 813e2bb57..06da19e21 100644 --- a/quartz/i18n/locales/id-ID.ts +++ b/quartz/i18n/locales/id-ID.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} menit baca`, }, + encryption: { + title: "🛡️ Konten Terbatas 🛡️", + restricted: "Konten ini terbatas. Masukkan kata sandi untuk melihat:", + enterPassword: "Masukkan kata sandi", + decrypt: "Dekripsi", + decrypting: "Mendekripsi...", + incorrectPassword: "Kata sandi salah. Silakan coba lagi.", + decryptionFailed: "Dekripsi gagal, periksa log", + encryptedDescription: "File ini terenkripsi. Buka untuk melihat konten.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/it-IT.ts b/quartz/i18n/locales/it-IT.ts index be25bb640..911a05bc4 100644 --- a/quartz/i18n/locales/it-IT.ts +++ b/quartz/i18n/locales/it-IT.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} minuti`, }, + encryption: { + title: "🛡️ Contenuto Riservato 🛡️", + restricted: "Questo contenuto è riservato. Inserisci la password per visualizzare:", + enterPassword: "Inserisci password", + decrypt: "Decripta", + decrypting: "Decriptando...", + incorrectPassword: "Password errata. Riprova.", + decryptionFailed: "Decriptazione fallita, controlla i log", + encryptedDescription: "Questo file è criptato. Aprilo per vedere i contenuti.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts index 3c30124d7..6372b3fca 100644 --- a/quartz/i18n/locales/ja-JP.ts +++ b/quartz/i18n/locales/ja-JP.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ 制限されたコンテンツ 🛡️", + restricted: "このコンテンツは制限されています。表示するにはパスワードを入力してください:", + enterPassword: "パスワードを入力", + decrypt: "復号化", + decrypting: "復号化中...", + incorrectPassword: "パスワードが間違っています。もう一度お試しください。", + decryptionFailed: "復号化に失敗しました。ログを確認してください", + encryptedDescription: "このファイルは暗号化されています。内容を見るには開いてください。", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ko-KR.ts b/quartz/i18n/locales/ko-KR.ts index 1bca096b2..c2d9ca85a 100644 --- a/quartz/i18n/locales/ko-KR.ts +++ b/quartz/i18n/locales/ko-KR.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ 제한된 콘텐츠 🛡️", + restricted: "이 콘텐츠는 제한되어 있습니다. 보려면 비밀번호를 입력하세요:", + enterPassword: "비밀번호 입력", + decrypt: "복호화", + decrypting: "복호화 중...", + incorrectPassword: "비밀번호가 틀렸습니다. 다시 시도해주세요.", + decryptionFailed: "복호화에 실패했습니다. 로그를 확인하세요", + encryptedDescription: "이 파일은 암호화되어 있습니다. 내용을 보려면 열어주세요.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/lt-LT.ts b/quartz/i18n/locales/lt-LT.ts index 690960c35..b85b20934 100644 --- a/quartz/i18n/locales/lt-LT.ts +++ b/quartz/i18n/locales/lt-LT.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min skaitymo`, }, + encryption: { + title: "🛡️ Apribotas Turinys 🛡️", + restricted: "Šis turinys yra apribotas. Įveskite slaptažodį, kad peržiūrėtumėte:", + enterPassword: "Įvesti slaptažodį", + decrypt: "Iššifruoti", + decrypting: "Iššifruojama...", + incorrectPassword: "Neteisingas slaptažodis. Bandykite dar kartą.", + decryptionFailed: "Iššifravimas nepavyko, patikrinkite žurnalus", + encryptedDescription: "Šis failas yra užšifruotas. Atidarykite jį, kad pamatytumėte turinį.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/nb-NO.ts b/quartz/i18n/locales/nb-NO.ts index 0e415221e..282404b35 100644 --- a/quartz/i18n/locales/nb-NO.ts +++ b/quartz/i18n/locales/nb-NO.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min lesning`, }, + encryption: { + title: "🛡️ Begrenset Innhold 🛡️", + restricted: "Dette innholdet er begrenset. Skriv inn passord for å vise:", + enterPassword: "Skriv inn passord", + decrypt: "Dekrypter", + decrypting: "Dekrypterer...", + incorrectPassword: "Feil passord. Prøv igjen.", + decryptionFailed: "Dekryptering mislyktes, sjekk logger", + encryptedDescription: "Denne filen er kryptert. Åpne den for å se innholdet.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts index 8ab3b0722..3287b2b0d 100644 --- a/quartz/i18n/locales/nl-NL.ts +++ b/quartz/i18n/locales/nl-NL.ts @@ -60,6 +60,16 @@ export default { readingTime: ({ minutes }) => minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`, }, + encryption: { + title: "🛡️ Beperkte Inhoud 🛡️", + restricted: "Deze inhoud is beperkt. Voer het wachtwoord in om te bekijken:", + enterPassword: "Voer wachtwoord in", + decrypt: "Ontsleutelen", + decrypting: "Ontsleutelen...", + incorrectPassword: "Onjuist wachtwoord. Probeer opnieuw.", + decryptionFailed: "Ontsleuteling mislukt, controleer logs", + encryptedDescription: "Dit bestand is versleuteld. Open het om de inhoud te zien.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/pl-PL.ts b/quartz/i18n/locales/pl-PL.ts index 11033e30f..5755533cb 100644 --- a/quartz/i18n/locales/pl-PL.ts +++ b/quartz/i18n/locales/pl-PL.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min. czytania `, }, + encryption: { + title: "🛡️ Ograniczona Treść 🛡️", + restricted: "Ta treść jest ograniczona. Wprowadź hasło, aby wyświetlić:", + enterPassword: "Wprowadź hasło", + decrypt: "Odszyfruj", + decrypting: "Odszyfrowywanie...", + incorrectPassword: "Nieprawidłowe hasło. Spróbuj ponownie.", + decryptionFailed: "Odszyfrowanie nie powiodło się, sprawdź logi", + encryptedDescription: "Ten plik jest zaszyfrowany. Otwórz go, aby zobaczyć zawartość.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/pt-BR.ts b/quartz/i18n/locales/pt-BR.ts index e431d8ed5..c5ecfe170 100644 --- a/quartz/i18n/locales/pt-BR.ts +++ b/quartz/i18n/locales/pt-BR.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `Leitura de ${minutes} min`, }, + encryption: { + title: "🛡️ Conteúdo Restrito 🛡️", + restricted: "Este conteúdo é restrito. Digite a senha para visualizar:", + enterPassword: "Digite a senha", + decrypt: "Descriptografar", + decrypting: "Descriptografando...", + incorrectPassword: "Senha incorreta. Tente novamente.", + decryptionFailed: "Descriptografia falhou, verifique os logs", + encryptedDescription: "Este arquivo está criptografado. Abra-o para ver o conteúdo.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts index 007d90169..b211e089b 100644 --- a/quartz/i18n/locales/ro-RO.ts +++ b/quartz/i18n/locales/ro-RO.ts @@ -60,6 +60,16 @@ export default { readingTime: ({ minutes }) => minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`, }, + encryption: { + title: "🛡️ Conținut Restricționat 🛡️", + restricted: "Acest conținut este restricționat. Introduceți parola pentru a vizualiza:", + enterPassword: "Introduceți parola", + decrypt: "Decriptați", + decrypting: "Se decriptează...", + incorrectPassword: "Parolă incorectă. Încercați din nou.", + decryptionFailed: "Decriptarea a eșuat, verificați jurnalele", + encryptedDescription: "Acest fișier este criptat. Deschideți-l pentru a vedea conținutul.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ru-RU.ts b/quartz/i18n/locales/ru-RU.ts index 5534d140c..41efcae4b 100644 --- a/quartz/i18n/locales/ru-RU.ts +++ b/quartz/i18n/locales/ru-RU.ts @@ -60,6 +60,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`, }, + encryption: { + title: "🛡️ Ограниченный Контент 🛡️", + restricted: "Этот контент ограничен. Введите пароль для просмотра:", + enterPassword: "Введите пароль", + decrypt: "Расшифровать", + decrypting: "Расшифровка...", + incorrectPassword: "Неверный пароль. Попробуйте снова.", + decryptionFailed: "Расшифровка не удалась, проверьте логи", + encryptedDescription: "Этот файл зашифрован. Откройте его, чтобы увидеть содержимое.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/th-TH.ts b/quartz/i18n/locales/th-TH.ts index 073013196..05e6441ab 100644 --- a/quartz/i18n/locales/th-TH.ts +++ b/quartz/i18n/locales/th-TH.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`, }, + encryption: { + title: "🛡️ เนื้อหาถูกจำกัด 🛡️", + restricted: "เนื้อหานี้ถูกจำกัด กรุณาใส่รหัสผ่านเพื่อดู:", + enterPassword: "ใส่รหัสผ่าน", + decrypt: "ถอดรหัส", + decrypting: "กำลังถอดรหัส...", + incorrectPassword: "รหัสผ่านไม่ถูกต้อง กรุณาลองใหม่", + decryptionFailed: "การถอดรหัสล้มเหลว ตรวจสอบบันทึก", + encryptedDescription: "ไฟล์นี้ถูกเข้ารหัส เปิดเพื่อดูเนื้อหา", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/tr-TR.ts b/quartz/i18n/locales/tr-TR.ts index 9c2d6d4ad..7eea49550 100644 --- a/quartz/i18n/locales/tr-TR.ts +++ b/quartz/i18n/locales/tr-TR.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`, }, + encryption: { + title: "🛡️ Kısıtlı İçerik 🛡️", + restricted: "Bu içerik kısıtlıdır. Görüntülemek için şifreyi girin:", + enterPassword: "Şifreyi girin", + decrypt: "Şifre çöz", + decrypting: "Şifre çözülüyor...", + incorrectPassword: "Yanlış şifre. Tekrar deneyin.", + decryptionFailed: "Şifre çözme başarısız, günlükleri kontrol edin", + encryptedDescription: "Bu dosya şifrelenmiş. İçeriği görmek için açın.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts index ac2a24850..8db23b072 100644 --- a/quartz/i18n/locales/uk-UA.ts +++ b/quartz/i18n/locales/uk-UA.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} хв читання`, }, + encryption: { + title: "🛡️ Обмежений Контент 🛡️", + restricted: "Цей контент обмежений. Введіть пароль, щоб переглянути:", + enterPassword: "Введіть пароль", + decrypt: "Розшифрувати", + decrypting: "Розшифрування...", + incorrectPassword: "Неправильний пароль. Спробуйте ще раз.", + decryptionFailed: "Розшифрування не вдалося, перевірте журнали", + encryptedDescription: "Цей файл зашифрований. Відкрийте його, щоб побачити вміст.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/vi-VN.ts b/quartz/i18n/locales/vi-VN.ts index 26a58a50e..782702cd9 100644 --- a/quartz/i18n/locales/vi-VN.ts +++ b/quartz/i18n/locales/vi-VN.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `đọc ${minutes} phút`, }, + encryption: { + title: "🛡️ Nội Dung Bị Hạn Chế 🛡️", + restricted: "Nội dung này bị hạn chế. Nhập mật khẩu để xem:", + enterPassword: "Nhập mật khẩu", + decrypt: "Giải mã", + decrypting: "Đang giải mã...", + incorrectPassword: "Mật khẩu sai. Vui lòng thử lại.", + decryptionFailed: "Giải mã thất bại, kiểm tra nhật ký", + encryptedDescription: "Tệp này đã được mã hóa. Mở để xem nội dung.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/zh-CN.ts b/quartz/i18n/locales/zh-CN.ts index 09951bbb3..7a84864fc 100644 --- a/quartz/i18n/locales/zh-CN.ts +++ b/quartz/i18n/locales/zh-CN.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes}分钟阅读`, }, + encryption: { + title: "🛡️ 受限内容 🛡️", + restricted: "此内容受限制。输入密码以查看:", + enterPassword: "输入密码", + decrypt: "解密", + decrypting: "解密中...", + incorrectPassword: "密码错误。请重试。", + decryptionFailed: "解密失败,请检查日志", + encryptedDescription: "此文件已加密。打开以查看内容。", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/zh-TW.ts b/quartz/i18n/locales/zh-TW.ts index 4e784c06d..52e579778 100644 --- a/quartz/i18n/locales/zh-TW.ts +++ b/quartz/i18n/locales/zh-TW.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`, }, + encryption: { + title: "🛡️ 受限內容 🛡️", + restricted: "此內容受限制。輸入密碼以檢視:", + enterPassword: "輸入密碼", + decrypt: "解密", + decrypting: "解密中...", + incorrectPassword: "密碼錯誤。請重試。", + decryptionFailed: "解密失敗,請檢查日誌", + encryptedDescription: "此檔案已加密。打開以檢視內容。", + }, }, pages: { rss: { diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index d6a9e3c1f..a5ae2c95e 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -2,6 +2,7 @@ import { Root as HTMLRoot } from "hast" import { toString } from "hast-util-to-string" import { QuartzTransformerPlugin } from "../types" import { escapeHTML } from "../../util/escape" +import { i18n } from "../../i18n" export interface Options { descriptionLength: number @@ -24,12 +25,12 @@ export const Description: QuartzTransformerPlugin> = (userOpts) const opts = { ...defaultOptions, ...userOpts } return { name: "Description", - htmlPlugins() { + htmlPlugins(ctx) { return [ () => { return async (tree: HTMLRoot, file) => { if (file.data?.encrypted) { - file.data.description = "This file is encrypted. Open it to see the contents." + file.data.description = i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription return } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index dbf3a104b..1f6a4be0b 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -4,6 +4,7 @@ import { Root } from "hast" import { toHtml } from "hast-util-to-html" import { fromHtml } from "hast-util-from-html" import { VFile } from "vfile" +import { i18n } from "../../i18n" // @ts-ignore import encryptScript from "../../components/scripts/encrypt.inline.ts" @@ -153,7 +154,7 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt }, ] }, - htmlPlugins() { + htmlPlugins(ctx) { return [ () => { return (tree: Root, file) => { @@ -162,6 +163,9 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt return tree // No encryption, return original tree } + const locale = ctx.cfg.configuration.locale + const t = i18n(locale).components.encryption + // Convert the HTML tree to string const htmlContent = toHtml(tree) @@ -171,20 +175,20 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt // Create a new tree with encrypted content placeholder const encryptedTree = fromHtml( ` -
+
-

🛡️ Restricted Content 🛡️

-

This content is restricted. Enter the password to view:

+

${t.title}

+

${t.restricted}

- - + +
- Decrypting... + ${t.decrypting}
- Incorrect password. Please try again. + ${t.incorrectPassword}
From c2fd7e44313ed69818c8a467c2898e177e31b841 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 20:09:59 +0200 Subject: [PATCH 05/18] Add docs, run prettier --- docs/plugins/Encrypt.md | 108 +++++++++++++++++++++ docs/plugins/Frontmatter.md | 4 + quartz/i18n/locales/de-DE.ts | 3 +- quartz/plugins/transformers/description.ts | 4 +- 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 docs/plugins/Encrypt.md diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md new file mode 100644 index 000000000..e0b758ce1 --- /dev/null +++ b/docs/plugins/Encrypt.md @@ -0,0 +1,108 @@ +--- +title: "Encrypt" +tags: + - plugin/transformer +--- + +This plugin enables content encryption for sensitive pages in your Quartz site. It uses AES encryption with password-based access control, allowing you to protect specific pages or entire folders with passwords. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. + +## Configuration + +```typescript +Plugin.Encrypt({ + algorithm: "aes-256-cbc", // Encryption algorithm + keyLength: 32, // Key length in bytes + iterations: 100000, // PBKDF2 iterations + encryptedFolders: { + // Folder-level encryption + "private/": "folder-password", + "work/confidential/": "work-password", + }, + ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days) +}) +``` + +### Configuration Options + +- `algorithm`: Encryption algorithm to use. Supported values: + - `"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) +- `keyLength`: Key length in bytes (default: 32 for AES-256) +- `iterations`: Number of PBKDF2 iterations for key derivation (default: 100000) +- `encryptedFolders`: Object mapping folder paths to passwords for folder-level encryption +- `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only) + +## Usage + +### Folder-level Encryption + +Configure folders to be encrypted by adding them to the `encryptedFolders` option: + +```typescript +Plugin.Encrypt({ + encryptedFolders: { + "private/": "my-secret-password", + "work/": "work-password", + "personal/diary/": "diary-password", + }, +}) +``` + +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" +encrypt: true +password: "page-specific-password" +--- +This content will be encrypted and require a password to view. +``` + +### Frontmatter Fields + +The plugin recognizes these frontmatter fields: + +- `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` field, the page-specific password will be used instead of the folder password. + +### Security Considerations + +- Use strong passwords for sensitive content +- Consider using AES-256-GCM mode for authenticated encryption +- The default 100,000 PBKDF2 iterations provide good security but can be increased for higher security needs, or decreased for slow devices +- ECB mode is provided for compatibility but is not recommended for security-critical applications + +## Security Features + +### Password Cache + +The plugin implements intelligent password caching: + +- Passwords are cached in browser localStorage +- Configurable TTL (time-to-live) for automatic cache expiration +- Passwords are automatically tried when navigating to encrypted pages + +### 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 +- **Secure Key Derivation**: Uses PBKDF2 with configurable iterations +- **Password Verification**: Fast password hash verification before attempting decryption + +## 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). diff --git a/docs/plugins/Frontmatter.md b/docs/plugins/Frontmatter.md index 9dfe53378..0740d3e3c 100644 --- a/docs/plugins/Frontmatter.md +++ b/docs/plugins/Frontmatter.md @@ -64,6 +64,10 @@ Quartz supports the following frontmatter: - `published` - `publishDate` - `date` +- encrypt + - `encrypt` +- password + - `password` ## API diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts index 1e333de13..953f0ddfc 100644 --- a/quartz/i18n/locales/de-DE.ts +++ b/quartz/i18n/locales/de-DE.ts @@ -67,7 +67,8 @@ export default { decrypting: "Entschlüsselt...", incorrectPassword: "Falsches Passwort. Bitte versuchen Sie es erneut.", decryptionFailed: "Entschlüsselung fehlgeschlagen, überprüfen Sie die Protokolle", - encryptedDescription: "Diese Datei ist verschlüsselt. Öffnen Sie sie, um den Inhalt zu sehen.", + encryptedDescription: + "Diese Datei ist verschlüsselt. Öffnen Sie sie, um den Inhalt zu sehen.", }, }, pages: { diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index a5ae2c95e..979eb0ff5 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -30,7 +30,9 @@ export const Description: QuartzTransformerPlugin> = (userOpts) () => { return async (tree: HTMLRoot, file) => { if (file.data?.encrypted) { - file.data.description = i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription + file.data.description = i18n( + ctx.cfg.configuration.locale, + ).components.encryption.encryptedDescription return } From 22c70fbe6fa9a373fac91d5b7bcfac608f373f22 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 20:13:02 +0200 Subject: [PATCH 06/18] Fix turkish translation --- quartz/i18n/locales/tr-TR.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/i18n/locales/tr-TR.ts b/quartz/i18n/locales/tr-TR.ts index 7eea49550..1d416179c 100644 --- a/quartz/i18n/locales/tr-TR.ts +++ b/quartz/i18n/locales/tr-TR.ts @@ -61,12 +61,12 @@ export default { }, encryption: { title: "🛡️ Kısıtlı İçerik 🛡️", - restricted: "Bu içerik kısıtlıdır. Görüntülemek için şifreyi girin:", + restricted: "Bu içerik şifreli. Görüntülemek için şifreyi girin:", enterPassword: "Şifreyi girin", - decrypt: "Şifre çöz", + decrypt: "Şifreyi Çöz", decrypting: "Şifre çözülüyor...", incorrectPassword: "Yanlış şifre. Tekrar deneyin.", - decryptionFailed: "Şifre çözme başarısız, günlükleri kontrol edin", + decryptionFailed: "Şifre çözme başarısız, logları kontrol edin", encryptedDescription: "Bu dosya şifrelenmiş. İçeriği görmek için açın.", }, }, From 2533c5b01a17a2ed7271480e886547fbee2386f7 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 20:13:53 +0200 Subject: [PATCH 07/18] Revert config language to en-US --- quartz.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz.config.ts b/quartz.config.ts index 5d69855fe..52ae335f2 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -15,7 +15,7 @@ const config: QuartzConfig = { analytics: { provider: "plausible", }, - locale: "fi-FI", + locale: "en-US", baseUrl: "quartz.jzhao.xyz", ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "modified", From ff83a93588dcc339fe1c9e513fe41d6bb1961760 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 20:42:32 +0200 Subject: [PATCH 08/18] Add message frontmatter, field. Encrypted the encrypt docs for PoC --- docs/plugins/Encrypt.md | 5 +++++ docs/plugins/Frontmatter.md | 5 +++++ quartz/components/styles/encrypt.scss | 11 ++++++++++- quartz/plugins/emitters/contentIndex.tsx | 9 +++++---- quartz/plugins/transformers/description.ts | 6 +++--- quartz/plugins/transformers/encrypt.ts | 8 ++++++++ quartz/plugins/transformers/frontmatter.ts | 13 +++++++++++++ 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index e0b758ce1..191cda950 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -2,6 +2,9 @@ title: "Encrypt" tags: - plugin/transformer +encrypt: true +encrypt_message: '^ Password is "quartz"' +password: "quartz" --- This plugin enables content encryption for sensitive pages in your Quartz site. It uses AES encryption with password-based access control, allowing you to protect specific pages or entire folders with passwords. @@ -63,6 +66,7 @@ Use frontmatter to encrypt individual pages or override folder passwords: title: "My Secret Page" encrypt: true password: "page-specific-password" +encrypt_message: "Sorry, this one is only for my eyes," --- This content will be encrypted and require a password to view. ``` @@ -73,6 +77,7 @@ The plugin recognizes these frontmatter fields: - `encrypt`: Set to `true` to enable encryption for this page - `password`: The password required to decrypt this page +- `encrypt_message`: Message to be shown on the unlock page. If a page is in an encrypted folder but has its own `password` field, the page-specific password will be used instead of the folder password. diff --git a/docs/plugins/Frontmatter.md b/docs/plugins/Frontmatter.md index 0740d3e3c..16b1d4cd0 100644 --- a/docs/plugins/Frontmatter.md +++ b/docs/plugins/Frontmatter.md @@ -66,6 +66,11 @@ Quartz supports the following frontmatter: - `date` - encrypt - `encrypt` + - `encrypted` +- encryptMessage + - `encrypt_message` + - `encryptMessage` + - `encrypt-message` - password - `password` diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index 44a74eba5..b8dc5aec0 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -32,7 +32,6 @@ } .encryption-notice p { - margin: 0 0 1.5rem 0; color: var(--gray); line-height: 1.4; font-size: 0.95rem; @@ -151,3 +150,13 @@ font-size: 16px; /* Prevent zoom on iOS */ } } + +.encrypted-message-footnote { + color: var(--gray); + font-size: 0.85rem; + opacity: 0.8; + text-align: center; + width: 100%; + font-style: italic; + margin: 0; +} diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 416a2515c..94ab67b1c 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -59,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} - + ${content.date?.toUTCString()} ` @@ -111,9 +111,10 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", - richContent: opts?.rssFullHtml - ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : undefined, + richContent: + !file.data.encrypted && opts?.rssFullHtml + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : undefined, date: date, description: file.data.description ?? "", encrypted: file.data.encrypted, diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 979eb0ff5..aa8d20a84 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -30,9 +30,9 @@ export const Description: QuartzTransformerPlugin> = (userOpts) () => { return async (tree: HTMLRoot, file) => { if (file.data?.encrypted) { - file.data.description = i18n( - ctx.cfg.configuration.locale, - ).components.encryption.encryptedDescription + file.data.description = + file.data.encryptMessage || + i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription return } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index 1f6a4be0b..c6d095b33 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -146,6 +146,10 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt } file.data.encrypted = true + file.data.password = password + if (file.data?.frontmatter?.encryptMessage) { + file.data.encryptMessage = file.data.frontmatter.encryptMessage as string + } if (file.data?.frontmatter?.title) { file.data.frontmatter.title = `🔒 ${file.data.frontmatter.title}` @@ -171,6 +175,7 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt // Encrypt the content const encryptedContent = encryptContent(htmlContent, password, opts) + console.log(file.data) // Create a new tree with encrypted content placeholder const encryptedTree = fromHtml( @@ -182,6 +187,7 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt
+ ${file.data.encryptMessage ? `

${file.data.encryptMessage}

` : ""}
@@ -228,5 +234,7 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt declare module "vfile" { interface DataMap { encrypted: boolean + encryptMessage?: string + password?: string } } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 1103900c5..ed5bc935b 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -118,6 +118,19 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) if (socialImage) data.socialImage = socialImage + const encrypted = coalesceAliases(data, ["encrypted", "encrypt"]) + if (encrypted) data.encrypt = true + + const password = coalesceAliases(data, ["password"]) + if (password) data.password = password + + const encryptMessage = coalesceAliases(data, [ + "encryptMessage", + "encrypt_message", + "encrypt-message", + ]) + if (encryptMessage) data.encryptMessage = encryptMessage + // Remove duplicate slugs const uniqueSlugs = [...new Set(allSlugs)] allSlugs.splice(0, allSlugs.length, ...uniqueSlugs) From 4e02f7810e433f8cb5d2e042d61cc0ba6a57a00d Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 31 Jul 2025 06:52:55 +0200 Subject: [PATCH 09/18] Tried to address the comments --- docs/plugins/Encrypt.md | 70 ++- index.d.ts | 8 +- quartz.config.ts | 4 +- quartz/components/ArticleTitle.tsx | 7 +- quartz/components/Explorer.tsx | 6 +- quartz/components/scripts/encrypt.inline.ts | 382 ++++-------- quartz/components/scripts/explorer.inline.ts | 33 +- quartz/components/scripts/graph.inline.ts | 5 + quartz/components/scripts/mermaid.inline.ts | 8 +- quartz/components/scripts/popover.inline.ts | 11 +- quartz/components/scripts/search.inline.ts | 84 ++- quartz/components/styles/encrypt.scss | 288 ++++----- quartz/plugins/emitters/contentIndex.tsx | 26 +- quartz/plugins/transformers/description.ts | 4 +- quartz/plugins/transformers/encrypt.ts | 205 +++---- quartz/plugins/transformers/frontmatter.ts | 20 +- quartz/plugins/transformers/index.ts | 2 +- quartz/util/encryption.ts | 595 +++++++++++++++++++ 18 files changed, 1174 insertions(+), 584 deletions(-) create mode 100644 quartz/util/encryption.ts diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index 191cda950..56397eda6 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -2,9 +2,9 @@ title: "Encrypt" tags: - plugin/transformer -encrypt: true -encrypt_message: '^ Password is "quartz"' -password: "quartz" +encryptConfig: + password: "quartz" + message: '^ Password is "quartz"' --- This plugin enables content encryption for sensitive pages in your Quartz site. It uses AES encryption with password-based access control, allowing you to protect specific pages or entire folders with passwords. @@ -16,28 +16,54 @@ This plugin enables content encryption for sensitive pages in your Quartz site. ```typescript Plugin.Encrypt({ - algorithm: "aes-256-cbc", // Encryption algorithm - keyLength: 32, // Key length in bytes - iterations: 100000, // PBKDF2 iterations + algorithm: "aes-256-cbc", // Encryption algorithm (key length auto-inferred) encryptedFolders: { - // Folder-level encryption + // Folder-level encryption with simple passwords "private/": "folder-password", "work/confidential/": "work-password", + // Advanced per-folder configuration + "secure/": { + password: "advanced-password", + algorithm: "aes-256-gcm", + ttl: 3600 * 24 * 30, // 30 days + }, }, ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days) }) ``` +> [!warning] +> It is very important to note that: +> +> - 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. + ### Configuration Options - `algorithm`: Encryption algorithm to use. Supported values: - `"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) -- `keyLength`: Key length in bytes (default: 32 for AES-256) -- `iterations`: Number of PBKDF2 iterations for key derivation (default: 100000) -- `encryptedFolders`: Object mapping folder paths to passwords for folder-level encryption + - 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 + +### Advanced Folder Configuration + +You can provide detailed configuration for each encrypted folder: + +```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 + } +} +``` ## Usage @@ -59,34 +85,43 @@ All pages within these folders will be encrypted with the specified password. Ne ### Page-level Encryption -Use frontmatter to encrypt individual pages or override folder passwords: +Use frontmatter to encrypt individual pages or override folder passwords. ```yaml --- title: "My Secret Page" -encrypt: true password: "page-specific-password" -encrypt_message: "Sorry, this one is only for my eyes," +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 -The plugin recognizes these 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 -- `encrypt_message`: Message to be shown on the unlock page. -If a page is in an encrypted folder but has its own `password` field, the page-specific password will be used instead of the folder password. +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 -- The default 100,000 PBKDF2 iterations provide good security but can be increased for higher security needs, or decreased for slow devices - 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) ## Security Features @@ -103,7 +138,6 @@ The plugin implements intelligent password caching: - **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 -- **Secure Key Derivation**: Uses PBKDF2 with configurable iterations - **Password Verification**: Fast password hash verification before attempting decryption ## API diff --git a/index.d.ts b/index.d.ts index 9011ee38f..70ec9c26f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,10 +6,14 @@ declare module "*.scss" { // dom custom event interface CustomEventMap { prenav: CustomEvent<{}> - nav: CustomEvent<{ url: FullSlug }> + nav: CustomEvent<{ url: FullSlug; rerender?: boolean }> themechange: CustomEvent<{ theme: "light" | "dark" }> readermodechange: CustomEvent<{ mode: "on" | "off" }> } -type ContentIndex = Record +type DecryptedFlag = { + decrypted?: boolean +} + +type ContentIndex = Record declare const fetchData: Promise diff --git a/quartz.config.ts b/quartz.config.ts index 52ae335f2..8914f6363 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -71,10 +71,8 @@ const config: QuartzConfig = { Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.Latex({ renderEngine: "katex" }), - Plugin.EncryptPlugin({ + Plugin.Encrypt({ algorithm: "aes-256-cbc", - keyLength: 32, - iterations: 100000, encryptedFolders: {}, ttl: 3600 * 24 * 7, // A week }), diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx index 318aeb24e..7e08c8799 100644 --- a/quartz/components/ArticleTitle.tsx +++ b/quartz/components/ArticleTitle.tsx @@ -4,7 +4,12 @@ import { classNames } from "../util/lang" const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { const title = fileData.frontmatter?.title if (title) { - return

{title}

+ return ( +

+ {fileData.encryptionResult && 🔒 } + {title} +

+ ) } else { return null } diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 56784f132..3827587d7 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -30,7 +30,7 @@ const defaultOptions: Options = { return node }, sortFn: (a, b) => { - // Sort order: folders first, then files. Sort folders and files alphabeticall + // Sort order: folders first, then files. Sort folders and files alphabetically if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A @@ -121,6 +121,7 @@ export default ((userOpts?: Partial) => {
@@ -143,7 +144,8 @@ export default ((userOpts?: Partial) => {
diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index fd01deab4..62cd62e0f 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -1,222 +1,15 @@ -// Password cache management -const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords" +import { + decryptContent, + verifyPasswordHash, + addPasswordToCache, + Hash, + EncryptionConfig, + searchForValidPassword, + EncryptionResult, +} from "../../util/encryption" +import { FullSlug, getFullSlug } from "../../util/path" -function getPasswordCache(): Record { - try { - const cache = localStorage.getItem(PASSWORD_CACHE_KEY) - return cache ? JSON.parse(cache) : {} - } catch { - return {} - } -} - -function savePasswordCache(cache: Record) { - try { - localStorage.setItem(PASSWORD_CACHE_KEY, JSON.stringify(cache)) - } catch { - // Silent fail if localStorage is not available - } -} - -function addPasswordToCache(password: string, filePath: string, ttl: number) { - const cache = getPasswordCache() - const now = Date.now() - - // Store password for exact file path - cache[filePath] = { - password, - ttl: ttl <= 0 ? 0 : now + ttl, - } - - savePasswordCache(cache) -} - -function getRelevantPasswords(filePath: string): string[] { - const cache = getPasswordCache() - const now = Date.now() - const passwords: string[] = [] - - // Clean expired passwords (but keep infinite TTL ones) - Object.keys(cache).forEach((path) => { - if (cache[path].ttl > 0 && cache[path].ttl < now) { - delete cache[path] - } - }) - - // Get passwords by directory hierarchy (closest first) - - // Sort cache keys by how many directory levels they share with current file - const sortedPaths = Object.keys(cache).sort((a, b) => { - const aShared = getSharedDirectoryDepth(a, filePath) - const bShared = getSharedDirectoryDepth(b, filePath) - return bShared - aShared // Descending order (most shared first) - }) - - for (const cachedPath of sortedPaths) { - if (getSharedDirectoryDepth(cachedPath, filePath) > 0) { - passwords.push(cache[cachedPath].password) - } - } - - savePasswordCache(cache) - return passwords -} - -function getSharedDirectoryDepth(path1: string, path2: string): number { - const parts1 = path1.split("/") - const parts2 = path2.split("/") - let sharedDepth = 0 - - const minLength = Math.min(parts1.length, parts2.length) - for (let i = 0; i < minLength - 1; i++) { - // -1 to exclude filename - if (parts1[i] === parts2[i]) { - sharedDepth++ - } else { - break - } - } - - return sharedDepth -} - -// Helper: hex string to ArrayBuffer -function hexToArrayBuffer(hex: string): ArrayBuffer { - if (!hex) return new ArrayBuffer(0) - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substr(i * 2, 2), 16) - } - return bytes.buffer -} - -// Helper: ArrayBuffer to hex string -function arrayBufferToHex(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") -} - -// Helper: string to ArrayBuffer -function stringToArrayBuffer(str: string): ArrayBuffer { - const encoder = new TextEncoder() - return encoder.encode(str).buffer as ArrayBuffer -} - -// Helper: ArrayBuffer to string -function arrayBufferToString(buffer: ArrayBuffer): string { - const decoder = new TextDecoder() - return decoder.decode(buffer) -} - -async function verifyPassword(password: string, parsed: any): Promise { - // Hash password with salt for verification using SubtleCrypto - const encoder = new TextEncoder() - const passwordBytes = encoder.encode(password) - const saltBytes = hexToArrayBuffer(parsed.salt) - - // Concatenate password and salt - const combined = new Uint8Array(passwordBytes.byteLength + saltBytes.byteLength) - combined.set(new Uint8Array(passwordBytes), 0) - combined.set(new Uint8Array(saltBytes), passwordBytes.byteLength) - - // Hash using SHA-256 - const hashBuffer = await crypto.subtle.digest("SHA-256", combined) - const passwordHash = arrayBufferToHex(hashBuffer) - - return passwordHash === parsed.passwordHash -} - -async function performDecryption(password: string, parsed: any, config: any): Promise { - const encoder = new TextEncoder() - const keyMaterial = await crypto.subtle.importKey( - "raw", - encoder.encode(password), - { name: "PBKDF2" }, - false, - ["deriveKey"], - ) - - // Derive key using PBKDF2 - const key = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: hexToArrayBuffer(parsed.salt), - iterations: config.iterations, - hash: "SHA-256", - }, - keyMaterial, - { name: getAlgorithmName(config.algorithm), length: config.keyLength * 8 }, - false, - ["decrypt"], - ) - - const ciphertext = hexToArrayBuffer(parsed.content) - - let decryptedBuffer: ArrayBuffer - - if (config.algorithm.includes("gcm")) { - // GCM mode - const iv = hexToArrayBuffer(parsed.iv) - const authTag = parsed.authTag ? hexToArrayBuffer(parsed.authTag) : null - - // For GCM, concatenate ciphertext + authTag - let fullCiphertext = ciphertext - if (authTag) { - const combined = new Uint8Array(ciphertext.byteLength + authTag.byteLength) - combined.set(new Uint8Array(ciphertext), 0) - combined.set(new Uint8Array(authTag), ciphertext.byteLength) - fullCiphertext = combined.buffer - } - - decryptedBuffer = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: iv, - }, - key, - fullCiphertext, - ) - } else if (config.algorithm.includes("cbc")) { - // CBC mode - const iv = hexToArrayBuffer(parsed.iv) - - decryptedBuffer = await crypto.subtle.decrypt( - { - name: "AES-CBC", - iv: iv, - }, - key, - ciphertext, - ) - } else if (config.algorithm.includes("ecb")) { - // ECB mode - simulate using CBC with zero IV (SubtleCrypto doesn't support ECB directly) - const zeroIv = new ArrayBuffer(16) // 16 bytes of zeros - - decryptedBuffer = await crypto.subtle.decrypt( - { - name: "AES-CBC", - iv: zeroIv, - }, - key, - ciphertext, - ) - } else { - throw new Error("Unsupported algorithm: " + config.algorithm) - } - - return arrayBufferToString(decryptedBuffer) -} - -function getAlgorithmName(algorithm: string): string { - if (algorithm.includes("gcm")) return "AES-GCM" - if (algorithm.includes("cbc")) return "AES-CBC" - if (algorithm.includes("ecb")) return "AES-CBC" // Use CBC for ECB simulation - throw new Error("Unsupported algorithm: " + algorithm) -} - -function showLoading(container: Element, show: boolean) { +const showLoading = (container: Element, show: boolean) => { const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement const form = container.querySelector(".decrypt-form") as HTMLElement @@ -231,26 +24,35 @@ function showLoading(container: Element, show: boolean) { } } -async function decryptWithPassword( +const decryptWithPassword = async ( container: Element, password: string, showError = true, -): Promise { - const errorDiv = container.querySelector(".decrypt-error") as HTMLElement - const encryptedData = (container as HTMLElement).dataset.encrypted! - const config = JSON.parse((container as HTMLElement).dataset.config!) - const i18n = JSON.parse((container as HTMLElement).dataset.i18n!) +): Promise => { + const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf + const containerElement = container as HTMLElement - if (showError) errorDiv.style.display = "none" + const config = JSON.parse(containerElement.dataset.config!) as EncryptionConfig + const encrypted = JSON.parse(containerElement.dataset.encrypted!) as EncryptionResult + const hash = JSON.parse(containerElement.dataset.hash!) as Hash + + if (showError) errorDivs.forEach((div) => (div.style.display = "none")) try { - const parsed = JSON.parse(atob(encryptedData)) + // Check if crypto.subtle is available (especially important for older iOS versions) + if (!crypto || !crypto.subtle) { + throw new Error( + "This device does not support the required encryption features. Please use a modern browser.", + ) + } // First verify password hash - const isValidPassword = await verifyPassword(password, parsed) + let isValidPassword: boolean + + isValidPassword = await verifyPasswordHash(password, hash) if (!isValidPassword) { - if (showError) throw new Error(i18n.incorrectPassword) + if (showError) throw new Error("incorrect-password") return false } @@ -262,13 +64,14 @@ async function decryptWithPassword( } try { - // If hash matches, decrypt content - const decryptedContent = await performDecryption(password, parsed, config) + let decryptedContent: string + + decryptedContent = await decryptContent(encrypted, config, password) if (decryptedContent) { // Cache the password - const filePath = window.location.pathname - addPasswordToCache(password, filePath, config.ttl) + const filePath = getFullSlug(window) + await addPasswordToCache(password, filePath, config.ttl) // Replace content const contentWrapper = document.createElement("div") @@ -278,41 +81,100 @@ async function decryptWithPassword( return true } - if (showError) throw new Error(i18n.decryptionFailed) + if (showError) throw new Error("decryption-failed") return false } catch (decryptError) { if (showError) showLoading(container, false) - if (showError) throw new Error(i18n.decryptionFailed) + if (showError) throw new Error("decryption-failed") return false } } catch (error) { if (showError) { showLoading(container, false) - errorDiv.style.display = "block" - errorDiv.textContent = error instanceof Error ? error.message : "Decryption failed" + const errorMessage = error instanceof Error ? error.message : null + + errorDivs.forEach((div) => { + if (div.dataset.error == errorMessage) { + div.style.display = "block" + } + }) const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + const decryptButton = container.querySelector(".decrypt-button") as HTMLButtonElement + + // Check if we're in a popover context + const isInPopover = container.closest(".popover") !== null + if (passwordInput) { passwordInput.value = "" - passwordInput.focus() + if (isInPopover) { + // Disable input and button in popover on failure + passwordInput.disabled = true + if (decryptButton) { + decryptButton.disabled = true + } + } else { + passwordInput.focus() + } } } return false } } -async function tryAutoDecrypt(container: Element): Promise { - const filePath = window.location.pathname - const passwords = getRelevantPasswords(filePath) +const notifyNav = (url: FullSlug) => { + const event: CustomEventMap["nav"] = new CustomEvent("nav", { + detail: { url, rerender: true }, + }) + document.dispatchEvent(event) +} - for (const password of passwords) { - if (await decryptWithPassword(container, password, false)) { - return true +const getFirstParentWithClass = (container: HTMLElement, className: string): HTMLElement | null => { + let current = container.parentElement + while (current) { + if (current.classList.contains(className)) { + return current } + current = current.parentElement + } + return null +} + +function updateTitle(container: HTMLElement | null) { + console.log(container) + if (container) { + const span = container.querySelector(".article-title-icon") as HTMLElement + if (span) { + span.textContent = "🔓 " + } + } +} + +const tryAutoDecrypt = async (container: HTMLElement): Promise => { + const filePath = getFullSlug(window) + const fullSlug = container.dataset.slug as FullSlug + const config = JSON.parse(container.dataset.config!) as EncryptionConfig + const hash = JSON.parse(container.dataset.hash!) as Hash + const parent = + (container.closest(".popover") as HTMLElement) || + (container.closest(".preview-inner") as HTMLElement) || + (container.closest(".center") as HTMLElement) || + null + + const password = await searchForValidPassword(fullSlug, hash, config) + + if (password && (await decryptWithPassword(container, password, false))) { + notifyNav(filePath) + updateTitle(parent) + return true } return false } -async function manualDecrypt(container: Element) { +const manualDecrypt = async (container: HTMLElement) => { + const parent = + (container.closest(".preview-inner") as HTMLElement) || + (container.closest(".center") as HTMLElement) || + null const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement const password = passwordInput.value @@ -321,15 +183,21 @@ async function manualDecrypt(container: Element) { return } - await decryptWithPassword(container, password, true) + if (await decryptWithPassword(container, password, true)) { + const filePath = getFullSlug(window) + notifyNav(filePath) + updateTitle(parent) + } } document.addEventListener("nav", async () => { // Try auto-decryption for all encrypted content - const encryptedElements = document.querySelectorAll(".encrypted-content") + const encryptedElements = document.querySelectorAll( + ".encrypted-content", + ) as NodeListOf - for (const container of encryptedElements) { - await tryAutoDecrypt(container) + for (const encryptedContainer of encryptedElements) { + await tryAutoDecrypt(encryptedContainer) } // Manual decryption handlers @@ -337,12 +205,15 @@ document.addEventListener("nav", async () => { buttons.forEach((button) => { const handleClick = async function (this: HTMLElement) { - const container = this.closest(".encrypted-content")! - await manualDecrypt(container) + const encryptedContainer = this.closest(".encrypted-content")! + await manualDecrypt(encryptedContainer as HTMLElement) } button.addEventListener("click", handleClick) - window.addCleanup(() => button.removeEventListener("click", handleClick)) + // Check if window.addCleanup exists before using it + if (window.addCleanup) { + window.addCleanup(() => button.removeEventListener("click", handleClick)) + } }) // Enter key handler @@ -350,12 +221,15 @@ document.addEventListener("nav", async () => { const handleKeypress = async function (this: HTMLInputElement, e: Event) { const keyEvent = e as KeyboardEvent if (keyEvent.key === "Enter") { - const container = this.closest(".encrypted-content")! - await manualDecrypt(container) + const encryptedContainer = this.closest(".encrypted-content")! + await manualDecrypt(encryptedContainer as HTMLElement) } } input.addEventListener("keypress", handleKeypress) - window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + // Check if window.addCleanup exists before using it + if (window.addCleanup) { + window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + } }) }) diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 9c8341169..6361196f3 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -1,6 +1,7 @@ import { FileTrieNode } from "../../util/fileTrie" import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { ContentDetails } from "../../plugins/emitters/contentIndex" +import { contentDecryptedEventListener } from "../../util/encryption" type MaybeHTMLElement = HTMLElement | undefined @@ -88,6 +89,18 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen a.dataset.for = node.slug a.textContent = node.displayName + const span = li.querySelector("span") as HTMLSpanElement + + if (span && node.data?.encryptionResult) { + span.textContent = "🔒 " + + contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => { + span.textContent = "🔓 " + }) + } else if (span) { + span.remove() + } + if (currentSlug === node.slug) { a.classList.add("active") } @@ -111,6 +124,15 @@ 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 = "🔓 " + }) + } + if (opts.folderClickBehavior === "link") { // Replace button with link for link behavior const button = titleContainer.querySelector(".folder-button") as HTMLElement @@ -120,8 +142,9 @@ function createFolderNode( a.className = "folder-title" a.textContent = node.displayName button.replaceWith(a) + titleContainer.insertBefore(span, a) } else { - const span = titleContainer.querySelector(".folder-title") as HTMLElement + const span = titleContainer.querySelector(".folder-title-text") as HTMLElement span.textContent = node.displayName } @@ -173,6 +196,7 @@ async function setupExplorer(currentSlug: FullSlug) { ) const data = await fetchData + const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][] const trie = FileTrieNode.fromEntries(entries) @@ -267,6 +291,13 @@ document.addEventListener("prenav", async () => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const currentSlug = e.detail.url + const rerender = e.detail.rerender + + // If this is secondary nav call, do not populate explorer again + if (rerender) { + return + } + await setupExplorer(currentSlug) // if mobile hamburger is visible, collapse by default diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index a669b0547..2f75fa218 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { v, ]), ) + const links: SimpleLinkData[] = [] const tags: SimpleSlug[] = [] const validLinks = new Set(data.keys()) @@ -575,6 +576,10 @@ function cleanupGlobalGraphs() { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const slug = e.detail.url + + // if we are rerendering, we can ignore this event + if (e.detail.rerender) return + addToVisited(simplifySlug(slug)) async function renderLocalGraph() { diff --git a/quartz/components/scripts/mermaid.inline.ts b/quartz/components/scripts/mermaid.inline.ts index 19ef24db1..533e38555 100644 --- a/quartz/components/scripts/mermaid.inline.ts +++ b/quartz/components/scripts/mermaid.inline.ts @@ -143,9 +143,11 @@ const cssVars = [ ] as const let mermaidImport = undefined + document.addEventListener("nav", async () => { - const center = document.querySelector(".center") as HTMLElement - const nodes = center.querySelectorAll("code.mermaid") as NodeListOf + const nodes = document.querySelectorAll( + "code.mermaid:not([data-processed])", + ) as NodeListOf if (nodes.length === 0) return mermaidImport ||= await import( @@ -162,9 +164,9 @@ document.addEventListener("nav", async () => { async function renderMermaid() { // de-init any other diagrams for (const node of nodes) { - node.removeAttribute("data-processed") const oldText = textMapping.get(node) if (oldText) { + node.removeAttribute("data-processed") node.innerHTML = oldText } } diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 989af7ee8..d48fecf51 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,10 +1,17 @@ import { computePosition, flip, inline, shift } from "@floating-ui/dom" -import { normalizeRelativeURLs } from "../../util/path" +import { FullSlug, normalizeRelativeURLs } from "../../util/path" import { fetchCanonical } from "./util" const p = new DOMParser() let activeAnchor: HTMLAnchorElement | null = null +function notifyNav(url: FullSlug) { + const event: CustomEventMap["nav"] = new CustomEvent("nav", { + detail: { url, rerender: true }, + }) + document.dispatchEvent(event) +} + async function mouseEnterHandler( this: HTMLAnchorElement, { clientX, clientY }: { clientX: number; clientY: number }, @@ -37,6 +44,8 @@ async function mouseEnterHandler( popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) } } + + notifyNav(link.getAttribute("href") as FullSlug) } const targetUrl = new URL(link.href) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 27f74ecb8..3183f47b9 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,7 +1,7 @@ import FlexSearch from "flexsearch" -import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren } from "./util" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" +import { contentDecryptedEventListener, decryptContent } from "../../util/encryption" interface Item { id: number @@ -143,6 +143,13 @@ function highlightHTML(searchTerm: string, el: HTMLElement) { return html.body } +function notifyNav(url: FullSlug) { + const event: CustomEventMap["nav"] = new CustomEvent("nav", { + detail: { url, rerender: true }, + }) + document.dispatchEvent(event) +} + async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) { const container = searchElement.querySelector(".search-container") as HTMLElement if (!container) return @@ -265,10 +272,18 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: const formatForDisplay = (term: string, id: number) => { const slug = idDataMap[id] + let title = data[slug].title + + if (data[slug].decrypted === false) { + title = "🔒 " + title + } else if (data[slug].decrypted === true) { + title = "🔓 " + title + } + return { id, slug, - title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), + title: searchType === "tags" ? title : highlight(term, title ?? ""), content: highlight(term, data[slug].content ?? "", true), tags: highlightTags(term.substring(1), data[slug].tags), } @@ -389,6 +404,9 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: (a, b) => b.innerHTML.length - a.innerHTML.length, ) highlights[0]?.scrollIntoView({ block: "start" }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + notifyNav(slug) } async function onType(e: HTMLElementEventMap["input"]) { @@ -470,16 +488,55 @@ async function fillDocument(data: ContentIndex) { if (indexPopulated) return let id = 0 const promises: Array> = [] - for (const [slug, fileData] of Object.entries(data)) { - promises.push( - index.addAsync(id++, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - tags: fileData.tags, - }), - ) + for (const [slug, fileData] of Object.entries(data)) { + if (fileData.encryptionResult) { + const slugId = id + promises.push( + index.addAsync(id, { + id: id++, + slug: slug as FullSlug, + title: fileData.title, + content: "", + tags: fileData.tags, + }), + ) + fileData.decrypted = false + + promises.push( + contentDecryptedEventListener( + slug, + fileData.hash!, + fileData.encryptionConfig!, + async (password) => { + const decryptedContent = await decryptContent( + fileData.encryptionResult!, + fileData.encryptionConfig!, + password, + ) + + fileData.decrypted = true + + index.updateAsync(slugId, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: decryptedContent, + tags: fileData.tags, + }) + }, + ), + ) + } else { + promises.push( + index.addAsync(id++, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }), + ) + } } await Promise.all(promises) @@ -487,6 +544,9 @@ async function fillDocument(data: ContentIndex) { } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { + // Ignore rerender events + if (e.detail.rerender) return + const currentSlug = e.detail.url const data = await fetchData const searchElement = document.getElementsByClassName("search") diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index b8dc5aec0..d7327351f 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -4,18 +4,158 @@ display: flex; align-items: center; justify-content: center; + + .encryption-notice { + background: color-mix(in srgb, var(--lightgray) 60%, var(--light)); + border: 1px solid var(--lightgray); + border-radius: 5px; + padding: 2rem 1.5rem; + max-width: 450px; + width: 100%; + text-align: center; + box-shadow: 0 6px 36px rgba(0, 0, 0, 0.15); + margin-top: 2rem; + font-family: var(--bodyFont); + + & h3 { + margin: 0 0 0.5rem 0; + color: var(--dark); + font-size: 1.3rem; + font-weight: $semiBoldWeight; + font-family: var(--headerFont); + } + + & p { + color: var(--darkgray); + line-height: 1.5; + font-size: 0.95rem; + margin: 0 0 1rem 0; + } + + .decrypt-form { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + + .decrypt-password { + width: 100%; + box-sizing: border-box; + padding: 0.75rem 1rem; + border-radius: 5px; + font-family: var(--bodyFont); + font-size: 1rem; + border: 1px solid var(--lightgray); + background: var(--light); + color: var(--dark); + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: var(--secondary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent); + } + + @media all and ($mobile) { + font-size: 16px; // Prevent zoom on iOS + } + } + + .decrypt-button { + background: var(--secondary); + color: var(--light); + border: none; + border-radius: 5px; + padding: 0.75rem 2rem; + font-family: var(--bodyFont); + font-size: 1rem; + font-weight: $semiBoldWeight; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; + + &:hover { + background: color-mix(in srgb, var(--secondary) 90%, var(--dark)); + transform: translateY(-1px); + box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary) 30%, transparent); + } + + &:active { + transform: translateY(0); + } + + @media all and ($mobile) { + font-size: 16px; // Prevent zoom on iOS + } + } + + .encrypted-message-footnote { + color: var(--gray); + font-size: 0.85rem; + opacity: 0.8; + text-align: center; + width: 100%; + font-style: italic; + margin: 0; + } + } + + .decrypt-loading { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--secondary) 10%, var(--light)); + border: 1px solid color-mix(in srgb, var(--secondary) 30%, transparent); + border-radius: 5px; + color: var(--secondary); + font-size: 0.9rem; + text-align: center; + align-items: center; + justify-content: center; + gap: 0.5rem; + + .loading-spinner { + width: 16px; + height: 16px; + border: 2px solid color-mix(in srgb, var(--secondary) 20%, transparent); + border-top: 2px solid var(--secondary); + border-radius: 50%; + animation: spin 1s linear infinite; + } + } + + .decrypt-error { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, #ef4444 10%, var(--light)); + border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); + border-radius: 5px; + color: #dc2626; + font-size: 0.9rem; + text-align: center; + } + + @media all and ($mobile) { + padding: 1.5rem 1rem; + margin: 1rem; + } + } } -.encryption-notice { - background: rgba(0, 0, 0, 0.35); - border: 1px solid var(--lightgray); - border-radius: 8px; - padding: 2rem 1.5rem; - max-width: 450px; - width: 100%; - text-align: center; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); - margin-top: 2rem; +// Hide the decrypt form when encrypted content appears in popover and search +.popover .encrypted-content .encryption-notice .decrypt-form { + display: none; +} + +.search-space .encrypted-content .encryption-notice .decrypt-form { + display: none; +} + +.decrypted-content-wrapper { + display: block; + margin: 0; + padding: 0; } .encryption-icon { @@ -24,100 +164,6 @@ opacity: 0.7; } -.encryption-notice h3 { - margin: 0 0 0.5rem 0; - color: var(--darkgray); - font-size: 1.3rem; - font-weight: 600; -} - -.encryption-notice p { - color: var(--gray); - line-height: 1.4; - font-size: 0.95rem; -} - -.decrypted-content-wrapper { - /* Reset any container styles that might interfere */ - display: block; - margin: 0; - padding: 0; -} - -.decrypt-form { - display: flex; - flex-direction: column; - gap: 1rem; - align-items: center; -} - -.decrypt-password { - width: 100%; - box-sizing: border-box; - padding: 0.75rem 1rem; - border-radius: 5px; - font-family: var(--bodyFont); - font-size: 1rem; - border: 1px solid var(--gray); - background: rgba(0, 0, 0, 0); - color: var(--dark); - transition: border-color 0.2s ease; -} - -.decrypt-password:focus { - outline: none; - border-color: var(--secondary); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent); -} - -.decrypt-button { - background: var(--secondary); - color: var(--light); - border: none; - border-radius: 5px; - padding: 0.75rem 2rem; - font-family: var(--bodyFont); - font-size: 1rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - min-width: 120px; -} - -.decrypt-button:hover { - background: color-mix(in srgb, var(--secondary) 90%, var(--dark)); - transform: translateY(-1px); - box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary) 30%, transparent); -} - -.decrypt-button:active { - transform: translateY(0); -} - -.decrypt-loading { - display: none; - margin-top: 1rem; - padding: 0.75rem 1rem; - background: color-mix(in srgb, var(--secondary) 10%, var(--light)); - border: 1px solid color-mix(in srgb, var(--secondary) 30%, transparent); - border-radius: 5px; - color: var(--secondary); - font-size: 0.9rem; - text-align: center; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.loading-spinner { - width: 16px; - height: 16px; - border: 2px solid color-mix(in srgb, var(--secondary) 20%, transparent); - border-top: 2px solid var(--secondary); - border-radius: 50%; - animation: spin 1s linear infinite; -} - @keyframes spin { 0% { transform: rotate(0deg); @@ -126,37 +172,3 @@ transform: rotate(360deg); } } - -.decrypt-error { - display: none; - margin-top: 1rem; - padding: 0.75rem 1rem; - background: color-mix(in srgb, #ef4444 10%, var(--light)); - border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); - border-radius: 5px; - color: #dc2626; - font-size: 0.9rem; - text-align: center; -} - -@media (max-width: 600px) { - .encryption-notice { - padding: 1.5rem 1rem; - margin: 1rem; - } - - .decrypt-password, - .decrypt-button { - font-size: 16px; /* Prevent zoom on iOS */ - } -} - -.encrypted-message-footnote { - color: var(--gray); - font-size: 0.85rem; - opacity: 0.8; - text-align: center; - width: 100%; - font-style: italic; - margin: 0; -} diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 94ab67b1c..eda0c322f 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -7,8 +7,11 @@ 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" export type ContentIndexMap = Map + +// Base content details without encryption-specific fields export type ContentDetails = { slug: FullSlug filePath: FilePath @@ -19,7 +22,9 @@ export type ContentDetails = { richContent?: string date?: Date description?: string - encrypted?: boolean + encryptionConfig?: EncryptionConfig + hash?: Hash + encryptionResult?: EncryptionResult } interface Options { @@ -104,7 +109,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const slug = file.data.slug! const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { - linkIndex.set(slug, { + const contentDetails: ContentDetails = { slug, filePath: file.data.relativePath!, title: file.data.frontmatter?.title!, @@ -112,13 +117,17 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", richContent: - !file.data.encrypted && opts?.rssFullHtml + !file.data.encryptionResult && opts?.rssFullHtml ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) : undefined, date: date, description: file.data.description ?? "", - encrypted: file.data.encrypted, - }) + encryptionConfig: file.data.encryptionConfig, + hash: file.data.hash, + encryptionResult: file.data.encryptionResult, + } + + linkIndex.set(slug, contentDetails) } } @@ -146,9 +155,12 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // remove description and from content index as nothing downstream // actually uses it. we only keep it in the index as we need it // for the RSS feed - if (content.encrypted) { - content.description = "" + if (content.encryptionResult) { delete content.richContent + } else { + delete content.hash + delete content.encryptionConfig + delete content.encryptionResult } delete content.description diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index aa8d20a84..9036436b9 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -29,9 +29,9 @@ export const Description: QuartzTransformerPlugin> = (userOpts) return [ () => { return async (tree: HTMLRoot, file) => { - if (file.data?.encrypted) { + if (file.data?.encrypted && file.data.encryptionConfig) { file.data.description = - file.data.encryptMessage || + file.data.encryptionConfig.message || i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription return } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index c6d095b33..e4d2d0bd6 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -1,159 +1,86 @@ -import { createCipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto" 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, + EncryptionConfig, +} from "../../util/encryption" // @ts-ignore import encryptScript from "../../components/scripts/encrypt.inline.ts" import encryptStyle from "../../components/styles/encrypt.scss" -export interface Options { - algorithm?: string - keyLength?: number - iterations?: number - encryptedFolders?: { [folderPath: string]: string } // json object with folder paths as keys and passwords as values - ttl: number -} +export interface Options extends EncryptionOptions {} const defaultOptions: Options = { - algorithm: "aes-256-cbc", - keyLength: 32, - iterations: 100000, + ...defaultEncryptionConfig, encryptedFolders: {}, - ttl: 3600 * 24 * 7, } -const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const - -type SupportedAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] - -function deriveKey(password: string, salt: Buffer, keyLength: number, iterations: number): Buffer { - return pbkdf2Sync(password, salt, iterations, keyLength, "sha256") -} - -function hashPassword(password: string, salt: Buffer): string { - // Create a fast hash for password verification (separate from key derivation) - const hash = createHash("sha256") - hash.update(password) - hash.update(salt) - const result = hash.digest("hex") - return result -} - -function encryptContent(content: string, password: string, options: Options): string { - const { algorithm, keyLength, iterations } = { ...defaultOptions, ...options } - - // Validate algorithm - if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedAlgorithm)) { - throw new Error( - `Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`, - ) - } - - const salt = randomBytes(16) - const key = deriveKey(password, salt, keyLength!, iterations!) - - let iv: Buffer | undefined - let cipher: any - - // Handle different encryption modes - if (algorithm === "aes-256-ecb") { - // ECB doesn't use IV - cipher = createCipheriv(algorithm!, key, null) - } else { - // CBC and GCM use IV - iv = randomBytes(16) - cipher = createCipheriv(algorithm!, key, iv) - } - - let encrypted = cipher.update(content, "utf8", "hex") - encrypted += cipher.final("hex") - - // Handle auth tag for GCM - let authTag: string | undefined - if (algorithm === "aes-256-gcm") { - authTag = cipher.getAuthTag().toString("hex") - } - - // Create password hash for verification - const passwordHash = hashPassword(password, salt) - - // Build result object - const result: any = { - salt: salt.toString("hex"), - content: encrypted, - passwordHash, - } - - if (iv) { - result.iv = iv.toString("hex") - } - - if (authTag) { - result.authTag = authTag - } - - return Buffer.from(JSON.stringify(result)).toString("base64") -} - -export const EncryptPlugin: QuartzTransformerPlugin> = (userOpts) => { +export const Encrypt: QuartzTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } // Validate algorithm at build time - if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedAlgorithm)) { + if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm)) { throw new Error( `[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, ) } - const getPassword = (file: VFile): string | undefined => { + const getEncryptionConfig = (file: VFile): DirectoryConfig | undefined => { const frontmatter = file.data?.frontmatter + const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig + const relativePath = file.data?.relativePath - if (frontmatter?.encrypt && frontmatter?.password) { - return frontmatter.password as string + const folderConfig = relativePath ? getEncryptionConfigForPath(relativePath, opts) : null + + if (!folderConfig && !frontmatterConfig.password) { + return undefined + } else if (!folderConfig && !frontmatter?.encrypt) { + return undefined } - let deepestFolder = "" - for (const folder of Object.keys(opts.encryptedFolders ?? {})) { - if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { - deepestFolder = folder - } + 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, } - if (deepestFolder) { - if (frontmatter?.password) { - // if frontmatter has a password, use it - return frontmatter.password as string - } - - return opts.encryptedFolders!![deepestFolder] as string + if (!config.password) { + return undefined } + + return config } return { - name: "EncryptPlugin", + name: "Encrypt", markdownPlugins() { - // If encypted, prepend lock emoji before the title + // If encrypted, prepend lock emoji before the title return [ () => { - return (_, file) => { - const password = getPassword(file) - if (!password) { + return async (_, file) => { + const config = getEncryptionConfig(file) + if (!config) { return } - file.data.encrypted = true - file.data.password = password - if (file.data?.frontmatter?.encryptMessage) { - file.data.encryptMessage = file.data.frontmatter.encryptMessage as string - } - - if (file.data?.frontmatter?.title) { - file.data.frontmatter.title = `🔒 ${file.data.frontmatter.title}` - } + file.data.encryptionConfig = config + file.data.hash = await hashString(config.password) } }, ] @@ -161,41 +88,55 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt htmlPlugins(ctx) { return [ () => { - return (tree: Root, file) => { - const password = getPassword(file) - if (!password) { - return tree // No encryption, return original tree + return async (tree: Root, file) => { + const config = getEncryptionConfig(file) + + if (!file.data.hash || !config) { + return tree } const locale = ctx.cfg.configuration.locale const t = i18n(locale).components.encryption - // Convert the HTML tree to string - const htmlContent = toHtml(tree) + // Convert html to plaintext and encrypt it + file.data.encryptionResult = await encryptContent( + toString(tree), + config.password, + config, + ) - // Encrypt the content - const encryptedContent = encryptContent(htmlContent, password, opts) - console.log(file.data) + // Encrypt the content and generate verification hash + const encryptionResult = await encryptContent(toHtml(tree), config.password, config) + + // Create individual attributes for each field instead of JSON + const attributes = [ + `data-config='${JSON.stringify(config)}'`, + `data-encrypted='${JSON.stringify(encryptionResult)}'`, + `data-hash='${JSON.stringify(file.data.hash)}'`, + `data-slug='${file.data.slug}'`, + ].join(" ") // Create a new tree with encrypted content placeholder const encryptedTree = fromHtml( ` -
+

${t.title}

-

${t.restricted}

+ ${config.message ? `

${config.message}

` : ""}
- ${file.data.encryptMessage ? `

${file.data.encryptMessage}

` : ""}
${t.decrypting}
-
+
${t.incorrectPassword}
+
+ ${t.decryptionFailed} +
`, @@ -233,8 +174,8 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt declare module "vfile" { interface DataMap { - encrypted: boolean - encryptMessage?: string - password?: string + encryptionConfig: EncryptionConfig + encryptionResult: EncryptionResult + hash: Hash } } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index ed5bc935b..0979385d3 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -6,6 +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" export interface Options { delimiters: string | [string, string] @@ -122,14 +123,17 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) if (encrypted) data.encrypt = true const password = coalesceAliases(data, ["password"]) - if (password) data.password = password + if (password) data.encryptConfig = { password: password } - const encryptMessage = coalesceAliases(data, [ - "encryptMessage", - "encrypt_message", - "encrypt-message", - ]) - if (encryptMessage) data.encryptMessage = encryptMessage + const encryptConfig = coalesceAliases(data, ["encryptConfig", "encrypt_config"]) + if (encryptConfig && typeof encryptConfig === "object") { + data.encryptConfig = { + password: encryptConfig.password || password, + message: encryptConfig.message || undefined, + algorithm: encryptConfig.algorithm || undefined, + ttl: encryptConfig.ttl || undefined, + } + } // Remove duplicate slugs const uniqueSlugs = [...new Set(allSlugs)] @@ -164,6 +168,8 @@ declare module "vfile" { cssclasses: string[] socialImage: string comments: boolean | string + encrypt: boolean + encryptConfig: DirectoryConfig }> } } diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 74b57ef6c..5270d5f05 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,7 +8,7 @@ export { CrawlLinks } from "./links" export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" -export { EncryptPlugin } from "./encrypt" +export { Encrypt } from "./encrypt" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam" diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts new file mode 100644 index 000000000..cd138f1d3 --- /dev/null +++ b/quartz/util/encryption.ts @@ -0,0 +1,595 @@ +// ============================================================================= +// 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] + +export interface Hash { + hash: string + salt: string +} + +export interface EncryptionResult { + encryptedContent: string + encryptionSalt: string + iv?: string + authTag?: string +} + +export interface EncryptionConfig { + algorithm: string + ttl: number + message: string +} + +export interface DirectoryConfig extends EncryptionConfig { + password: string +} + +export interface EncryptionOptions { + algorithm: string + encryptedFolders: { [folderPath: string]: string | DirectoryConfig } + message: string + ttl: number +} + +// ============================================================================= +// 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" + +// ============================================================================= +// CRYPTO INITIALIZATION +// ============================================================================= + +// Unified crypto interface for both Node.js and browser environments +let crypto: Crypto +if (typeof globalThis !== "undefined" && globalThis.crypto) { + crypto = globalThis.crypto +} else if (typeof window !== "undefined" && window.crypto) { + crypto = window.crypto +} else { + // Node.js environment + try { + const { webcrypto } = require("node:crypto") + crypto = webcrypto as Crypto + } catch { + throw new Error("No crypto implementation available") + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +// Check if crypto.subtle is available and supported +function checkCryptoSupport(): void { + if (!crypto || !crypto.subtle) { + throw new Error("Web Crypto API is not supported in this environment") + } +} + +function deriveKeyLengthFromAlgorithm(algorithm: string): number { + if (algorithm.includes("256")) return 32 // 256 bits = 32 bytes + if (algorithm.includes("192")) return 24 // 192 bits = 24 bytes + if (algorithm.includes("128")) return 16 // 128 bits = 16 bytes + return 32 // Default to 256-bit +} + +// Browser-compatible base64 encoding/decoding +export function base64Encode(data: string): string { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(data).toString("base64") + } else { + // Browser environment + return btoa(unescape(encodeURIComponent(data))) + } +} + +export function base64Decode(data: string): string { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(data, "base64").toString() + } else { + // Browser environment + return decodeURIComponent(escape(atob(data))) + } +} + +// Utility functions for array buffer conversions +export function hexToArrayBuffer(hex: string): ArrayBuffer { + if (!hex) return new ArrayBuffer(0) + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16) + } + return bytes.buffer +} + +export function arrayBufferToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +export function stringToArrayBuffer(str: string): ArrayBuffer { + const encoder = new TextEncoder() + return encoder.encode(str).buffer as ArrayBuffer +} + +export function arrayBufferToString(buffer: ArrayBuffer): string { + const decoder = new TextDecoder() + return decoder.decode(buffer) +} + +// ============================================================================= +// CORE CRYPTOGRAPHIC FUNCTIONS +// ============================================================================= + +export async function deriveKeyFromHash( + passwordHash: string, + algorithm: string, +): Promise { + try { + const keyLength = deriveKeyLengthFromAlgorithm(algorithm) + const hashBytes = hexToArrayBuffer(passwordHash) + + // Use only the required key length from the hash + const keyBytes = new Uint8Array(hashBytes).slice(0, keyLength) + + // For GCM mode, use AES-GCM as the algorithm name + const algorithmName = algorithm === "aes-256-gcm" ? "AES-GCM" : "AES-CBC" + + return await crypto.subtle.importKey("raw", keyBytes, { name: algorithmName }, false, [ + "encrypt", + "decrypt", + ]) + } catch (error) { + throw new Error( + `Key derivation failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } +} + +export async function hashString( + password: string, + salt: ArrayBuffer | string | undefined = undefined, +): Promise { + const passwordBytes = stringToArrayBuffer(password) + + let saltBytes: Uint8Array | null = null + + if (typeof salt === "string") { + saltBytes = new Uint8Array(hexToArrayBuffer(salt)) + } else if (salt !== undefined) { + saltBytes = new Uint8Array(salt) + } else { + saltBytes = crypto.getRandomValues(new Uint8Array(16)) + } + + const combined = new Uint8Array(passwordBytes.byteLength + saltBytes.byteLength) + combined.set(new Uint8Array(passwordBytes), 0) + combined.set(saltBytes, passwordBytes.byteLength) + + const hashBuffer = await crypto.subtle.digest("SHA-256", combined) + return { + hash: arrayBufferToHex(hashBuffer), + salt: arrayBufferToHex(saltBytes.buffer as ArrayBuffer), + } +} + +export async function verifyPasswordHash( + password: string, + passwordHashData: Hash, +): Promise { + const { hash: passwordHash } = await hashString(password, passwordHashData.salt) + return passwordHash === passwordHashData.hash +} + +export async function encryptContent( + content: string, + password: string, + config: EncryptionConfig, +): Promise { + checkCryptoSupport() + + const { algorithm } = config + + if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedEncryptionAlgorithm)) { + throw new Error( + `Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + // Generate random salt for encryption + const initializationVector = + algorithm === "aes-256-ecb" ? new Uint8Array(16) : crypto.getRandomValues(new Uint8Array(16)) // Zero IV for ECB simulation + + // Create encryption hash and derive key + const encryptionHashData = await hashString(password) + const key = await deriveKeyFromHash(encryptionHashData.hash, algorithm) + + // Prepare content for encryption + const contentBuffer = stringToArrayBuffer(content) + + let encryptedBuffer: ArrayBuffer + let authTag: ArrayBuffer | undefined + + try { + if (algorithm === "aes-256-gcm") { + // GCM mode - the Web Crypto API returns ciphertext with auth tag appended + const encryptedWithTag = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: initializationVector, + }, + key, + contentBuffer, + ) + + // The last 16 bytes (128 bits) are the authentication tag + const encryptedBytes = new Uint8Array(encryptedWithTag) + const ciphertext = encryptedBytes.slice(0, -16) + const authTagBytes = encryptedBytes.slice(-16) + + encryptedBuffer = ciphertext.buffer + authTag = authTagBytes.buffer + } else if (algorithm === "aes-256-cbc") { + // CBC mode + encryptedBuffer = await crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: initializationVector, + }, + key, + contentBuffer, + ) + } else if (algorithm === "aes-256-ecb") { + // ECB simulation using CBC with zero IV + encryptedBuffer = await crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: initializationVector, // Zero IV for ECB simulation + }, + key, + contentBuffer, + ) + } else { + throw new Error("Unsupported algorithm: " + algorithm) + } + } catch (error) { + throw new Error( + `Encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + // Build result object + const result: EncryptionResult = { + encryptedContent: arrayBufferToHex(encryptedBuffer), + encryptionSalt: encryptionHashData.salt, + } + + if (algorithm !== "aes-256-ecb") { + result.iv = arrayBufferToHex(initializationVector.buffer) + } + + if (authTag) { + result.authTag = arrayBufferToHex(authTag) + } + + return result +} + +export async function decryptContent( + encrypted: EncryptionResult, + config: EncryptionConfig, + password: string, +): Promise { + checkCryptoSupport() + const { encryptedContent, encryptionSalt, iv, authTag } = encrypted + + // Create encryption hash and derive key + const encryptionSaltBuffer = hexToArrayBuffer(encryptionSalt) + const encryptionHashData = await hashString(password, encryptionSaltBuffer) + const key = await deriveKeyFromHash(encryptionHashData.hash, config.algorithm) + + // Prepare for decryption + const ciphertext = hexToArrayBuffer(encryptedContent) + let decryptedBuffer: ArrayBuffer + + try { + if (config.algorithm === "aes-256-gcm") { + // GCM mode + if (!iv) throw new Error("IV is required for GCM mode") + if (!authTag) throw new Error("Authentication tag is required for GCM mode") + + const initializationVectorBuffer = hexToArrayBuffer(iv) + const authTagBuffer = hexToArrayBuffer(authTag) + + // For GCM decryption, we need to append the auth tag to the ciphertext + const combined = new Uint8Array(ciphertext.byteLength + authTagBuffer.byteLength) + combined.set(new Uint8Array(ciphertext), 0) + combined.set(new Uint8Array(authTagBuffer), ciphertext.byteLength) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: initializationVectorBuffer, + }, + key, + combined.buffer, + ) + } else if (config.algorithm === "aes-256-cbc") { + // CBC mode + if (!iv) throw new Error("IV is required for CBC mode") + const initializationVectorBuffer = hexToArrayBuffer(iv) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: initializationVectorBuffer, + }, + key, + ciphertext, + ) + } else if (config.algorithm === "aes-256-ecb") { + // ECB simulation using CBC with zero IV + const zeroInitializationVector = new ArrayBuffer(16) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: zeroInitializationVector, + }, + key, + ciphertext, + ) + } else { + throw new Error("Unsupported algorithm: " + config.algorithm) + } + } catch (error) { + throw new Error( + `Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + 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, +): Promise { + const passwords = getRelevantPasswords(filePath) + + for (const password of passwords) { + if (await verifyPasswordHash(password, hash)) { + addPasswordToCache(password, filePath, config.ttl) + return password + } + } + + return undefined +} + +// ============================================================================= +// PASSWORD CACHING AND MANAGEMENT +// ============================================================================= + +// Queue to prevent race conditions in cache operations +let cacheOperationQueue: Promise = Promise.resolve() + +interface CachedPassword { + password: string + ttl: number +} + +// Helper function to execute cache operations atomically +async function executeAtomicCacheOperation(operation: () => T): Promise { + return new Promise((resolve, reject) => { + cacheOperationQueue = cacheOperationQueue + .then(() => { + try { + const result = operation() + resolve(result) + } catch (error) { + reject(error) + } + }) + .catch((error) => { + reject(error) + }) + }) +} + +export function getPasswordCache(): Record { + // Check if we're in a browser environment + if (typeof localStorage === "undefined") { + return {} + } + + try { + const cache = localStorage.getItem(ENCRYPTION_CACHE_KEY) + return cache ? JSON.parse(cache) : {} + } catch { + return {} + } +} + +export function savePasswordCache(cache: Record) { + // Check if we're in a browser environment + if (typeof localStorage === "undefined") { + return + } + + try { + localStorage.setItem(ENCRYPTION_CACHE_KEY, JSON.stringify(cache)) + } catch { + // Silent fail if localStorage is not available + } +} + +export async function addPasswordToCache( + password: string, + filePath: string, + ttl: number, +): Promise { + return executeAtomicCacheOperation(() => { + const cache = getPasswordCache() + const now = Date.now() + + cache[filePath] = { + password, + ttl: ttl <= 0 ? 0 : now + ttl, + } + + savePasswordCache(cache) + }) +} + +export function getRelevantPasswords(filePath: string): string[] { + const cache = getPasswordCache() + const now = Date.now() + const uniquePasswords: Set = new Set() + const passwords: string[] = [] + + // Clean expired passwords (but keep infinite TTL ones) + Object.keys(cache).forEach((path) => { + if (cache[path].ttl > 0 && cache[path].ttl < now) { + delete cache[path] + } + }) + + if (cache[filePath] && cache[filePath].ttl > now) { + // If the exact file path is cached, return its password + return [cache[filePath].password] + } + + // Get passwords by directory hierarchy (closest first) + const sortedPaths = Object.keys(cache).sort((a, b) => { + const aShared = getSharedDirectoryDepth(a, filePath) + const bShared = getSharedDirectoryDepth(b, filePath) + return bShared - aShared // Descending order (most shared first) + }) + + for (const path of sortedPaths) { + if (!uniquePasswords.has(cache[path].password)) { + uniquePasswords.add(cache[path].password) + passwords.push(cache[path].password) + } + } + + return passwords +} + +export function getSharedDirectoryDepth(path1: string, path2: string): number { + const parts1 = path1.split("/") + const parts2 = path2.split("/") + let sharedDepth = 0 + + const minLength = Math.min(parts1.length, parts2.length) + for (let i = 0; i < minLength - 1; i++) { + // -1 to exclude filename + if (parts1[i] === parts2[i]) { + sharedDepth++ + } else { + break + } + } + + return sharedDepth +} + +export async function contentDecryptedEventListener( + filePath: string, + hash: Hash, + config: EncryptionConfig, + callback: (password: string) => void, + once: boolean = true, +) { + const checkForValidPassword: () => Promise = async () => { + const password = await searchForValidPassword(filePath, hash, config) + if (password) { + callback(password) + return true + } + 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) + } + }) + } +} \ No newline at end of file From d527797fa2e3ea47b79f2191ebf9dfc10fc29f20 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 31 Jul 2025 06:59:11 +0200 Subject: [PATCH 10/18] Remove unused field from translations --- quartz/components/scripts/encrypt.inline.ts | 11 ----------- quartz/i18n/locales/ar-SA.ts | 1 - quartz/i18n/locales/ca-ES.ts | 1 - quartz/i18n/locales/cs-CZ.ts | 1 - quartz/i18n/locales/de-DE.ts | 1 - quartz/i18n/locales/definition.ts | 1 - quartz/i18n/locales/en-GB.ts | 1 - quartz/i18n/locales/en-US.ts | 1 - quartz/i18n/locales/es-ES.ts | 1 - quartz/i18n/locales/fa-IR.ts | 1 - quartz/i18n/locales/fi-FI.ts | 1 - quartz/i18n/locales/fr-FR.ts | 1 - quartz/i18n/locales/hu-HU.ts | 1 - quartz/i18n/locales/id-ID.ts | 1 - quartz/i18n/locales/it-IT.ts | 1 - quartz/i18n/locales/ja-JP.ts | 1 - quartz/i18n/locales/ko-KR.ts | 1 - quartz/i18n/locales/lt-LT.ts | 1 - quartz/i18n/locales/nb-NO.ts | 1 - quartz/i18n/locales/nl-NL.ts | 1 - quartz/i18n/locales/pl-PL.ts | 1 - quartz/i18n/locales/pt-BR.ts | 1 - quartz/i18n/locales/ro-RO.ts | 1 - quartz/i18n/locales/ru-RU.ts | 1 - quartz/i18n/locales/th-TH.ts | 1 - quartz/i18n/locales/tr-TR.ts | 1 - quartz/i18n/locales/uk-UA.ts | 1 - quartz/i18n/locales/vi-VN.ts | 1 - quartz/i18n/locales/zh-CN.ts | 1 - quartz/i18n/locales/zh-TW.ts | 1 - quartz/plugins/transformers/encrypt.ts | 5 ++++- quartz/util/encryption.ts | 2 +- 32 files changed, 5 insertions(+), 42 deletions(-) diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index 62cd62e0f..8f4fe0061 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -128,17 +128,6 @@ const notifyNav = (url: FullSlug) => { document.dispatchEvent(event) } -const getFirstParentWithClass = (container: HTMLElement, className: string): HTMLElement | null => { - let current = container.parentElement - while (current) { - if (current.classList.contains(className)) { - return current - } - current = current.parentElement - } - return null -} - function updateTitle(container: HTMLElement | null) { console.log(container) if (container) { diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts index 7a6db7650..dd425b708 100644 --- a/quartz/i18n/locales/ar-SA.ts +++ b/quartz/i18n/locales/ar-SA.ts @@ -66,7 +66,6 @@ export default { }, encryption: { title: "🛡️ محتوى محدود 🛡️", - restricted: "هذا المحتوى محدود. ادخل كلمة المرور للعرض:", enterPassword: "ادخل كلمة المرور", decrypt: "فك التشفير", decrypting: "جاري فك التشفير...", diff --git a/quartz/i18n/locales/ca-ES.ts b/quartz/i18n/locales/ca-ES.ts index 73c07f2f1..17c373374 100644 --- a/quartz/i18n/locales/ca-ES.ts +++ b/quartz/i18n/locales/ca-ES.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Contingut Restringit 🛡️", - restricted: "Aquest contingut està restringit. Introduïu la contrasenya per veure:", enterPassword: "Introduïu la contrasenya", decrypt: "Desxifrar", decrypting: "Desxifrant...", diff --git a/quartz/i18n/locales/cs-CZ.ts b/quartz/i18n/locales/cs-CZ.ts index 4648faa6a..017b1900c 100644 --- a/quartz/i18n/locales/cs-CZ.ts +++ b/quartz/i18n/locales/cs-CZ.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Omezený obsah 🛡️", - restricted: "Tento obsah je omezen. Zadejte heslo pro zobrazení:", enterPassword: "Zadejte heslo", decrypt: "Dešifrovat", decrypting: "Dešifruji...", diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts index 953f0ddfc..8317ee6f9 100644 --- a/quartz/i18n/locales/de-DE.ts +++ b/quartz/i18n/locales/de-DE.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Eingeschränkter Inhalt 🛡️", - restricted: "Dieser Inhalt ist eingeschränkt. Geben Sie das Passwort ein, um ihn anzuzeigen:", enterPassword: "Passwort eingeben", decrypt: "Entschlüsseln", decrypting: "Entschlüsselt...", diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts index b51eb94d3..9d5e19d55 100644 --- a/quartz/i18n/locales/definition.ts +++ b/quartz/i18n/locales/definition.ts @@ -63,7 +63,6 @@ export interface Translation { } encryption: { title: string - restricted: string enterPassword: string decrypt: string decrypting: string diff --git a/quartz/i18n/locales/en-GB.ts b/quartz/i18n/locales/en-GB.ts index f3c1eb52f..d3d506843 100644 --- a/quartz/i18n/locales/en-GB.ts +++ b/quartz/i18n/locales/en-GB.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Restricted Content 🛡️", - restricted: "This content is restricted. Enter the password to view:", enterPassword: "Enter password", decrypt: "Decrypt", decrypting: "Decrypting...", diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts index 1ea4437a4..6720064e5 100644 --- a/quartz/i18n/locales/en-US.ts +++ b/quartz/i18n/locales/en-US.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Restricted Content 🛡️", - restricted: "This content is restricted. Enter the password to view:", enterPassword: "Enter password", decrypt: "Decrypt", decrypting: "Decrypting...", diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts index f79b5a374..f128702b3 100644 --- a/quartz/i18n/locales/es-ES.ts +++ b/quartz/i18n/locales/es-ES.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Contenido Restringido 🛡️", - restricted: "Este contenido está restringido. Ingrese la contraseña para ver:", enterPassword: "Ingrese contraseña", decrypt: "Desencriptar", decrypting: "Desencriptando...", diff --git a/quartz/i18n/locales/fa-IR.ts b/quartz/i18n/locales/fa-IR.ts index 1fcef66ee..d7b36860b 100644 --- a/quartz/i18n/locales/fa-IR.ts +++ b/quartz/i18n/locales/fa-IR.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ محتوای محدود 🛡️", - restricted: "این محتوا محدود است. رمز عبور را وارد کنید:", enterPassword: "رمز عبور را وارد کنید", decrypt: "رمزگشایی", decrypting: "در حال رمزگشایی...", diff --git a/quartz/i18n/locales/fi-FI.ts b/quartz/i18n/locales/fi-FI.ts index 4375b07f7..9bc84061a 100644 --- a/quartz/i18n/locales/fi-FI.ts +++ b/quartz/i18n/locales/fi-FI.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Rajoitettu Sisältö 🛡️", - restricted: "Tämä sisältö on rajoitettu. Anna salasana nähdäksesi:", enterPassword: "Anna salasana", decrypt: "Pura salaus", decrypting: "Puretaan salausta...", diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts index 60e7d1fec..ddc9aaa07 100644 --- a/quartz/i18n/locales/fr-FR.ts +++ b/quartz/i18n/locales/fr-FR.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Contenu Restreint 🛡️", - restricted: "Ce contenu est restreint. Entrez le mot de passe pour l'afficher :", enterPassword: "Entrez le mot de passe", decrypt: "Déchiffrer", decrypting: "Déchiffrement...", diff --git a/quartz/i18n/locales/hu-HU.ts b/quartz/i18n/locales/hu-HU.ts index ab022546b..1fd7695f7 100644 --- a/quartz/i18n/locales/hu-HU.ts +++ b/quartz/i18n/locales/hu-HU.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Korlátozott Tartalom 🛡️", - restricted: "Ez a tartalom korlátozott. Adja meg a jelszót a megtekintéshez:", enterPassword: "Jelszó megadása", decrypt: "Visszafejtés", decrypting: "Visszafejtés...", diff --git a/quartz/i18n/locales/id-ID.ts b/quartz/i18n/locales/id-ID.ts index 06da19e21..42bb3f973 100644 --- a/quartz/i18n/locales/id-ID.ts +++ b/quartz/i18n/locales/id-ID.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Konten Terbatas 🛡️", - restricted: "Konten ini terbatas. Masukkan kata sandi untuk melihat:", enterPassword: "Masukkan kata sandi", decrypt: "Dekripsi", decrypting: "Mendekripsi...", diff --git a/quartz/i18n/locales/it-IT.ts b/quartz/i18n/locales/it-IT.ts index 911a05bc4..53189edeb 100644 --- a/quartz/i18n/locales/it-IT.ts +++ b/quartz/i18n/locales/it-IT.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Contenuto Riservato 🛡️", - restricted: "Questo contenuto è riservato. Inserisci la password per visualizzare:", enterPassword: "Inserisci password", decrypt: "Decripta", decrypting: "Decriptando...", diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts index 6372b3fca..85732e8c6 100644 --- a/quartz/i18n/locales/ja-JP.ts +++ b/quartz/i18n/locales/ja-JP.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ 制限されたコンテンツ 🛡️", - restricted: "このコンテンツは制限されています。表示するにはパスワードを入力してください:", enterPassword: "パスワードを入力", decrypt: "復号化", decrypting: "復号化中...", diff --git a/quartz/i18n/locales/ko-KR.ts b/quartz/i18n/locales/ko-KR.ts index c2d9ca85a..c76fe2297 100644 --- a/quartz/i18n/locales/ko-KR.ts +++ b/quartz/i18n/locales/ko-KR.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ 제한된 콘텐츠 🛡️", - restricted: "이 콘텐츠는 제한되어 있습니다. 보려면 비밀번호를 입력하세요:", enterPassword: "비밀번호 입력", decrypt: "복호화", decrypting: "복호화 중...", diff --git a/quartz/i18n/locales/lt-LT.ts b/quartz/i18n/locales/lt-LT.ts index b85b20934..039877699 100644 --- a/quartz/i18n/locales/lt-LT.ts +++ b/quartz/i18n/locales/lt-LT.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Apribotas Turinys 🛡️", - restricted: "Šis turinys yra apribotas. Įveskite slaptažodį, kad peržiūrėtumėte:", enterPassword: "Įvesti slaptažodį", decrypt: "Iššifruoti", decrypting: "Iššifruojama...", diff --git a/quartz/i18n/locales/nb-NO.ts b/quartz/i18n/locales/nb-NO.ts index 282404b35..73f76871e 100644 --- a/quartz/i18n/locales/nb-NO.ts +++ b/quartz/i18n/locales/nb-NO.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Begrenset Innhold 🛡️", - restricted: "Dette innholdet er begrenset. Skriv inn passord for å vise:", enterPassword: "Skriv inn passord", decrypt: "Dekrypter", decrypting: "Dekrypterer...", diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts index 3287b2b0d..2d73dd8d4 100644 --- a/quartz/i18n/locales/nl-NL.ts +++ b/quartz/i18n/locales/nl-NL.ts @@ -62,7 +62,6 @@ export default { }, encryption: { title: "🛡️ Beperkte Inhoud 🛡️", - restricted: "Deze inhoud is beperkt. Voer het wachtwoord in om te bekijken:", enterPassword: "Voer wachtwoord in", decrypt: "Ontsleutelen", decrypting: "Ontsleutelen...", diff --git a/quartz/i18n/locales/pl-PL.ts b/quartz/i18n/locales/pl-PL.ts index 5755533cb..5887d7b3b 100644 --- a/quartz/i18n/locales/pl-PL.ts +++ b/quartz/i18n/locales/pl-PL.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Ograniczona Treść 🛡️", - restricted: "Ta treść jest ograniczona. Wprowadź hasło, aby wyświetlić:", enterPassword: "Wprowadź hasło", decrypt: "Odszyfruj", decrypting: "Odszyfrowywanie...", diff --git a/quartz/i18n/locales/pt-BR.ts b/quartz/i18n/locales/pt-BR.ts index c5ecfe170..4e7ebce49 100644 --- a/quartz/i18n/locales/pt-BR.ts +++ b/quartz/i18n/locales/pt-BR.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Conteúdo Restrito 🛡️", - restricted: "Este conteúdo é restrito. Digite a senha para visualizar:", enterPassword: "Digite a senha", decrypt: "Descriptografar", decrypting: "Descriptografando...", diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts index b211e089b..76b537b61 100644 --- a/quartz/i18n/locales/ro-RO.ts +++ b/quartz/i18n/locales/ro-RO.ts @@ -62,7 +62,6 @@ export default { }, encryption: { title: "🛡️ Conținut Restricționat 🛡️", - restricted: "Acest conținut este restricționat. Introduceți parola pentru a vizualiza:", enterPassword: "Introduceți parola", decrypt: "Decriptați", decrypting: "Se decriptează...", diff --git a/quartz/i18n/locales/ru-RU.ts b/quartz/i18n/locales/ru-RU.ts index 41efcae4b..9996a4f12 100644 --- a/quartz/i18n/locales/ru-RU.ts +++ b/quartz/i18n/locales/ru-RU.ts @@ -62,7 +62,6 @@ export default { }, encryption: { title: "🛡️ Ограниченный Контент 🛡️", - restricted: "Этот контент ограничен. Введите пароль для просмотра:", enterPassword: "Введите пароль", decrypt: "Расшифровать", decrypting: "Расшифровка...", diff --git a/quartz/i18n/locales/th-TH.ts b/quartz/i18n/locales/th-TH.ts index 05e6441ab..01aa47879 100644 --- a/quartz/i18n/locales/th-TH.ts +++ b/quartz/i18n/locales/th-TH.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ เนื้อหาถูกจำกัด 🛡️", - restricted: "เนื้อหานี้ถูกจำกัด กรุณาใส่รหัสผ่านเพื่อดู:", enterPassword: "ใส่รหัสผ่าน", decrypt: "ถอดรหัส", decrypting: "กำลังถอดรหัส...", diff --git a/quartz/i18n/locales/tr-TR.ts b/quartz/i18n/locales/tr-TR.ts index 1d416179c..aeb287b8c 100644 --- a/quartz/i18n/locales/tr-TR.ts +++ b/quartz/i18n/locales/tr-TR.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Kısıtlı İçerik 🛡️", - restricted: "Bu içerik şifreli. Görüntülemek için şifreyi girin:", enterPassword: "Şifreyi girin", decrypt: "Şifreyi Çöz", decrypting: "Şifre çözülüyor...", diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts index 8db23b072..75c7d6ede 100644 --- a/quartz/i18n/locales/uk-UA.ts +++ b/quartz/i18n/locales/uk-UA.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Обмежений Контент 🛡️", - restricted: "Цей контент обмежений. Введіть пароль, щоб переглянути:", enterPassword: "Введіть пароль", decrypt: "Розшифрувати", decrypting: "Розшифрування...", diff --git a/quartz/i18n/locales/vi-VN.ts b/quartz/i18n/locales/vi-VN.ts index 782702cd9..6af59bf16 100644 --- a/quartz/i18n/locales/vi-VN.ts +++ b/quartz/i18n/locales/vi-VN.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ Nội Dung Bị Hạn Chế 🛡️", - restricted: "Nội dung này bị hạn chế. Nhập mật khẩu để xem:", enterPassword: "Nhập mật khẩu", decrypt: "Giải mã", decrypting: "Đang giải mã...", diff --git a/quartz/i18n/locales/zh-CN.ts b/quartz/i18n/locales/zh-CN.ts index 7a84864fc..470b6eb7d 100644 --- a/quartz/i18n/locales/zh-CN.ts +++ b/quartz/i18n/locales/zh-CN.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ 受限内容 🛡️", - restricted: "此内容受限制。输入密码以查看:", enterPassword: "输入密码", decrypt: "解密", decrypting: "解密中...", diff --git a/quartz/i18n/locales/zh-TW.ts b/quartz/i18n/locales/zh-TW.ts index 52e579778..54294585a 100644 --- a/quartz/i18n/locales/zh-TW.ts +++ b/quartz/i18n/locales/zh-TW.ts @@ -61,7 +61,6 @@ export default { }, encryption: { title: "🛡️ 受限內容 🛡️", - restricted: "此內容受限制。輸入密碼以檢視:", enterPassword: "輸入密碼", decrypt: "解密", decrypting: "解密中...", diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index e4d2d0bd6..647b1d748 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -34,7 +34,10 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => const opts = { ...defaultOptions, ...userOpts } // Validate algorithm at build time - if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm)) { + if ( + opts.algorithm && + !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm) + ) { throw new Error( `[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, ) diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts index cd138f1d3..5bc7c3944 100644 --- a/quartz/util/encryption.ts +++ b/quartz/util/encryption.ts @@ -592,4 +592,4 @@ export async function contentDecryptedEventListener( } }) } -} \ No newline at end of file +} From d87cd7d4971f0e6bdf1a8673ccd89a1364294688 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 31 Jul 2025 07:02:37 +0200 Subject: [PATCH 11/18] Enable encrypt wiki page decryption --- docs/plugins/Encrypt.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index 56397eda6..052076186 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -2,6 +2,7 @@ title: "Encrypt" tags: - plugin/transformer +encrypt: true encryptConfig: password: "quartz" message: '^ Password is "quartz"' From 54d739214ab2817359f0456e364520b12ca91251 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 31 Jul 2025 07:16:03 +0200 Subject: [PATCH 12/18] Make EncryptionOptions extend to EncryptionConfig --- quartz/util/encryption.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts index 5bc7c3944..feef85f29 100644 --- a/quartz/util/encryption.ts +++ b/quartz/util/encryption.ts @@ -28,11 +28,8 @@ export interface DirectoryConfig extends EncryptionConfig { password: string } -export interface EncryptionOptions { - algorithm: string +export interface EncryptionOptions extends EncryptionConfig { encryptedFolders: { [folderPath: string]: string | DirectoryConfig } - message: string - ttl: number } // ============================================================================= From 43cfbaae22040be9fe0c18188ae2a1f00de604be Mon Sep 17 00:00:00 2001 From: arg3t Date: Thu, 31 Jul 2025 14:47:28 +0200 Subject: [PATCH 13/18] Add a new "render" event that is triggered post-encryption --- docs/advanced/architecture.md | 10 +- docs/advanced/creating components.md | 45 ++++- docs/advanced/event system.md | 171 ++++++++++++++++++ index.d.ts | 3 +- quartz/components/scripts/callout.inline.ts | 8 +- quartz/components/scripts/checkbox.inline.ts | 5 +- quartz/components/scripts/clipboard.inline.ts | 6 +- quartz/components/scripts/comments.inline.ts | 6 +- quartz/components/scripts/encrypt.inline.ts | 48 ++--- quartz/components/scripts/mermaid.inline.ts | 6 +- quartz/components/scripts/popover.inline.ts | 16 +- quartz/components/scripts/search.inline.ts | 20 +- quartz/components/scripts/spa.inline.ts | 5 +- quartz/components/scripts/toc.inline.ts | 6 +- quartz/components/scripts/util.ts | 13 ++ quartz/components/styles/encrypt.scss | 35 ++-- quartz/plugins/emitters/componentResources.ts | 2 + quartz/plugins/transformers/encrypt.ts | 1 + 18 files changed, 304 insertions(+), 102 deletions(-) create mode 100644 docs/advanced/event system.md diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 33da89d90..cbeda5230 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -45,8 +45,12 @@ This question is best answered by tracing what happens when a user (you!) runs ` 1. The browser opens a Quartz page and loads the HTML. The `` also links to page styles (emitted to `public/index.css`) and page-critical JS (emitted to `public/prescript.js`) 2. Then, once the body is loaded, the browser loads the non-critical JS (emitted to `public/postscript.js`) -3. Once the page is done loading, the page will then dispatch a custom synthetic browser event `"nav"`. This is used so client-side scripts declared by components can 'setup' anything that requires access to the page DOM. - 1. If the [[SPA Routing|enableSPA option]] is enabled in the [[configuration]], this `"nav"` event is also fired on any client-navigation to allow for components to unregister and reregister any event handlers and state. - 2. If it's not, we wire up the `"nav"` event to just be fired a single time after page load to allow for consistency across how state is setup across both SPA and non-SPA contexts. +3. Once the page is done loading, the page will dispatch two custom synthetic browser events: + 1. **`"nav"`** event: Fired when the user navigates to a new page. This is used for navigation-specific logic like updating URL-dependent state, analytics tracking, etc. + - Contains `e.detail.url` with the current page URL + - Fired on initial page load and on client-side navigation (if [[SPA Routing|enableSPA option]] is enabled) + 2. **`"render"`** event: Fired when content needs to be processed or re-rendered. This is used for DOM manipulation, setting up event listeners, and other content-specific logic. + - Contains `e.detail.htmlElement` with the DOM element that was updated + - Fired on initial page load (with `document.body`) and whenever content is dynamically updated (e.g., in popovers, search results, after decryption) The architecture and design of the plugin system was intentionally left pretty vague here as this is described in much more depth in the guide on [[making plugins|making your own plugin]]. diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md index 84e038012..885acc48d 100644 --- a/docs/advanced/creating components.md +++ b/docs/advanced/creating components.md @@ -149,15 +149,46 @@ As the names suggest, the `.beforeDOMLoaded` scripts are executed _before_ the p The `.afterDOMLoaded` script executes once the page has been completely loaded. This is a good place to setup anything that should last for the duration of a site visit (e.g. getting something saved from local storage). -If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you can listen for the `"nav"` event that gets fired whenever a page loads (which may happen on navigation if [[SPA Routing]] is enabled). +If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you have two options: + +**For navigation-specific logic**, listen for the `"nav"` event that gets fired whenever the user navigates to a new page: ```ts -document.addEventListener("nav", () => { - // do page specific logic here - // e.g. attach event listeners - const toggleSwitch = document.querySelector("#switch") as HTMLInputElement - toggleSwitch.addEventListener("change", switchTheme) - window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) +document.addEventListener("nav", (e) => { + // runs only on page navigation + // e.detail.url contains the new page URL + const currentUrl = e.detail.url + console.log(`Navigated to: ${currentUrl}`) +}) +``` + +**For rendering/re-rendering content**, use the `"render"` event which is fired when content needs to be processed or updated: + +```ts +document.addEventListener("render", (e) => { + // runs when content is rendered or re-rendered + // e.detail.htmlElement contains the DOM element that was updated + const container = e.detail.htmlElement + + // attach event listeners to elements within this container + const toggleSwitch = container.querySelector("#switch") as HTMLInputElement + if (toggleSwitch) { + toggleSwitch.addEventListener("change", switchTheme) + window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) + } +}) +``` + +You can also use the utility function from `"./util"` to simplify render event handling: + +```ts +import { addRenderListener } from "./util" + +addRenderListener((container) => { + // your rendering logic here + // container is the DOM element that was updated + const elements = container.querySelectorAll(".my-component") + elements.forEach(setupElement) }) ``` diff --git a/docs/advanced/event system.md b/docs/advanced/event system.md new file mode 100644 index 000000000..d0f3543bc --- /dev/null +++ b/docs/advanced/event system.md @@ -0,0 +1,171 @@ +--- +title: Event System +--- + +Quartz uses a custom event system to coordinate between navigation and content rendering. Understanding these events is crucial for creating interactive components that work correctly with [[SPA Routing]]. + +## Event Types + +### Navigation Event (`nav`) + +The `nav` event is fired when the user navigates to a new page. This should be used for logic that needs to run once per page navigation. + +```ts +document.addEventListener("nav", (e: CustomEventMap["nav"]) => { + // Access the current page URL + const currentUrl = e.detail.url + console.log(`User navigated to: ${currentUrl}`) + + // Good for: + // - Analytics tracking + // - URL-dependent state updates + // - Setting up page-level event handlers + // - Theme/mode initialization +}) +``` + +**When it fires:** +- On initial page load +- On client-side navigation (if SPA routing is enabled) +- Does NOT fire on content re-renders + +### Render Event (`render`) + +The `render` event is fired when content needs to be processed or updated. This should be used for DOM manipulation and content-specific logic. + +```ts +document.addEventListener("render", (e: CustomEventMap["render"]) => { + // Access the container that was updated + const container = e.detail.htmlElement + + // Process elements within this container + const codeBlocks = container.querySelectorAll("pre code") + codeBlocks.forEach(addSyntaxHighlighting) + + // Good for: + // - Setting up event listeners on new content + // - Processing dynamic content (syntax highlighting, math rendering, etc.) + // - Initializing interactive components +}) +``` + +**When it fires:** +- On initial page load (with `document.body` as the container) +- When popover content is loaded +- When search results are displayed +- After content is decrypted +- Whenever `dispatchRenderEvent()` is called + +## Utility Functions + +Quartz provides utility functions in `quartz/components/scripts/util.ts` to make working with these events easier: + +### `addRenderListener(fn)` + +A convenience function for listening to render events: + +```ts +import { addRenderListener } from "./util" + +addRenderListener((container: HTMLElement) => { + // Your rendering logic here + // container is the DOM element that was updated + const myElements = container.querySelectorAll(".my-component") + myElements.forEach(setupMyComponent) +}) +``` + +This is equivalent to manually adding a render event listener but with cleaner syntax. + +### `dispatchRenderEvent(htmlElement)` + +Triggers a render event for a specific DOM element: + +```ts +import { dispatchRenderEvent } from "./util" + +// After dynamically creating or updating content +const myContainer = document.getElementById("dynamic-content") +// ... update the container content ... +dispatchRenderEvent(myContainer) +``` + +This will cause all render event listeners to process the specified container. + +## Best Practices + +### When to use `nav` vs `render` + +- **Use `nav` for:** Page-level setup, URL tracking, global state management +- **Use `render` for:** Content processing, element-specific event handlers, DOM manipulation + +### Memory Management + +Always clean up event handlers to prevent memory leaks: + +```ts +addRenderListener((container) => { + const buttons = container.querySelectorAll(".my-button") + + const handleClick = (e) => { /* ... */ } + + buttons.forEach(button => { + button.addEventListener("click", handleClick) + // Clean up when navigating away + window.addCleanup(() => { + button.removeEventListener("click", handleClick) + }) + }) +}) +``` + +The `window.addCleanup()` function ensures handlers are removed when navigating to a new page. + +### Scoped Processing + +Always scope your render logic to the provided container: + +```ts +// ✅ Good - only processes elements within the updated container +addRenderListener((container) => { + const elements = container.querySelectorAll(".my-element") + elements.forEach(process) +}) + +// ❌ Bad - processes all elements on the page +addRenderListener((container) => { + const elements = document.querySelectorAll(".my-element") + elements.forEach(process) +}) +``` + +This ensures your logic only runs on newly updated content and avoids duplicate processing. + +## Migration from Old System + +If you have existing code that used the old `rerender` flag pattern: + +```ts +// Old pattern ❌ +document.addEventListener("nav", (e) => { + if (e.detail.rerender) return // Skip rerender events + // ... setup logic +}) +``` + +You should split this into separate event handlers: + +```ts +// New pattern ✅ +document.addEventListener("nav", (e) => { + // Navigation-only logic + updateURL(e.detail.url) +}) + +addRenderListener((container) => { + // Content rendering logic + setupComponents(container) +}) +``` + +This provides cleaner separation of concerns and better performance. \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 70ec9c26f..03cff3763 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,7 +6,8 @@ declare module "*.scss" { // dom custom event interface CustomEventMap { prenav: CustomEvent<{}> - nav: CustomEvent<{ url: FullSlug; rerender?: boolean }> + nav: CustomEvent<{ url: FullSlug }> + render: CustomEvent<{ htmlElement: HTMLElement }> themechange: CustomEvent<{ theme: "light" | "dark" }> readermodechange: CustomEvent<{ mode: "on" | "off" }> } diff --git a/quartz/components/scripts/callout.inline.ts b/quartz/components/scripts/callout.inline.ts index 242ce514e..46f0ff489 100644 --- a/quartz/components/scripts/callout.inline.ts +++ b/quartz/components/scripts/callout.inline.ts @@ -1,3 +1,5 @@ +import { addRenderListener } from "./util" + function toggleCallout(this: HTMLElement) { const outerBlock = this.parentElement! outerBlock.classList.toggle("is-collapsed") @@ -7,8 +9,8 @@ function toggleCallout(this: HTMLElement) { content.style.gridTemplateRows = collapsed ? "0fr" : "1fr" } -function setupCallout() { - const collapsible = document.getElementsByClassName( +function setupCallout(container: HTMLElement) { + const collapsible = container.getElementsByClassName( `callout is-collapsible`, ) as HTMLCollectionOf for (const div of collapsible) { @@ -24,4 +26,4 @@ function setupCallout() { } } -document.addEventListener("nav", setupCallout) +addRenderListener(setupCallout) diff --git a/quartz/components/scripts/checkbox.inline.ts b/quartz/components/scripts/checkbox.inline.ts index 50ab0425a..189df2220 100644 --- a/quartz/components/scripts/checkbox.inline.ts +++ b/quartz/components/scripts/checkbox.inline.ts @@ -1,9 +1,10 @@ import { getFullSlug } from "../../util/path" +import { addRenderListener } from "./util" const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}` -document.addEventListener("nav", () => { - const checkboxes = document.querySelectorAll( +addRenderListener((container: HTMLElement) => { + const checkboxes = container.querySelectorAll( "input.checkbox-toggle", ) as NodeListOf checkboxes.forEach((el, index) => { diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts index e16c11299..be7cf192a 100644 --- a/quartz/components/scripts/clipboard.inline.ts +++ b/quartz/components/scripts/clipboard.inline.ts @@ -1,10 +1,12 @@ +import { addRenderListener } from "./util" + const svgCopy = '' const svgCheck = '' -document.addEventListener("nav", () => { - const els = document.getElementsByTagName("pre") +addRenderListener((container: HTMLElement) => { + const els = container.getElementsByTagName("pre") for (let i = 0; i < els.length; i++) { const codeBlock = els[i].getElementsByTagName("code")[0] if (codeBlock) { diff --git a/quartz/components/scripts/comments.inline.ts b/quartz/components/scripts/comments.inline.ts index 2b876bf6b..ef7bde01a 100644 --- a/quartz/components/scripts/comments.inline.ts +++ b/quartz/components/scripts/comments.inline.ts @@ -1,3 +1,5 @@ +import { addRenderListener } from "./util" + const changeTheme = (e: CustomEventMap["themechange"]) => { const theme = e.detail.theme const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement @@ -59,8 +61,8 @@ type GiscusElement = Omit & { } } -document.addEventListener("nav", () => { - const giscusContainer = document.querySelector(".giscus") as GiscusElement +addRenderListener((container: HTMLElement) => { + const giscusContainer = container.querySelector(".giscus") as GiscusElement if (!giscusContainer) { return } diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index 8f4fe0061..495ed0ba3 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -8,6 +8,7 @@ import { EncryptionResult, } from "../../util/encryption" import { FullSlug, getFullSlug } from "../../util/path" +import { addRenderListener, dispatchRenderEvent } from "./util" const showLoading = (container: Element, show: boolean) => { const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement @@ -78,6 +79,9 @@ const decryptWithPassword = async ( contentWrapper.className = "decrypted-content-wrapper" contentWrapper.innerHTML = decryptedContent container.parentNode!.replaceChild(contentWrapper, container) + // set data-decrypted of the original container to true + containerElement.dataset.decrypted = "true" + return true } @@ -121,13 +125,6 @@ const decryptWithPassword = async ( } } -const notifyNav = (url: FullSlug) => { - const event: CustomEventMap["nav"] = new CustomEvent("nav", { - detail: { url, rerender: true }, - }) - document.dispatchEvent(event) -} - function updateTitle(container: HTMLElement | null) { console.log(container) if (container) { @@ -138,32 +135,22 @@ function updateTitle(container: HTMLElement | null) { } } -const tryAutoDecrypt = async (container: HTMLElement): Promise => { - const filePath = getFullSlug(window) +const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise => { const fullSlug = container.dataset.slug as FullSlug const config = JSON.parse(container.dataset.config!) as EncryptionConfig const hash = JSON.parse(container.dataset.hash!) as Hash - const parent = - (container.closest(".popover") as HTMLElement) || - (container.closest(".preview-inner") as HTMLElement) || - (container.closest(".center") as HTMLElement) || - null const password = await searchForValidPassword(fullSlug, hash, config) if (password && (await decryptWithPassword(container, password, false))) { - notifyNav(filePath) + dispatchRenderEvent(parent) updateTitle(parent) return true } return false } -const manualDecrypt = async (container: HTMLElement) => { - const parent = - (container.closest(".preview-inner") as HTMLElement) || - (container.closest(".center") as HTMLElement) || - null +const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => { const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement const password = passwordInput.value @@ -173,29 +160,28 @@ const manualDecrypt = async (container: HTMLElement) => { } if (await decryptWithPassword(container, password, true)) { - const filePath = getFullSlug(window) - notifyNav(filePath) + dispatchRenderEvent(parent) updateTitle(parent) } } -document.addEventListener("nav", async () => { - // Try auto-decryption for all encrypted content - const encryptedElements = document.querySelectorAll( - ".encrypted-content", +addRenderListener(async (element) => { + // Try auto-decryption for all encrypted content with data-decrypted="false" + const encryptedElements = element.querySelectorAll( + ".encrypted-content[data-decrypted='false']", ) as NodeListOf for (const encryptedContainer of encryptedElements) { - await tryAutoDecrypt(encryptedContainer) + await tryAutoDecrypt(element, encryptedContainer) } // Manual decryption handlers - const buttons = document.querySelectorAll(".decrypt-button") + const buttons = element.querySelectorAll(".decrypt-button") buttons.forEach((button) => { const handleClick = async function (this: HTMLElement) { const encryptedContainer = this.closest(".encrypted-content")! - await manualDecrypt(encryptedContainer as HTMLElement) + await manualDecrypt(element, encryptedContainer as HTMLElement) } button.addEventListener("click", handleClick) @@ -206,12 +192,12 @@ document.addEventListener("nav", async () => { }) // Enter key handler - document.querySelectorAll(".decrypt-password").forEach((input) => { + element.querySelectorAll(".decrypt-password").forEach((input) => { const handleKeypress = async function (this: HTMLInputElement, e: Event) { const keyEvent = e as KeyboardEvent if (keyEvent.key === "Enter") { const encryptedContainer = this.closest(".encrypted-content")! - await manualDecrypt(encryptedContainer as HTMLElement) + await manualDecrypt(element, encryptedContainer as HTMLElement) } } diff --git a/quartz/components/scripts/mermaid.inline.ts b/quartz/components/scripts/mermaid.inline.ts index 533e38555..eeb834196 100644 --- a/quartz/components/scripts/mermaid.inline.ts +++ b/quartz/components/scripts/mermaid.inline.ts @@ -1,4 +1,4 @@ -import { registerEscapeHandler, removeAllChildren } from "./util" +import { registerEscapeHandler, removeAllChildren, addRenderListener } from "./util" interface Position { x: number @@ -144,8 +144,8 @@ const cssVars = [ let mermaidImport = undefined -document.addEventListener("nav", async () => { - const nodes = document.querySelectorAll( +addRenderListener(async (container: HTMLElement) => { + const nodes = container.querySelectorAll( "code.mermaid:not([data-processed])", ) as NodeListOf if (nodes.length === 0) return diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index d48fecf51..e5576758c 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,17 +1,10 @@ import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { FullSlug, normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util" const p = new DOMParser() let activeAnchor: HTMLAnchorElement | null = null -function notifyNav(url: FullSlug) { - const event: CustomEventMap["nav"] = new CustomEvent("nav", { - detail: { url, rerender: true }, - }) - document.dispatchEvent(event) -} - async function mouseEnterHandler( this: HTMLAnchorElement, { clientX, clientY }: { clientX: number; clientY: number }, @@ -45,7 +38,7 @@ async function mouseEnterHandler( } } - notifyNav(link.getAttribute("href") as FullSlug) + dispatchRenderEvent(popoverInner) } const targetUrl = new URL(link.href) @@ -129,8 +122,9 @@ function clearActivePopover() { allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover")) } -document.addEventListener("nav", () => { - const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[] +addRenderListener((element) => { + const links = [...element.querySelectorAll("a.internal")] as HTMLAnchorElement[] + for (const link of links) { link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseleave", clearActivePopover) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 3183f47b9..38f36de32 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,5 +1,5 @@ import FlexSearch from "flexsearch" -import { registerEscapeHandler, removeAllChildren } from "./util" +import { registerEscapeHandler, removeAllChildren, dispatchRenderEvent } from "./util" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" import { contentDecryptedEventListener, decryptContent } from "../../util/encryption" @@ -143,13 +143,6 @@ function highlightHTML(searchTerm: string, el: HTMLElement) { return html.body } -function notifyNav(url: FullSlug) { - const event: CustomEventMap["nav"] = new CustomEvent("nav", { - detail: { url, rerender: true }, - }) - document.dispatchEvent(event) -} - async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) { const container = searchElement.querySelector(".search-container") as HTMLElement if (!container) return @@ -274,10 +267,8 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: const slug = idDataMap[id] let title = data[slug].title - if (data[slug].decrypted === false) { - title = "🔒 " + title - } else if (data[slug].decrypted === true) { - title = "🔓 " + title + if (data[slug].encryptionResult) { + title = (data[slug].decrypted ? "🔓 " : "🔒 ") + data[slug].title } return { @@ -406,7 +397,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: highlights[0]?.scrollIntoView({ block: "start" }) await new Promise((resolve) => setTimeout(resolve, 100)) - notifyNav(slug) + dispatchRenderEvent(previewInner) } async function onType(e: HTMLElementEventMap["input"]) { @@ -544,9 +535,6 @@ async function fillDocument(data: ContentIndex) { } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { - // Ignore rerender events - if (e.detail.rerender) return - const currentSlug = e.detail.url const data = await fetchData const searchElement = document.getElementsByClassName("search") diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 22fcd72b4..230aa874c 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,6 +1,6 @@ import micromorph from "micromorph" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, dispatchRenderEvent } from "./util" // adapted from `micromorph` // https://github.com/natemoo-re/micromorph @@ -38,6 +38,9 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined function notifyNav(url: FullSlug) { const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) document.dispatchEvent(event) + + // Also trigger render event for the whole document body + dispatchRenderEvent(document.body) } const cleanupFns: Set<(...args: any[]) => void> = new Set() diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts index 4148fa235..1741d17e8 100644 --- a/quartz/components/scripts/toc.inline.ts +++ b/quartz/components/scripts/toc.inline.ts @@ -1,3 +1,5 @@ +import { addRenderListener } from "./util" + const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const slug = entry.target.id @@ -34,11 +36,11 @@ function setupToc() { } } -document.addEventListener("nav", () => { +addRenderListener((container: HTMLElement) => { setupToc() // update toc entry highlighting observer.disconnect() - const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") + const headers = container.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") headers.forEach((header) => observer.observe(header)) }) diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index f71790104..8068e7d94 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -44,3 +44,16 @@ export async function fetchCanonical(url: URL): Promise { const [_, redirect] = text.match(canonicalRegex) ?? [] return redirect ? fetch(`${new URL(redirect, url)}`) : res } + +export function addRenderListener(renderFn: (container: HTMLElement) => void) { + document.addEventListener("render", (e: CustomEventMap["render"]) => { + renderFn(e.detail.htmlElement) + }) +} + +export function dispatchRenderEvent(htmlElement: HTMLElement) { + const event: CustomEventMap["render"] = new CustomEvent("render", { + detail: { htmlElement }, + }) + document.dispatchEvent(event) +} diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index d7327351f..0fb77d756 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -6,10 +6,11 @@ justify-content: center; .encryption-notice { - background: color-mix(in srgb, var(--lightgray) 60%, var(--light)); + /* background: color-mix(in srgb, var(--lightgray) 60%, var(--light)); + */ border: 1px solid var(--lightgray); + padding: 2rem 1.5rem 2rem; border-radius: 5px; - padding: 2rem 1.5rem; max-width: 450px; width: 100%; text-align: center; @@ -29,10 +30,12 @@ color: var(--darkgray); line-height: 1.5; font-size: 0.95rem; - margin: 0 0 1rem 0; + margin: 0; + font-style: italic; } .decrypt-form { + margin: 1rem 0 0 0; display: flex; flex-direction: column; gap: 1rem; @@ -88,16 +91,6 @@ font-size: 16px; // Prevent zoom on iOS } } - - .encrypted-message-footnote { - color: var(--gray); - font-size: 0.85rem; - opacity: 0.8; - text-align: center; - width: 100%; - font-style: italic; - margin: 0; - } } .decrypt-loading { @@ -144,12 +137,18 @@ } // Hide the decrypt form when encrypted content appears in popover and search -.popover .encrypted-content .encryption-notice .decrypt-form { - display: none; -} +.search-space, .popover { + .encrypted-content .encryption-notice .decrypt-form { + display: none; + } -.search-space .encrypted-content .encryption-notice .decrypt-form { - display: none; + .encryption-notice { + /* + border: 0; + padding: 0; + */ + margin: 1rem 0; + } } .decrypted-content-wrapper { diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 61e2ba858..a42a36c87 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -211,6 +211,8 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso window.addCleanup = () => {} const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) document.dispatchEvent(event) + const renderEvent = new CustomEvent("render", { detail: { htmlElement: document.body } }) + document.dispatchEvent(renderEvent) `) } } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index 647b1d748..a52395d9c 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -117,6 +117,7 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => `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 From 689b54a9456a54b4ee39001e2c8670dd095c7341 Mon Sep 17 00:00:00 2001 From: arg3t Date: Thu, 31 Jul 2025 14:53:50 +0200 Subject: [PATCH 14/18] Fix type errors and prettier --- docs/advanced/creating components.md | 2 +- docs/advanced/event system.md | 22 ++++++++++++-------- quartz/components/scripts/explorer.inline.ts | 6 ------ quartz/components/scripts/graph.inline.ts | 3 --- quartz/components/scripts/popover.inline.ts | 2 +- quartz/components/scripts/spa.inline.ts | 2 +- quartz/components/styles/encrypt.scss | 3 ++- 7 files changed, 18 insertions(+), 22 deletions(-) diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md index 885acc48d..8e0a0a48b 100644 --- a/docs/advanced/creating components.md +++ b/docs/advanced/creating components.md @@ -169,7 +169,7 @@ document.addEventListener("render", (e) => { // runs when content is rendered or re-rendered // e.detail.htmlElement contains the DOM element that was updated const container = e.detail.htmlElement - + // attach event listeners to elements within this container const toggleSwitch = container.querySelector("#switch") as HTMLInputElement if (toggleSwitch) { diff --git a/docs/advanced/event system.md b/docs/advanced/event system.md index d0f3543bc..d95097a7a 100644 --- a/docs/advanced/event system.md +++ b/docs/advanced/event system.md @@ -15,16 +15,17 @@ document.addEventListener("nav", (e: CustomEventMap["nav"]) => { // Access the current page URL const currentUrl = e.detail.url console.log(`User navigated to: ${currentUrl}`) - + // Good for: // - Analytics tracking - // - URL-dependent state updates + // - URL-dependent state updates // - Setting up page-level event handlers // - Theme/mode initialization }) ``` **When it fires:** + - On initial page load - On client-side navigation (if SPA routing is enabled) - Does NOT fire on content re-renders @@ -37,11 +38,11 @@ The `render` event is fired when content needs to be processed or updated. This document.addEventListener("render", (e: CustomEventMap["render"]) => { // Access the container that was updated const container = e.detail.htmlElement - + // Process elements within this container const codeBlocks = container.querySelectorAll("pre code") codeBlocks.forEach(addSyntaxHighlighting) - + // Good for: // - Setting up event listeners on new content // - Processing dynamic content (syntax highlighting, math rendering, etc.) @@ -50,6 +51,7 @@ document.addEventListener("render", (e: CustomEventMap["render"]) => { ``` **When it fires:** + - On initial page load (with `document.body` as the container) - When popover content is loaded - When search results are displayed @@ -106,10 +108,12 @@ Always clean up event handlers to prevent memory leaks: ```ts addRenderListener((container) => { const buttons = container.querySelectorAll(".my-button") - - const handleClick = (e) => { /* ... */ } - - buttons.forEach(button => { + + const handleClick = (e) => { + /* ... */ + } + + buttons.forEach((button) => { button.addEventListener("click", handleClick) // Clean up when navigating away window.addCleanup(() => { @@ -168,4 +172,4 @@ addRenderListener((container) => { }) ``` -This provides cleaner separation of concerns and better performance. \ No newline at end of file +This provides cleaner separation of concerns and better performance. diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 6361196f3..fbae65e80 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -291,12 +291,6 @@ document.addEventListener("prenav", async () => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const currentSlug = e.detail.url - const rerender = e.detail.rerender - - // If this is secondary nav call, do not populate explorer again - if (rerender) { - return - } await setupExplorer(currentSlug) diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 2f75fa218..11f7365dd 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -577,9 +577,6 @@ function cleanupGlobalGraphs() { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const slug = e.detail.url - // if we are rerendering, we can ignore this event - if (e.detail.rerender) return - addToVisited(simplifySlug(slug)) async function renderLocalGraph() { diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index e5576758c..32757041d 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,5 +1,5 @@ import { computePosition, flip, inline, shift } from "@floating-ui/dom" -import { FullSlug, normalizeRelativeURLs } from "../../util/path" +import { normalizeRelativeURLs } from "../../util/path" import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util" const p = new DOMParser() diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 230aa874c..e7c4c46f1 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -38,7 +38,7 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined function notifyNav(url: FullSlug) { const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) document.dispatchEvent(event) - + // Also trigger render event for the whole document body dispatchRenderEvent(document.body) } diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index 0fb77d756..dd6e5ba3f 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -137,7 +137,8 @@ } // Hide the decrypt form when encrypted content appears in popover and search -.search-space, .popover { +.search-space, +.popover { .encrypted-content .encryption-notice .decrypt-form { display: none; } From 78d4fb5faad39c250248be1b7e37062c8bbae4bb Mon Sep 17 00:00:00 2001 From: arg3t Date: Thu, 31 Jul 2025 15:02:30 +0200 Subject: [PATCH 15/18] Disable ToC for encrypted notes --- quartz/components/TableOfContents.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index f3dc9050f..8ed3f9f3f 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -25,7 +25,7 @@ export default ((opts?: Partial) => { displayClass, cfg, }: QuartzComponentProps) => { - if (!fileData.toc) { + if (!fileData.toc || fileData.encryptionResult) { return null } @@ -54,13 +54,14 @@ export default ((opts?: Partial) => { - {fileData.toc.map((tocEntry) => ( -
  • - - {tocEntry.text} - -
  • - ))} + {!fileData.encryptionResult && + fileData.toc.map((tocEntry) => ( +
  • + + {tocEntry.text} + +
  • + ))}
    ) From 127e5c3c031252e56ab69189580b13136096c617 Mon Sep 17 00:00:00 2001 From: arg3t Date: Thu, 31 Jul 2025 18:10:44 +0200 Subject: [PATCH 16/18] Clean up the plugin configuration types and make fields nullable --- docs/plugins/Encrypt.md | 134 ++++------ index.d.ts | 1 + quartz/components/Explorer.tsx | 2 - quartz/components/TableOfContents.tsx | 17 +- quartz/components/scripts/encrypt.inline.ts | 19 +- quartz/components/scripts/explorer.inline.ts | 36 ++- quartz/components/styles/encrypt.scss | 2 +- quartz/plugins/emitters/contentIndex.tsx | 4 +- quartz/plugins/transformers/encrypt.ts | 257 +++++++++++++++---- quartz/plugins/transformers/frontmatter.ts | 4 +- quartz/util/encryption.ts | 147 ++++------- 11 files changed, 357 insertions(+), 266 deletions(-) diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index 052076186..cf966ae0f 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -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) diff --git a/index.d.ts b/index.d.ts index 03cff3763..62dcdb2b2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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" }> } diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 3827587d7..f28ccda3b 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -121,7 +121,6 @@ export default ((userOpts?: Partial) => {
    @@ -144,7 +143,6 @@ export default ((userOpts?: Partial) => {
    diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index 8ed3f9f3f..d6329f221 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -54,14 +54,13 @@ export default ((opts?: Partial) => { - {!fileData.encryptionResult && - fileData.toc.map((tocEntry) => ( -
  • - - {tocEntry.text} - -
  • - ))} + {fileData.toc.map((tocEntry) => ( +
  • + + {tocEntry.text} + +
  • + ))}
    ) @@ -71,7 +70,7 @@ export default ((opts?: Partial) => { TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded) const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { - if (!fileData.toc) { + if (!fileData.toc || fileData.encryptionResult) { return null } return ( diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index 495ed0ba3..aec95793e 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -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 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 => { 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) } } diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index fbae65e80..b9bbf42b4 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -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 diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index dd6e5ba3f..5ce5b99a8 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -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; diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index eda0c322f..c4234dc2e 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -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 @@ -22,7 +22,7 @@ export type ContentDetails = { richContent?: string date?: Date description?: string - encryptionConfig?: EncryptionConfig + encryptionConfig?: CompleteCryptoConfig hash?: Hash encryptionResult?: EncryptionResult } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index a52395d9c..5470f8325 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -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 } -export const Encrypt: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } +// User-provided options (all optional) +export type PluginOptions = Partial - // 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, + 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, +): 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 = (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> = (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( `
    @@ -149,7 +308,6 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => // Replace the original tree tree.children = encryptedTree.children - return tree } }, @@ -170,12 +328,15 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => inline: true, }, ], - additionalHead: [], } }, } } +// ============================================================================= +// MODULE AUGMENTATION +// ============================================================================= + declare module "vfile" { interface DataMap { encryptionConfig: EncryptionConfig diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 0979385d3..b06fffd20 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -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 }> } } diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts index feef85f29..90bb2c093 100644 --- a/quartz/util/encryption.ts +++ b/quartz/util/encryption.ts @@ -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 { checkCryptoSupport() @@ -290,7 +289,7 @@ export async function encryptContent( export async function decryptContent( encrypted: EncryptionResult, - config: EncryptionConfig, + config: CompleteCryptoConfig, password: string, ): Promise { 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 | null = null, ): Promise { 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 = async () => { - const password = await searchForValidPassword(filePath, hash, config) - if (password) { - callback(password) - return true + const blacklist = new Set() + + 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) } From b4494595d6c8568b8019fccc7040fcf934965059 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Tue, 21 Oct 2025 19:25:10 +0200 Subject: [PATCH 17/18] Address comments from Copilot --- docs/plugins/Encrypt.md | 8 ++-- quartz/components/scripts/encrypt.inline.ts | 2 +- quartz/components/scripts/search.inline.ts | 3 +- quartz/plugins/transformers/description.ts | 2 +- quartz/plugins/transformers/encrypt.ts | 19 ++++++---- quartz/util/encryption.ts | 42 ++++----------------- 6 files changed, 27 insertions(+), 49 deletions(-) diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index cf966ae0f..276a588c1 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -47,10 +47,10 @@ Plugin.Encrypt({ - `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) -- `ttl`: Time-to-live for cached passwords in seconds (default: 7 days) -- `message`: Default message shown on encrypted pages -- `encryptedFolders`: Configure folder-level encryption + - 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 ## How Configuration Works diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index aec95793e..c61f178f0 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -98,6 +98,7 @@ const decryptWithPassword = async ( if (showError) throw new Error("decryption-failed") return false } catch (decryptError) { + console.error("Decryption failed:", decryptError) if (showError) showLoading(container, false) if (showError) throw new Error("decryption-failed") return false @@ -136,7 +137,6 @@ const decryptWithPassword = async ( } function updateTitle(container: HTMLElement | null) { - console.log(container) if (container) { const span = container.querySelector(".article-title-icon") as HTMLElement if (span) { diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 38f36de32..c743f9bf2 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -44,6 +44,7 @@ const fetchContentCache: Map = new Map() const contextWindowWords = 30 const numSearchResults = 8 const numTagResults = 5 +const RENDER_DELAY_MS = 100 const tokenizeTerm = (term: string) => { const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") @@ -395,7 +396,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: (a, b) => b.innerHTML.length - a.innerHTML.length, ) highlights[0]?.scrollIntoView({ block: "start" }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, RENDER_DELAY_MS)) dispatchRenderEvent(previewInner) } diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 9036436b9..cf7a20360 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -29,7 +29,7 @@ export const Description: QuartzTransformerPlugin> = (userOpts) return [ () => { return async (tree: HTMLRoot, file) => { - if (file.data?.encrypted && file.data.encryptionConfig) { + if (file.data.encryptionConfig) { file.data.description = file.data.encryptionConfig.message || i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index 5470f8325..a3541ecb1 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -143,16 +143,22 @@ function getConfigurationForPath( /** * Validates the plugin configuration */ -function validateConfig(config: PluginConfig): void { +function validateConfig(config: EncryptionConfig, file: VFile | null = null): void { + let suffixedPath = "" + + if (file && file.data && file.data.relativePath) { + suffixedPath = `(in file: ${file.data.relativePath})` + } + if (!SUPPORTED_ALGORITHMS.includes(config.algorithm)) { throw new Error( `[EncryptPlugin] Unsupported encryption algorithm: ${config.algorithm}. ` + - `Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, + `Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")} ${suffixedPath}`, ) } - if (config.ttl <= 0) { - throw new Error(`[EncryptPlugin] TTL must be a positive number`) + if (config.ttl < 0) { + throw new Error(`[EncryptPlugin] TTL cannot be negative. ${suffixedPath}`) } } @@ -168,9 +174,6 @@ export const Encrypt: QuartzTransformerPlugin = (userOpts) => { encryptedFolders: userOpts?.encryptedFolders ?? {}, } - // Validate configuration - validateConfig(pluginConfig) - // Pre-process folder configurations for efficient lookup const folderConfigs = createFolderConfigHierarchy(pluginConfig.encryptedFolders, pluginConfig) @@ -241,6 +244,8 @@ export const Encrypt: QuartzTransformerPlugin = (userOpts) => { if (!config) { return } + // Validate configuration + validateConfig(config, file) file.data.encryptionConfig = config file.data.hash = await hashString(config.password) diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts index 90bb2c093..c09d06210 100644 --- a/quartz/util/encryption.ts +++ b/quartz/util/encryption.ts @@ -1,7 +1,4 @@ -// ============================================================================= -// TYPES AND INTERFACES -// ============================================================================= -export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const +export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm"] as const export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] @@ -88,7 +85,7 @@ export function base64Encode(data: string): string { return Buffer.from(data).toString("base64") } else { // Browser environment - return btoa(unescape(encodeURIComponent(data))) + return btoa(encodeURIComponent(data)) } } @@ -98,16 +95,17 @@ export function base64Decode(data: string): string { return Buffer.from(data, "base64").toString() } else { // Browser environment - return decodeURIComponent(escape(atob(data))) + return decodeURIComponent(atob(data)) } } // Utility functions for array buffer conversions export function hexToArrayBuffer(hex: string): ArrayBuffer { if (!hex) return new ArrayBuffer(0) + const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substr(i * 2, 2), 16) + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16) } return bytes.buffer } @@ -209,8 +207,7 @@ export async function encryptContent( } // Generate random salt for encryption - const initializationVector = - algorithm === "aes-256-ecb" ? new Uint8Array(16) : crypto.getRandomValues(new Uint8Array(16)) // Zero IV for ECB simulation + const initializationVector = crypto.getRandomValues(new Uint8Array(16)) // Create encryption hash and derive key const encryptionHashData = await hashString(password) @@ -251,16 +248,6 @@ export async function encryptContent( key, contentBuffer, ) - } else if (algorithm === "aes-256-ecb") { - // ECB simulation using CBC with zero IV - encryptedBuffer = await crypto.subtle.encrypt( - { - name: "AES-CBC", - iv: initializationVector, // Zero IV for ECB simulation - }, - key, - contentBuffer, - ) } else { throw new Error("Unsupported algorithm: " + algorithm) } @@ -274,10 +261,7 @@ export async function encryptContent( const result: EncryptionResult = { encryptedContent: arrayBufferToHex(encryptedBuffer), encryptionSalt: encryptionHashData.salt, - } - - if (algorithm !== "aes-256-ecb") { - result.iv = arrayBufferToHex(initializationVector.buffer) + iv: arrayBufferToHex(initializationVector.buffer as ArrayBuffer), } if (authTag) { @@ -339,18 +323,6 @@ export async function decryptContent( key, ciphertext, ) - } else if (config.algorithm === "aes-256-ecb") { - // ECB simulation using CBC with zero IV - const zeroInitializationVector = new ArrayBuffer(16) - - decryptedBuffer = await crypto.subtle.decrypt( - { - name: "AES-CBC", - iv: zeroInitializationVector, - }, - key, - ciphertext, - ) } else { throw new Error("Unsupported algorithm: " + config.algorithm) } From 5afcb8cc97a11dd6381ca672de02ab89c8ad24f7 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Tue, 21 Oct 2025 19:57:54 +0200 Subject: [PATCH 18/18] Address copilot fixes - Fix search for encrypted pages - Fix TTL mismatch when searching for matching passwords --- docs/plugins/Frontmatter.md | 6 ++---- quartz/components/scripts/search.inline.ts | 11 +++++------ quartz/plugins/emitters/contentIndex.tsx | 2 +- quartz/util/encryption.ts | 4 ++-- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/plugins/Frontmatter.md b/docs/plugins/Frontmatter.md index 16b1d4cd0..5fc293362 100644 --- a/docs/plugins/Frontmatter.md +++ b/docs/plugins/Frontmatter.md @@ -67,10 +67,8 @@ Quartz supports the following frontmatter: - encrypt - `encrypt` - `encrypted` -- encryptMessage - - `encrypt_message` - - `encryptMessage` - - `encrypt-message` +- encryptConfig + - Overrides for the [[plugins/Encrypt|encryptConfig]] - password - `password` diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index e966bf4bc..f7dfc6610 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -488,10 +488,10 @@ async function fillDocument(data: ContentIndex) { const promises: Array> = [] for (const [slug, fileData] of Object.entries(data)) { if (fileData.encryptionResult) { - const slugId = id + let slugId = id promises.push( - index.addAsync(id, { - id: id++, + index.addAsync(id++, { + id: id, slug: slug as FullSlug, title: fileData.title, content: "", @@ -511,11 +511,10 @@ async function fillDocument(data: ContentIndex) { fileData.encryptionConfig!, password, ) - fileData.decrypted = true - index.updateAsync(slugId, { - id, + index.update(slugId++, { + id: slugId, slug: slug as FullSlug, title: fileData.title, content: decryptedContent, diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index c4234dc2e..c8d43152b 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -117,7 +117,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", richContent: - !file.data.encryptionResult && opts?.rssFullHtml + opts?.rssFullHtml && !file.data.encryptionResult ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) : undefined, date: date, diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts index c09d06210..cfcce3dcc 100644 --- a/quartz/util/encryption.ts +++ b/quartz/util/encryption.ts @@ -429,7 +429,7 @@ export async function addPasswordToCache( cache[filePath] = { password, - ttl: ttl <= 0 ? 0 : now + ttl, + ttl: ttl == 0 ? 0 : now + ttl, } savePasswordCache(cache) @@ -449,7 +449,7 @@ export function getRelevantPasswords(filePath: string): string[] { } }) - if (cache[filePath] && cache[filePath].ttl > now) { + if (cache[filePath] && (cache[filePath].ttl > now || cache[filePath].ttl === 0)) { // If the exact file path is cached, return its password return [cache[filePath].password] }