mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-21 03:44:05 -06:00
Created plugin to publish encrypted pages
This commit is contained in:
parent
efddd798e8
commit
19abdca764
@ -70,8 +70,16 @@ const config: QuartzConfig = {
|
|||||||
Plugin.GitHubFlavoredMarkdown(),
|
Plugin.GitHubFlavoredMarkdown(),
|
||||||
Plugin.TableOfContents(),
|
Plugin.TableOfContents(),
|
||||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||||
Plugin.Description(),
|
|
||||||
Plugin.Latex({ renderEngine: "katex" }),
|
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()],
|
filters: [Plugin.RemoveDrafts()],
|
||||||
emitters: [
|
emitters: [
|
||||||
|
|||||||
361
quartz/components/scripts/encrypt.inline.ts
Normal file
361
quartz/components/scripts/encrypt.inline.ts
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
// Password cache management
|
||||||
|
const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords"
|
||||||
|
|
||||||
|
function getPasswordCache(): Record<string, { password: string; ttl: number }> {
|
||||||
|
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)
|
||||||
|
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<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 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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
149
quartz/components/styles/encrypt.scss
Normal file
149
quartz/components/styles/encrypt.scss
Normal file
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ export type ContentDetails = {
|
|||||||
richContent?: string
|
richContent?: string
|
||||||
date?: Date
|
date?: Date
|
||||||
description?: string
|
description?: string
|
||||||
|
encrypted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
@ -58,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?:
|
|||||||
<title>${escapeHTML(content.title)}</title>
|
<title>${escapeHTML(content.title)}</title>
|
||||||
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||||
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||||
<description><![CDATA[ ${content.richContent ?? content.description} ]]></description>
|
<description><![CDATA[ ${content.encrypted ? content.description : content.richContent ?? content.description} ]]></description>
|
||||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||||
</item>`
|
</item>`
|
||||||
|
|
||||||
@ -115,6 +116,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
date: date,
|
date: date,
|
||||||
description: file.data.description ?? "",
|
description: file.data.description ?? "",
|
||||||
|
encrypted: file.data.encrypted,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,6 +145,11 @@ 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) {
|
||||||
|
delete content.content
|
||||||
|
delete content.richContent
|
||||||
|
}
|
||||||
|
|
||||||
delete content.description
|
delete content.description
|
||||||
delete content.date
|
delete content.date
|
||||||
return [slug, content]
|
return [slug, content]
|
||||||
|
|||||||
@ -28,6 +28,11 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
return [
|
return [
|
||||||
() => {
|
() => {
|
||||||
return async (tree: HTMLRoot, file) => {
|
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 frontMatterDescription = file.data.frontmatter?.description
|
||||||
let text = escapeHTML(toString(tree))
|
let text = escapeHTML(toString(tree))
|
||||||
|
|
||||||
|
|||||||
224
quartz/plugins/transformers/encrypt.ts
Normal file
224
quartz/plugins/transformers/encrypt.ts
Normal file
@ -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<Partial<Options>> = (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(
|
||||||
|
`
|
||||||
|
<div class="encrypted-content" data-encrypted="${encryptedContent}" data-config='${JSON.stringify(opts)}'>
|
||||||
|
<div class="encryption-notice">
|
||||||
|
<h3>🛡️ Restricted Content 🛡️</h3>
|
||||||
|
<p>This content is restricted. Enter the password to view:</p>
|
||||||
|
<div class="decrypt-form">
|
||||||
|
<input type="password" class="decrypt-password" placeholder="Enter password" />
|
||||||
|
<button class="decrypt-button">Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<div class="decrypt-loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>Decrypting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="decrypt-error">
|
||||||
|
Incorrect password. Please try again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
{ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +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 { TableOfContents } from "./toc"
|
export { TableOfContents } from "./toc"
|
||||||
export { HardLineBreaks } from "./linebreaks"
|
export { HardLineBreaks } from "./linebreaks"
|
||||||
export { RoamFlavoredMarkdown } from "./roam"
|
export { RoamFlavoredMarkdown } from "./roam"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user