Tried to address the comments

This commit is contained in:
Yigit Colakoglu 2025-07-31 06:52:55 +02:00
parent ff83a93588
commit 4e02f7810e
18 changed files with 1174 additions and 584 deletions

View File

@ -2,9 +2,9 @@
title: "Encrypt" title: "Encrypt"
tags: tags:
- plugin/transformer - plugin/transformer
encrypt: true encryptConfig:
encrypt_message: '^ Password is "quartz"' password: "quartz"
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. 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 ```typescript
Plugin.Encrypt({ Plugin.Encrypt({
algorithm: "aes-256-cbc", // Encryption algorithm algorithm: "aes-256-cbc", // Encryption algorithm (key length auto-inferred)
keyLength: 32, // Key length in bytes
iterations: 100000, // PBKDF2 iterations
encryptedFolders: { encryptedFolders: {
// Folder-level encryption // Folder-level encryption with simple passwords
"private/": "folder-password", "private/": "folder-password",
"work/confidential/": "work-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) 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 ### Configuration Options
- `algorithm`: Encryption algorithm to use. Supported values: - `algorithm`: Encryption algorithm to use. Supported values:
- `"aes-256-cbc"` (default): AES-256 in CBC mode - `"aes-256-cbc"` (default): AES-256 in CBC mode
- `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption) - `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption)
- `"aes-256-ecb"`: AES-256 in ECB mode (not recommended for security) - `"aes-256-ecb"`: AES-256 in ECB mode (not recommended for security)
- `keyLength`: Key length in bytes (default: 32 for AES-256) - Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes)
- `iterations`: Number of PBKDF2 iterations for key derivation (default: 100000) - `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption
- `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) - `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 ## Usage
@ -59,34 +85,43 @@ All pages within these folders will be encrypted with the specified password. Ne
### Page-level Encryption ### Page-level Encryption
Use frontmatter to encrypt individual pages or override folder passwords: Use frontmatter to encrypt individual pages or override folder passwords.
```yaml ```yaml
--- ---
title: "My Secret Page" title: "My Secret Page"
encrypt: true
password: "page-specific-password" 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. This content will be encrypted and require a password to view.
``` ```
### Frontmatter Fields ### 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 - `encrypt`: Set to `true` to enable encryption for this page
- `password`: The password required to decrypt 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 ### Security Considerations
- Use strong passwords for sensitive content - Use strong passwords for sensitive content
- Consider using AES-256-GCM mode for authenticated encryption - 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 - 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 ## Security Features
@ -103,7 +138,6 @@ The plugin implements intelligent password caching:
- **Full Content Encryption**: The entire HTML content is encrypted, not just hidden - **Full Content Encryption**: The entire HTML content is encrypted, not just hidden
- **SEO Protection**: Search engines and RSS feeds see generic placeholder descriptions - **SEO Protection**: Search engines and RSS feeds see generic placeholder descriptions
- **Client-side Decryption**: Content is never transmitted in plain text - **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 - **Password Verification**: Fast password hash verification before attempting decryption
## API ## API

8
index.d.ts vendored
View File

@ -6,10 +6,14 @@ declare module "*.scss" {
// dom custom event // dom custom event
interface CustomEventMap { interface CustomEventMap {
prenav: CustomEvent<{}> prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }> nav: CustomEvent<{ url: FullSlug; rerender?: boolean }>
themechange: CustomEvent<{ theme: "light" | "dark" }> themechange: CustomEvent<{ theme: "light" | "dark" }>
readermodechange: CustomEvent<{ mode: "on" | "off" }> readermodechange: CustomEvent<{ mode: "on" | "off" }>
} }
type ContentIndex = Record<FullSlug, ContentDetails> type DecryptedFlag = {
decrypted?: boolean
}
type ContentIndex = Record<FullSlug, ContentDetails & DecryptedFlag>
declare const fetchData: Promise<ContentIndex> declare const fetchData: Promise<ContentIndex>

View File

