quartz/quartz/components/scripts/encrypt.inline.ts
2025-07-31 06:59:11 +02:00

225 lines
6.9 KiB
TypeScript

import {
decryptContent,
verifyPasswordHash,
addPasswordToCache,
Hash,
EncryptionConfig,
searchForValidPassword,
EncryptionResult,
} from "../../util/encryption"
import { FullSlug, getFullSlug } from "../../util/path"
const 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"
}
}
}
const decryptWithPassword = async (
container: Element,
password: string,
showError = true,
): Promise<boolean> => {
const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement>
const containerElement = container as HTMLElement
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 {
// 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
let isValidPassword: boolean
isValidPassword = await verifyPasswordHash(password, hash)
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 {
let decryptedContent: string
decryptedContent = await decryptContent(encrypted, config, password)
if (decryptedContent) {
// Cache the password
const filePath = getFullSlug(window)
await 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")
return false
} catch (decryptError) {
if (showError) showLoading(container, false)
if (showError) throw new Error("decryption-failed")
return false
}
} catch (error) {
if (showError) {
showLoading(container, false)
const errorMessage = error instanceof Error ? error.message : null
errorDivs.forEach((div) => {
if (div.dataset.error == errorMessage) {
div.style.display = "block"
}
})
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
const decryptButton = container.querySelector(".decrypt-button") as HTMLButtonElement
// Check if we're in a popover context
const isInPopover = container.closest(".popover") !== null
if (passwordInput) {
passwordInput.value = ""
if (isInPopover) {
// Disable input and button in popover on failure
passwordInput.disabled = true
if (decryptButton) {
decryptButton.disabled = true
}
} else {
passwordInput.focus()
}
}
}
return false
}
}
const notifyNav = (url: FullSlug) => {
const event: CustomEventMap["nav"] = new CustomEvent("nav", {
detail: { url, rerender: true },
})
document.dispatchEvent(event)
}
function updateTitle(container: HTMLElement | null) {
console.log(container)
if (container) {
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
}
const manualDecrypt = async (container: HTMLElement) => {
const parent =
(container.closest(".preview-inner") as HTMLElement) ||
(container.closest(".center") as HTMLElement) ||
null
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
const password = passwordInput.value
if (!password) {
passwordInput.focus()
return
}
if (await decryptWithPassword(container, password, true)) {
const filePath = getFullSlug(window)
notifyNav(filePath)
updateTitle(parent)
}
}
document.addEventListener("nav", async () => {
// Try auto-decryption for all encrypted content
const encryptedElements = document.querySelectorAll(
".encrypted-content",
) as NodeListOf<HTMLElement>
for (const encryptedContainer of encryptedElements) {
await tryAutoDecrypt(encryptedContainer)
}
// Manual decryption handlers
const buttons = document.querySelectorAll(".decrypt-button")
buttons.forEach((button) => {
const handleClick = async function (this: HTMLElement) {
const encryptedContainer = this.closest(".encrypted-content")!
await manualDecrypt(encryptedContainer as HTMLElement)
}
button.addEventListener("click", handleClick)
// Check if window.addCleanup exists before using it
if (window.addCleanup) {
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 encryptedContainer = this.closest(".encrypted-content")!
await manualDecrypt(encryptedContainer as HTMLElement)
}
}
input.addEventListener("keypress", handleKeypress)
// Check if window.addCleanup exists before using it
if (window.addCleanup) {
window.addCleanup(() => input.removeEventListener("keypress", handleKeypress))
}
})
})