@ -71,10 +71,8 @@ const config: QuartzConfig = {
Plugin.TableOfContents(), Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Latex({ renderEngine: "katex" }), Plugin.Latex({ renderEngine: "katex" }),
Plugin.EncryptPlugin({ Plugin.Encrypt({
algorithm: "aes-256-cbc", algorithm: "aes-256-cbc",
keyLength: 32,
iterations: 100000,
encryptedFolders: {}, encryptedFolders: {},
ttl: 3600 * 24 * 7, // A week ttl: 3600 * 24 * 7, // A week
}), }),

View File

@ -4,7 +4,12 @@ import { classNames } from "../util/lang"
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
const title = fileData.frontmatter?.title const title = fileData.frontmatter?.title
if (title) { if (title) {
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1> return (
<h1 class={classNames(displayClass, "article-title")}>
{fileData.encryptionResult && <span className="article-title-icon">🔒 </span>}
{title}
</h1>
)
} else { } else {
return null return null
} }

View File

@ -30,7 +30,7 @@ const defaultOptions: Options = {
return node return node
}, },
sortFn: (a, b) => { 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)) { if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // 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 // 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<Options>) => {
</div> </div>
<template id="template-file"> <template id="template-file">
<li> <li>
<span class="file-title"></span>
<a href="#"></a> <a href="#"></a>
</li> </li>
</template> </template>
@ -143,7 +144,8 @@ export default ((userOpts?: Partial<Options>) => {
</svg> </svg>
<div> <div>
<button class="folder-button"> <button class="folder-button">
<span class="folder-title"></span> <span class="folder-title folder-title-icon"></span>
<span class="folder-title folder-title-text"></span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,222 +1,15 @@
// Password cache management import {
const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords" decryptContent,
verifyPasswordHash,
addPasswordToCache,
Hash,
EncryptionConfig,
searchForValidPassword,
EncryptionResult,
} from "../../util/encryption"
import { FullSlug, getFullSlug } from "../../util/path"
function getPasswordCache(): Record<string, { password: string; ttl: number }> { const showLoading = (container: Element, show: boolean) => {
try {
const cache = localStorage.getItem(PASSWORD_CACHE_KEY)
return cache ? JSON.parse(cache) : {}
} catch {
return {}
}
}
function savePasswordCache(cache: Record<string, { password: string; ttl: number }>) {
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<boolean> {
// 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<string> {
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 loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement
const form = container.querySelector(".decrypt-form") 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, container: Element,
password: string, password: string,
showError = true, showError = true,
): Promise<boolean> { ): Promise<boolean> => {
const errorDiv = container.querySelector(".decrypt-error") as HTMLElement const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement>
const encryptedData = (container as HTMLElement).dataset.encrypted! const containerElement = container as HTMLElement
const config = JSON.parse((container as HTMLElement).dataset.config!)
const i18n = JSON.parse((container as HTMLElement).dataset.i18n!)
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 { 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 // First verify password hash
const isValidPassword = await verifyPassword(password, parsed) let isValidPassword: boolean
isValidPassword = await verifyPasswordHash(password, hash)
if (!isValidPassword) { if (!isValidPassword) {
if (showError) throw new Error(i18n.incorrectPassword) if (showError) throw new Error("incorrect-password")
return false return false
} }
@ -262,13 +64,14 @@ async function decryptWithPassword(
} }
try { try {
// If hash matches, decrypt content let decryptedContent: string
const decryptedContent = await performDecryption(password, parsed, config)
decryptedContent = await decryptContent(encrypted, config, password)
if (decryptedContent) { if (decryptedContent) {
// Cache the password // Cache the password
const filePath = window.location.pathname const filePath = getFullSlug(window)
addPasswordToCache(password, filePath, config.ttl) await addPasswordToCache(password, filePath, config.ttl)
// Replace content // Replace content
const contentWrapper = document.createElement("div") const contentWrapper = document.createElement("div")
@ -278,41 +81,100 @@ async function decryptWithPassword(
return true return true
} }
if (showError) throw new Error(i18n.decryptionFailed) if (showError) throw new Error("decryption-failed")
return false return false
} catch (decryptError) { } catch (decryptError) {
if (showError) showLoading(container, false) if (showError) showLoading(container, false)
if (showError) throw new Error(i18n.decryptionFailed) if (showError) throw new Error("decryption-failed")
return false return false
} }
} catch (error) { } catch (error) {
if (showError) { if (showError) {
showLoading(container, false) showLoading(container, false)
errorDiv.style.display = "block" const errorMessage = error instanceof Error ? error.message : null
errorDiv.textContent = error instanceof Error ? error.message : "Decryption failed"
errorDivs.forEach((div) => {
if (div.dataset.error == errorMessage) {
div.style.display = "block"
}
})
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement 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) { if (passwordInput) {
passwordInput.value = "" 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 return false
} }
} }
async function tryAutoDecrypt(container: Element): Promise<boolean> { const notifyNav = (url: FullSlug) => {
const filePath = window.location.pathname const event: CustomEventMap["nav"] = new CustomEvent("nav", {
const passwords = getRelevantPasswords(filePath) detail: { url, rerender: true },
})
document.dispatchEvent(event)
}
for (const password of passwords) { const getFirstParentWithClass = (container: HTMLElement, className: string): HTMLElement | null => {
if (await decryptWithPassword(container, password, false)) { let current = container.parentElement
return true 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<boolean> => {
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 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 passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
const password = passwordInput.value const password = passwordInput.value
@ -321,15 +183,21 @@ async function manualDecrypt(container: Element) {
return return
} }
await decryptWithPassword(container, password, true) if (await decryptWithPassword(container, password, true)) {
const filePath = getFullSlug(window)
notifyNav(filePath)
updateTitle(parent)
}
} }
document.addEventListener("nav", async () => { document.addEventListener("nav", async () => {
// Try auto-decryption for all encrypted content // Try auto-decryption for all encrypted content
const encryptedElements = document.querySelectorAll(".encrypted-content") const encryptedElements = document.querySelectorAll(
".encrypted-content",
) as NodeListOf<HTMLElement>
for (const container of encryptedElements) { for (const encryptedContainer of encryptedElements) {
await tryAutoDecrypt(container) await tryAutoDecrypt(encryptedContainer)
} }
// Manual decryption handlers // Manual decryption handlers
@ -337,12 +205,15 @@ document.addEventListener("nav", async () => {
buttons.forEach((button) => { buttons.forEach((button) => {
const handleClick = async function (this: HTMLElement) { const handleClick = async function (this: HTMLElement) {
const container = this.closest(".encrypted-content")! const encryptedContainer = this.closest(".encrypted-content")!
await manualDecrypt(container) await manualDecrypt(encryptedContainer as HTMLElement)
} }
button.addEventListener("click", handleClick) 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 // Enter key handler
@ -350,12 +221,15 @@ document.addEventListener("nav", async () => {
const handleKeypress = async function (this: HTMLInputElement, e: Event) { const handleKeypress = async function (this: HTMLInputElement, e: Event) {
const keyEvent = e as KeyboardEvent const keyEvent = e as KeyboardEvent
if (keyEvent.key === "Enter") { if (keyEvent.key === "Enter") {
const container = this.closest(".encrypted-content")! const encryptedContainer = this.closest(".encrypted-content")!
await manualDecrypt(container) await manualDecrypt(encryptedContainer as HTMLElement)
} }
} }
input.addEventListener("keypress", handleKeypress) 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))
}
}) })
}) })

View File

@ -1,6 +1,7 @@
import { FileTrieNode } from "../../util/fileTrie" import { FileTrieNode } from "../../util/fileTrie"
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { contentDecryptedEventListener } from "../../util/encryption"
type MaybeHTMLElement = HTMLElement | undefined type MaybeHTMLElement = HTMLElement | undefined
@ -88,6 +89,18 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
a.dataset.for = node.slug a.dataset.for = node.slug
a.textContent = node.displayName 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) { if (currentSlug === node.slug) {
a.classList.add("active") a.classList.add("active")
} }
@ -111,6 +124,15 @@ function createFolderNode(
const folderPath = node.slug const folderPath = node.slug
folderContainer.dataset.folderpath = folderPath 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") { if (opts.folderClickBehavior === "link") {
// Replace button with link for link behavior // Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement const button = titleContainer.querySelector(".folder-button") as HTMLElement
@ -120,8 +142,9 @@ function createFolderNode(
a.className = "folder-title" a.className = "folder-title"
a.textContent = node.displayName a.textContent = node.displayName
button.replaceWith(a) button.replaceWith(a)
titleContainer.insertBefore(span, a)
} else { } else {
const span = titleContainer.querySelector(".folder-title") as HTMLElement const span = titleContainer.querySelector(".folder-title-text") as HTMLElement
span.textContent = node.displayName span.textContent = node.displayName
} }
@ -173,6 +196,7 @@ async function setupExplorer(currentSlug: FullSlug) {
) )
const data = await fetchData const data = await fetchData
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][] const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
const trie = FileTrieNode.fromEntries(entries) const trie = FileTrieNode.fromEntries(entries)
@ -267,6 +291,13 @@ document.addEventListener("prenav", async () => {
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url 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) await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default // if mobile hamburger is visible, collapse by default

View File

@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
v, v,
]), ]),
) )
const links: SimpleLinkData[] = [] const links: SimpleLinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(data.keys()) const validLinks = new Set(data.keys())
@ -575,6 +576,10 @@ function cleanupGlobalGraphs() {
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url const slug = e.detail.url
// if we are rerendering, we can ignore this event
if (e.detail.rerender) return
addToVisited(simplifySlug(slug)) addToVisited(simplifySlug(slug))
async function renderLocalGraph() { async function renderLocalGraph() {

View File

@ -143,9 +143,11 @@ const cssVars = [
] as const ] as const
let mermaidImport = undefined let mermaidImport = undefined
document.addEventListener("nav", async () => { document.addEventListener("nav", async () => {
const center = document.querySelector(".center") as HTMLElement const nodes = document.querySelectorAll(
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement> "code.mermaid:not([data-processed])",
) as NodeListOf<HTMLElement>
if (nodes.length === 0) return if (nodes.length === 0) return
mermaidImport ||= await import( mermaidImport ||= await import(
@ -162,9 +164,9 @@ document.addEventListener("nav", async () => {
async function renderMermaid() { async function renderMermaid() {
// de-init any other diagrams // de-init any other diagrams
for (const node of nodes) { for (const node of nodes) {
node.removeAttribute("data-processed")
const oldText = textMapping.get(node) const oldText = textMapping.get(node)
if (oldText) { if (oldText) {
node.removeAttribute("data-processed")
node.innerHTML = oldText node.innerHTML = oldText
} }
} }

View File

@ -1,10 +1,17 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path" import { FullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util" import { fetchCanonical } from "./util"
const p = new DOMParser() const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null 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( async function mouseEnterHandler(
this: HTMLAnchorElement, this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
@ -37,6 +44,8 @@ async function mouseEnterHandler(
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
} }
} }
notifyNav(link.getAttribute("href") as FullSlug)
} }
const targetUrl = new URL(link.href) const targetUrl = new URL(link.href)

View File

@ -1,7 +1,7 @@
import FlexSearch from "flexsearch" import FlexSearch from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
import { contentDecryptedEventListener, decryptContent } from "../../util/encryption"
interface Item { interface Item {
id: number id: number
@ -143,6 +143,13 @@ function highlightHTML(searchTerm: string, el: HTMLElement) {
return html.body 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) { async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
const container = searchElement.querySelector(".search-container") as HTMLElement const container = searchElement.querySelector(".search-container") as HTMLElement
if (!container) return if (!container) return
@ -265,10 +272,18 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
const formatForDisplay = (term: string, id: number) => { const formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id] 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 { return {
id, id,
slug, 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), content: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term.substring(1), data[slug].tags), 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, (a, b) => b.innerHTML.length - a.innerHTML.length,
) )
highlights[0]?.scrollIntoView({ block: "start" }) highlights[0]?.scrollIntoView({ block: "start" })
await new Promise((resolve) => setTimeout(resolve, 100))
notifyNav(slug)
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
@ -470,16 +488,55 @@ async function fillDocument(data: ContentIndex) {
if (indexPopulated) return if (indexPopulated) return
let id = 0 let id = 0
const promises: Array<Promise<unknown>> = [] const promises: Array<Promise<unknown>> = []
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries(data)) {
promises.push( if (fileData.encryptionResult) {
index.addAsync(id++, { const slugId = id
id, promises.push(
slug: slug as FullSlug, index.addAsync(id, {
title: fileData.title, id: id++,
content: fileData.content, slug: slug as FullSlug,
tags: fileData.tags, 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) await Promise.all(promises)
@ -487,6 +544,9 @@ async function fillDocument(data: ContentIndex) {
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
// Ignore rerender events
if (e.detail.rerender) return
const currentSlug = e.detail.url const currentSlug = e.detail.url
const data = await fetchData const data = await fetchData
const searchElement = document.getElementsByClassName("search") const searchElement = document.getElementsByClassName("search")

View File

@ -4,18 +4,158 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { // Hide the decrypt form when encrypted content appears in popover and search
background: rgba(0, 0, 0, 0.35); .popover .encrypted-content .encryption-notice .decrypt-form {
border: 1px solid var(--lightgray); display: none;
border-radius: 8px; }
padding: 2rem 1.5rem;
max-width: 450px; .search-space .encrypted-content .encryption-notice .decrypt-form {
width: 100%; display: none;
text-align: center; }
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
margin-top: 2rem; .decrypted-content-wrapper {
display: block;
margin: 0;
padding: 0;
} }
.encryption-icon { .encryption-icon {
@ -24,100 +164,6 @@
opacity: 0.7; 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 { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@ -126,37 +172,3 @@
transform: rotate(360deg); 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;
}

View File

@ -7,8 +7,11 @@ import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { Hash, EncryptionResult, EncryptionConfig } from "../../util/encryption"
export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentIndexMap = Map<FullSlug, ContentDetails>
// Base content details without encryption-specific fields
export type ContentDetails = { export type ContentDetails = {
slug: FullSlug slug: FullSlug
filePath: FilePath filePath: FilePath
@ -19,7 +22,9 @@ export type ContentDetails = {
richContent?: string richContent?: string
date?: Date date?: Date
description?: string description?: string
encrypted?: boolean encryptionConfig?: EncryptionConfig
hash?: Hash
encryptionResult?: EncryptionResult
} }
interface Options { interface Options {
@ -104,7 +109,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const slug = file.data.slug! const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { const contentDetails: ContentDetails = {
slug, slug,
filePath: file.data.relativePath!, filePath: file.data.relativePath!,
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,
@ -112,13 +117,17 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
tags: file.data.frontmatter?.tags ?? [], tags: file.data.frontmatter?.tags ?? [],
content: file.data.text ?? "", content: file.data.text ?? "",
richContent: richContent:
!file.data.encrypted && opts?.rssFullHtml !file.data.encryptionResult && opts?.rssFullHtml
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined, : undefined,
date: date, date: date,
description: file.data.description ?? "", 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<Partial<Options>> = (opts) => {
// remove description and from content index as nothing downstream // remove description and from content index as nothing downstream
// actually uses it. we only keep it in the index as we need it // actually uses it. we only keep it in the index as we need it
// for the RSS feed // for the RSS feed
if (content.encrypted) { if (content.encryptionResult) {
content.description = ""
delete content.richContent delete content.richContent
} else {
delete content.hash
delete content.encryptionConfig
delete content.encryptionResult
} }
delete content.description delete content.description

View File

@ -29,9 +29,9 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
return [ return [
() => { () => {
return async (tree: HTMLRoot, file) => { return async (tree: HTMLRoot, file) => {
if (file.data?.encrypted) { if (file.data?.encrypted && file.data.encryptionConfig) {
file.data.description = file.data.description =
file.data.encryptMessage || file.data.encryptionConfig.message ||
i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription
return return
} }

View File

@ -1,159 +1,86 @@
import { createCipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { Root } from "hast" import { Root } from "hast"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { fromHtml } from "hast-util-from-html" import { fromHtml } from "hast-util-from-html"
import { toString } from "hast-util-to-string"
import { VFile } from "vfile" import { VFile } from "vfile"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import {
EncryptionOptions,
DirectoryConfig,
defaultEncryptionConfig,
SUPPORTED_ALGORITHMS,
SupportedEncryptionAlgorithm,
encryptContent,
getEncryptionConfigForPath,
Hash,
hashString,
EncryptionResult,
EncryptionConfig,
} from "../../util/encryption"
// @ts-ignore // @ts-ignore
import encryptScript from "../../components/scripts/encrypt.inline.ts" import encryptScript from "../../components/scripts/encrypt.inline.ts"
import encryptStyle from "../../components/styles/encrypt.scss" import encryptStyle from "../../components/styles/encrypt.scss"
export interface Options { export interface Options extends EncryptionOptions {}
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 = { const defaultOptions: Options = {
algorithm: "aes-256-cbc", ...defaultEncryptionConfig,
keyLength: 32,
iterations: 100000,
encryptedFolders: {}, encryptedFolders: {},
ttl: 3600 * 24 * 7,
} }
const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
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<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
// Validate algorithm at build time // 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( throw new Error(
`[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, `[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 frontmatter = file.data?.frontmatter
const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig
const relativePath = file.data?.relativePath
if (frontmatter?.encrypt && frontmatter?.password) { const folderConfig = relativePath ? getEncryptionConfigForPath(relativePath, opts) : null
return frontmatter.password as string
if (!folderConfig && !frontmatterConfig.password) {
return undefined
} else if (!folderConfig && !frontmatter?.encrypt) {
return undefined
} }
let deepestFolder = "" const config = {
for (const folder of Object.keys(opts.encryptedFolders ?? {})) { algorithm: frontmatterConfig.algorithm || folderConfig?.algorithm || opts.algorithm,
if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { password: frontmatterConfig.password || folderConfig?.password || "",
deepestFolder = folder message: frontmatterConfig.message || folderConfig?.message || opts.message,
} ttl: frontmatterConfig.ttl || folderConfig?.ttl || opts.ttl,
} }
if (deepestFolder) { if (!config.password) {
if (frontmatter?.password) { return undefined
// if frontmatter has a password, use it
return frontmatter.password as string
}
return opts.encryptedFolders!![deepestFolder] as string
} }
return config
} }
return { return {
name: "EncryptPlugin", name: "Encrypt",
markdownPlugins() { markdownPlugins() {
// If encypted, prepend lock emoji before the title // If encrypted, prepend lock emoji before the title
return [ return [
() => { () => {
return (_, file) => { return async (_, file) => {
const password = getPassword(file) const config = getEncryptionConfig(file)
if (!password) { if (!config) {
return return
} }
file.data.encrypted = true file.data.encryptionConfig = config
file.data.password = password file.data.hash = await hashString(config.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}`
}
} }
}, },
] ]
@ -161,41 +88,55 @@ export const EncryptPlugin: QuartzTransformerPlugin<Partial<Options>> = (userOpt
htmlPlugins(ctx) { htmlPlugins(ctx) {
return [ return [
() => { () => {
return (tree: Root, file) => { return async (tree: Root, file) => {
const password = getPassword(file) const config = getEncryptionConfig(file)
if (!password) {
return tree // No encryption, return original tree if (!file.data.hash || !config) {
return tree
} }
const locale = ctx.cfg.configuration.locale const locale = ctx.cfg.configuration.locale
const t = i18n(locale).components.encryption const t = i18n(locale).components.encryption
// Convert the HTML tree to string // Convert html to plaintext and encrypt it
const htmlContent = toHtml(tree) file.data.encryptionResult = await encryptContent(
toString(tree),
config.password,
config,
)
// Encrypt the content // Encrypt the content and generate verification hash
const encryptedContent = encryptContent(htmlContent, password, opts) const encryptionResult = await encryptContent(toHtml(tree), config.password, config)
console.log(file.data)
// 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 // Create a new tree with encrypted content placeholder
const encryptedTree = fromHtml( const encryptedTree = fromHtml(
` `
<div class="encrypted-content" data-encrypted="${encryptedContent}" data-config='${JSON.stringify(opts)}' data-i18n='${JSON.stringify(t)}'> <div class="encrypted-content" ${attributes}>
<div class="encryption-notice"> <div class="encryption-notice">
<h3>${t.title}</h3> <h3>${t.title}</h3>
<p>${t.restricted}</p> ${config.message ? `<p>${config.message}</p>` : ""}
<div class="decrypt-form"> <div class="decrypt-form">
<input type="password" class="decrypt-password" placeholder="${t.enterPassword}" /> <input type="password" class="decrypt-password" placeholder="${t.enterPassword}" />
<button class="decrypt-button">${t.decrypt}</button> <button class="decrypt-button">${t.decrypt}</button>
${file.data.encryptMessage ? `<p class="encrypted-message-footnote">${file.data.encryptMessage}</p>` : ""}
</div> </div>
<div class="decrypt-loading"> <div class="decrypt-loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<span>${t.decrypting}</span> <span>${t.decrypting}</span>
</div> </div>
<div class="decrypt-error"> <div class="decrypt-error" data-error="incorrect-password">
${t.incorrectPassword} ${t.incorrectPassword}
</div> </div>
<div class="decrypt-error" data-error="decryption-failed">
${t.decryptionFailed}
</div>
</div> </div>
</div> </div>
`, `,
@ -233,8 +174,8 @@ export const EncryptPlugin: QuartzTransformerPlugin<Partial<Options>> = (userOpt
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
encrypted: boolean encryptionConfig: EncryptionConfig
encryptMessage?: string encryptionResult: EncryptionResult
password?: string hash: Hash
} }
} }

View File

@ -6,6 +6,7 @@ import toml from "toml"
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path" import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile" import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { DirectoryConfig } from "../../util/encryption"
export interface Options { export interface Options {
delimiters: string | [string, string] delimiters: string | [string, string]
@ -122,14 +123,17 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
if (encrypted) data.encrypt = true if (encrypted) data.encrypt = true
const password = coalesceAliases(data, ["password"]) const password = coalesceAliases(data, ["password"])
if (password) data.password = password if (password) data.encryptConfig = { password: password }
const encryptMessage = coalesceAliases(data, [ const encryptConfig = coalesceAliases(data, ["encryptConfig", "encrypt_config"])
"encryptMessage", if (encryptConfig && typeof encryptConfig === "object") {
"encrypt_message", data.encryptConfig = {
"encrypt-message", password: encryptConfig.password || password,
]) message: encryptConfig.message || undefined,
if (encryptMessage) data.encryptMessage = encryptMessage algorithm: encryptConfig.algorithm || undefined,
ttl: encryptConfig.ttl || undefined,
}
}
// Remove duplicate slugs // Remove duplicate slugs
const uniqueSlugs = [...new Set(allSlugs)] const uniqueSlugs = [...new Set(allSlugs)]
@ -164,6 +168,8 @@ declare module "vfile" {
cssclasses: string[] cssclasses: string[]
socialImage: string socialImage: string
comments: boolean | string comments: boolean | string
encrypt: boolean
encryptConfig: DirectoryConfig
}> }>
} }
} }

View File

@ -8,7 +8,7 @@ export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm" export { ObsidianFlavoredMarkdown } from "./ofm"
export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax" export { SyntaxHighlighting } from "./syntax"
export { EncryptPlugin } from "./encrypt" export { Encrypt } from "./encrypt"
export { TableOfContents } from "./toc" export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks" export { HardLineBreaks } from "./linebreaks"
export { RoamFlavoredMarkdown } from "./roam" export { RoamFlavoredMarkdown } from "./roam"

595
quartz/util/encryption.ts Normal file
View File

@ -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<CryptoKey> {
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<Hash> {
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<boolean> {
const { hash: passwordHash } = await hashString(password, passwordHashData.salt)
return passwordHash === passwordHashData.hash
}
export async function encryptContent(
content: string,
password: string,
config: EncryptionConfig,
): Promise<EncryptionResult> {
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<string> {
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<string | undefined> {
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<void> = Promise.resolve()
interface CachedPassword {
password: string
ttl: number
}
// Helper function to execute cache operations atomically
async function executeAtomicCacheOperation<T>(operation: () => T): Promise<T> {
return new Promise<T>((resolve, reject) => {
cacheOperationQueue = cacheOperationQueue
.then(() => {
try {
const result = operation()
resolve(result)
} catch (error) {
reject(error)
}
})
.catch((error) => {
reject(error)
})
})
}
export function getPasswordCache(): Record<string, CachedPassword> {
// 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<string, CachedPassword>) {
// 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<void> {
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<string> = 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<boolean> = 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)
}
})
}
}