mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
Merge 5afcb8cc97 into bacd19c4ea
This commit is contained in:
commit
4ffb88a69c
@ -45,8 +45,12 @@ This question is best answered by tracing what happens when a user (you!) runs `
|
||||
|
||||
1. The browser opens a Quartz page and loads the HTML. The `<head>` also links to page styles (emitted to `public/index.css`) and page-critical JS (emitted to `public/prescript.js`)
|
||||
2. Then, once the body is loaded, the browser loads the non-critical JS (emitted to `public/postscript.js`)
|
||||
3. Once the page is done loading, the page will then dispatch a custom synthetic browser event `"nav"`. This is used so client-side scripts declared by components can 'setup' anything that requires access to the page DOM.
|
||||
1. If the [[SPA Routing|enableSPA option]] is enabled in the [[configuration]], this `"nav"` event is also fired on any client-navigation to allow for components to unregister and reregister any event handlers and state.
|
||||
2. If it's not, we wire up the `"nav"` event to just be fired a single time after page load to allow for consistency across how state is setup across both SPA and non-SPA contexts.
|
||||
3. Once the page is done loading, the page will dispatch two custom synthetic browser events:
|
||||
1. **`"nav"`** event: Fired when the user navigates to a new page. This is used for navigation-specific logic like updating URL-dependent state, analytics tracking, etc.
|
||||
- Contains `e.detail.url` with the current page URL
|
||||
- Fired on initial page load and on client-side navigation (if [[SPA Routing|enableSPA option]] is enabled)
|
||||
2. **`"render"`** event: Fired when content needs to be processed or re-rendered. This is used for DOM manipulation, setting up event listeners, and other content-specific logic.
|
||||
- Contains `e.detail.htmlElement` with the DOM element that was updated
|
||||
- Fired on initial page load (with `document.body`) and whenever content is dynamically updated (e.g., in popovers, search results, after decryption)
|
||||
|
||||
The architecture and design of the plugin system was intentionally left pretty vague here as this is described in much more depth in the guide on [[making plugins|making your own plugin]].
|
||||
|
||||
@ -149,15 +149,46 @@ As the names suggest, the `.beforeDOMLoaded` scripts are executed _before_ the p
|
||||
|
||||
The `.afterDOMLoaded` script executes once the page has been completely loaded. This is a good place to setup anything that should last for the duration of a site visit (e.g. getting something saved from local storage).
|
||||
|
||||
If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you can listen for the `"nav"` event that gets fired whenever a page loads (which may happen on navigation if [[SPA Routing]] is enabled).
|
||||
If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you have two options:
|
||||
|
||||
**For navigation-specific logic**, listen for the `"nav"` event that gets fired whenever the user navigates to a new page:
|
||||
|
||||
```ts
|
||||
document.addEventListener("nav", () => {
|
||||
// do page specific logic here
|
||||
// e.g. attach event listeners
|
||||
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
|
||||
toggleSwitch.addEventListener("change", switchTheme)
|
||||
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
|
||||
document.addEventListener("nav", (e) => {
|
||||
// runs only on page navigation
|
||||
// e.detail.url contains the new page URL
|
||||
const currentUrl = e.detail.url
|
||||
console.log(`Navigated to: ${currentUrl}`)
|
||||
})
|
||||
```
|
||||
|
||||
**For rendering/re-rendering content**, use the `"render"` event which is fired when content needs to be processed or updated:
|
||||
|
||||
```ts
|
||||
document.addEventListener("render", (e) => {
|
||||
// runs when content is rendered or re-rendered
|
||||
// e.detail.htmlElement contains the DOM element that was updated
|
||||
const container = e.detail.htmlElement
|
||||
|
||||
// attach event listeners to elements within this container
|
||||
const toggleSwitch = container.querySelector("#switch") as HTMLInputElement
|
||||
if (toggleSwitch) {
|
||||
toggleSwitch.addEventListener("change", switchTheme)
|
||||
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
You can also use the utility function from `"./util"` to simplify render event handling:
|
||||
|
||||
```ts
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
addRenderListener((container) => {
|
||||
// your rendering logic here
|
||||
// container is the DOM element that was updated
|
||||
const elements = container.querySelectorAll(".my-component")
|
||||
elements.forEach(setupElement)
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
175
docs/advanced/event system.md
Normal file
175
docs/advanced/event system.md
Normal file
@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Event System
|
||||
---
|
||||
|
||||
Quartz uses a custom event system to coordinate between navigation and content rendering. Understanding these events is crucial for creating interactive components that work correctly with [[SPA Routing]].
|
||||
|
||||
## Event Types
|
||||
|
||||
### Navigation Event (`nav`)
|
||||
|
||||
The `nav` event is fired when the user navigates to a new page. This should be used for logic that needs to run once per page navigation.
|
||||
|
||||
```ts
|
||||
document.addEventListener("nav", (e: CustomEventMap["nav"]) => {
|
||||
// Access the current page URL
|
||||
const currentUrl = e.detail.url
|
||||
console.log(`User navigated to: ${currentUrl}`)
|
||||
|
||||
// Good for:
|
||||
// - Analytics tracking
|
||||
// - URL-dependent state updates
|
||||
// - Setting up page-level event handlers
|
||||
// - Theme/mode initialization
|
||||
})
|
||||
```
|
||||
|
||||
**When it fires:**
|
||||
|
||||
- On initial page load
|
||||
- On client-side navigation (if SPA routing is enabled)
|
||||
- Does NOT fire on content re-renders
|
||||
|
||||
### Render Event (`render`)
|
||||
|
||||
The `render` event is fired when content needs to be processed or updated. This should be used for DOM manipulation and content-specific logic.
|
||||
|
||||
```ts
|
||||
document.addEventListener("render", (e: CustomEventMap["render"]) => {
|
||||
// Access the container that was updated
|
||||
const container = e.detail.htmlElement
|
||||
|
||||
// Process elements within this container
|
||||
const codeBlocks = container.querySelectorAll("pre code")
|
||||
codeBlocks.forEach(addSyntaxHighlighting)
|
||||
|
||||
// Good for:
|
||||
// - Setting up event listeners on new content
|
||||
// - Processing dynamic content (syntax highlighting, math rendering, etc.)
|
||||
// - Initializing interactive components
|
||||
})
|
||||
```
|
||||
|
||||
**When it fires:**
|
||||
|
||||
- On initial page load (with `document.body` as the container)
|
||||
- When popover content is loaded
|
||||
- When search results are displayed
|
||||
- After content is decrypted
|
||||
- Whenever `dispatchRenderEvent()` is called
|
||||
|
||||
## Utility Functions
|
||||
|
||||
Quartz provides utility functions in `quartz/components/scripts/util.ts` to make working with these events easier:
|
||||
|
||||
### `addRenderListener(fn)`
|
||||
|
||||
A convenience function for listening to render events:
|
||||
|
||||
```ts
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
addRenderListener((container: HTMLElement) => {
|
||||
// Your rendering logic here
|
||||
// container is the DOM element that was updated
|
||||
const myElements = container.querySelectorAll(".my-component")
|
||||
myElements.forEach(setupMyComponent)
|
||||
})
|
||||
```
|
||||
|
||||
This is equivalent to manually adding a render event listener but with cleaner syntax.
|
||||
|
||||
### `dispatchRenderEvent(htmlElement)`
|
||||
|
||||
Triggers a render event for a specific DOM element:
|
||||
|
||||
```ts
|
||||
import { dispatchRenderEvent } from "./util"
|
||||
|
||||
// After dynamically creating or updating content
|
||||
const myContainer = document.getElementById("dynamic-content")
|
||||
// ... update the container content ...
|
||||
dispatchRenderEvent(myContainer)
|
||||
```
|
||||
|
||||
This will cause all render event listeners to process the specified container.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to use `nav` vs `render`
|
||||
|
||||
- **Use `nav` for:** Page-level setup, URL tracking, global state management
|
||||
- **Use `render` for:** Content processing, element-specific event handlers, DOM manipulation
|
||||
|
||||
### Memory Management
|
||||
|
||||
Always clean up event handlers to prevent memory leaks:
|
||||
|
||||
```ts
|
||||
addRenderListener((container) => {
|
||||
const buttons = container.querySelectorAll(".my-button")
|
||||
|
||||
const handleClick = (e) => {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", handleClick)
|
||||
// Clean up when navigating away
|
||||
window.addCleanup(() => {
|
||||
button.removeEventListener("click", handleClick)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
The `window.addCleanup()` function ensures handlers are removed when navigating to a new page.
|
||||
|
||||
### Scoped Processing
|
||||
|
||||
Always scope your render logic to the provided container:
|
||||
|
||||
```ts
|
||||
// ✅ Good - only processes elements within the updated container
|
||||
addRenderListener((container) => {
|
||||
const elements = container.querySelectorAll(".my-element")
|
||||
elements.forEach(process)
|
||||
})
|
||||
|
||||
// ❌ Bad - processes all elements on the page
|
||||
addRenderListener((container) => {
|
||||
const elements = document.querySelectorAll(".my-element")
|
||||
elements.forEach(process)
|
||||
})
|
||||
```
|
||||
|
||||
This ensures your logic only runs on newly updated content and avoids duplicate processing.
|
||||
|
||||
## Migration from Old System
|
||||
|
||||
If you have existing code that used the old `rerender` flag pattern:
|
||||
|
||||
```ts
|
||||
// Old pattern ❌
|
||||
document.addEventListener("nav", (e) => {
|
||||
if (e.detail.rerender) return // Skip rerender events
|
||||
// ... setup logic
|
||||
})
|
||||
```
|
||||
|
||||
You should split this into separate event handlers:
|
||||
|
||||
```ts
|
||||
// New pattern ✅
|
||||
document.addEventListener("nav", (e) => {
|
||||
// Navigation-only logic
|
||||
updateURL(e.detail.url)
|
||||
})
|
||||
|
||||
addRenderListener((container) => {
|
||||
// Content rendering logic
|
||||
setupComponents(container)
|
||||
})
|
||||
```
|
||||
|
||||
This provides cleaner separation of concerns and better performance.
|
||||
106
docs/plugins/Encrypt.md
Normal file
106
docs/plugins/Encrypt.md
Normal file
@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Encrypt"
|
||||
tags:
|
||||
- plugin/transformer
|
||||
encrypt: true
|
||||
encryptConfig:
|
||||
password: "quartz"
|
||||
message: '^ Password is "quartz"'
|
||||
---
|
||||
|
||||
This plugin enables content encryption for sensitive pages in your Quartz site. It uses AES encryption with password-based access control, allowing you to protect specific pages or entire folders with passwords.
|
||||
|
||||
> [!note]
|
||||
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
Plugin.Encrypt({
|
||||
algorithm: "aes-256-cbc", // Encryption algorithm
|
||||
ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days)
|
||||
message: "This content is encrypted.", // Default message shown
|
||||
encryptedFolders: {
|
||||
// Simple password for a folder
|
||||
"private/": "folder-password",
|
||||
|
||||
// Advanced configuration for a folder
|
||||
"secure/": {
|
||||
password: "advanced-password",
|
||||
algorithm: "aes-256-gcm",
|
||||
ttl: 3600 * 24 * 30, // 30 days
|
||||
message: "Authorized access only",
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
> [!warning]
|
||||
> Important security notes:
|
||||
>
|
||||
> - All non-markdown files remain unencrypted in the final build
|
||||
> - Encrypted content is still visible in your source repository if it's public
|
||||
> - Use this for access control, not for storing highly sensitive secrets
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `algorithm`: Encryption algorithm to use
|
||||
- `"aes-256-cbc"` (default): AES-256 in CBC mode
|
||||
- `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption)
|
||||
- Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes)
|
||||
- `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption
|
||||
- `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only)
|
||||
- `message`: Message to be displayed in the decryption page
|
||||
|
||||
## How Configuration Works
|
||||
|
||||
### Configuration Inheritance
|
||||
|
||||
Settings cascade down through your folder structure:
|
||||
|
||||
```typescript
|
||||
encryptedFolders: {
|
||||
"docs/": {
|
||||
password: "docs-password",
|
||||
algorithm: "aes-256-gcm"
|
||||
},
|
||||
"docs/internal/": {
|
||||
password: "internal-password"
|
||||
// Inherits algorithm from parent folder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `docs/page.md` uses `"docs-password"` with `"aes-256-gcm"`
|
||||
- `docs/internal/report.md` uses `"internal-password"` but still uses `"aes-256-gcm"` (inherited)
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
When multiple configurations apply, the priority is:
|
||||
|
||||
1. **Page frontmatter** (highest priority)
|
||||
2. **Deepest matching folder**
|
||||
3. **Parent folders** (inherited settings)
|
||||
4. **Global defaults** (lowest priority)
|
||||
|
||||
## Security Features
|
||||
|
||||
### Password Caching
|
||||
|
||||
- Passwords are stored in browser localStorage
|
||||
- Automatic expiration based on TTL settings
|
||||
- Cached passwords are tried automatically when navigating
|
||||
|
||||
### Protection Levels
|
||||
|
||||
- **Content**: Entire page HTML is encrypted
|
||||
- **Search/RSS**: Only generic descriptions are exposed
|
||||
- **Navigation**: Encrypted pages appear in navigation but require passwords to view
|
||||
|
||||
## API
|
||||
|
||||
- Category: Transformer
|
||||
- Function name: `Plugin.Encrypt()`
|
||||
- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts)
|
||||
@ -64,6 +64,13 @@ Quartz supports the following frontmatter:
|
||||
- `published`
|
||||
- `publishDate`
|
||||
- `date`
|
||||
- encrypt
|
||||
- `encrypt`
|
||||
- `encrypted`
|
||||
- encryptConfig
|
||||
- Overrides for the [[plugins/Encrypt|encryptConfig]]
|
||||
- password
|
||||
- `password`
|
||||
|
||||
## API
|
||||
|
||||
|
||||
8
index.d.ts
vendored
8
index.d.ts
vendored
@ -7,9 +7,15 @@ declare module "*.scss" {
|
||||
interface CustomEventMap {
|
||||
prenav: CustomEvent<{}>
|
||||
nav: CustomEvent<{ url: FullSlug }>
|
||||
render: CustomEvent<{ htmlElement: HTMLElement }>
|
||||
decrypt: CustomEvent<{ filePath: FullSlug; password: string }>
|
||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
||||
}
|
||||
|
||||
type ContentIndex = Record<FullSlug, ContentDetails>
|
||||
type DecryptedFlag = {
|
||||
decrypted?: boolean
|
||||
}
|
||||
|
||||
type ContentIndex = Record<FullSlug, ContentDetails & DecryptedFlag>
|
||||
declare const fetchData: Promise<ContentIndex>
|
||||
|
||||
@ -70,8 +70,13 @@ const config: QuartzConfig = {
|
||||
Plugin.GitHubFlavoredMarkdown(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
Plugin.Description(),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
Plugin.Encrypt({
|
||||
algorithm: "aes-256-cbc",
|
||||
encryptedFolders: {},
|
||||
ttl: 3600 * 24 * 7, // A week
|
||||
}),
|
||||
Plugin.Description(),
|
||||
],
|
||||
filters: [Plugin.RemoveDrafts()],
|
||||
emitters: [
|
||||
|
||||
@ -4,7 +4,12 @@ import { classNames } from "../util/lang"
|
||||
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const title = fileData.frontmatter?.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 {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ const defaultOptions: Options = {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||
@ -146,7 +146,7 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
</svg>
|
||||
<div>
|
||||
<button class="folder-button">
|
||||
<span class="folder-title"></span>
|
||||
<span class="folder-title folder-title-text"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -26,7 +26,7 @@ export default ((opts?: Partial<Options>) => {
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
if (!fileData.toc || fileData.encryptionResult) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ export default ((opts?: Partial<Options>) => {
|
||||
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
|
||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
if (!fileData.toc || fileData.encryptionResult) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
@ -7,8 +9,8 @@ function toggleCallout(this: HTMLElement) {
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
|
||||
function setupCallout() {
|
||||
const collapsible = document.getElementsByClassName(
|
||||
function setupCallout(container: HTMLElement) {
|
||||
const collapsible = container.getElementsByClassName(
|
||||
`callout is-collapsible`,
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const div of collapsible) {
|
||||
@ -24,4 +26,4 @@ function setupCallout() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupCallout)
|
||||
addRenderListener(setupCallout)
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { getFullSlug } from "../../util/path"
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
addRenderListener((container: HTMLElement) => {
|
||||
const checkboxes = container.querySelectorAll(
|
||||
"input.checkbox-toggle",
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
checkboxes.forEach((el, index) => {
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
const svgCopy =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
|
||||
const svgCheck =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const els = document.getElementsByTagName("pre")
|
||||
addRenderListener((container: HTMLElement) => {
|
||||
const els = container.getElementsByTagName("pre")
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||
if (codeBlock) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
const changeTheme = (e: CustomEventMap["themechange"]) => {
|
||||
const theme = e.detail.theme
|
||||
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
|
||||
@ -59,8 +61,8 @@ type GiscusElement = Omit<HTMLElement, "dataset"> & {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
addRenderListener((container: HTMLElement) => {
|
||||
const giscusContainer = container.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
223
quartz/components/scripts/encrypt.inline.ts
Normal file
223
quartz/components/scripts/encrypt.inline.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import {
|
||||
decryptContent,
|
||||
verifyPasswordHash,
|
||||
addPasswordToCache,
|
||||
Hash,
|
||||
CompleteCryptoConfig,
|
||||
searchForValidPassword,
|
||||
EncryptionResult,
|
||||
} from "../../util/encryption"
|
||||
import { FullSlug, getFullSlug } from "../../util/path"
|
||||
import { addRenderListener, dispatchRenderEvent } from "./util"
|
||||
|
||||
const showLoading = (container: Element, show: boolean) => {
|
||||
const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchDecryptEvent(filePath: FullSlug, password: string) {
|
||||
const event = new CustomEvent("decrypt", {
|
||||
detail: {
|
||||
filePath,
|
||||
password,
|
||||
},
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
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 CompleteCryptoConfig
|
||||
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)
|
||||
// set data-decrypted of the original container to true
|
||||
containerElement.dataset.decrypted = "true"
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (showError) throw new Error("decryption-failed")
|
||||
return false
|
||||
} catch (decryptError) {
|
||||
console.error("Decryption failed:", decryptError)
|
||||
if (showError) showLoading(container, false)
|
||||
if (showError) throw new Error("decryption-failed")
|
||||
return false
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
function updateTitle(container: HTMLElement | null) {
|
||||
if (container) {
|
||||
const span = container.querySelector(".article-title-icon") as HTMLElement
|
||||
if (span) {
|
||||
span.textContent = "🔓 "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise<boolean> => {
|
||||
const fullSlug = container.dataset.slug as FullSlug
|
||||
const config = JSON.parse(container.dataset.config!) as CompleteCryptoConfig
|
||||
const hash = JSON.parse(container.dataset.hash!) as Hash
|
||||
|
||||
const password = await searchForValidPassword(fullSlug, hash, config)
|
||||
|
||||
if (password && (await decryptWithPassword(container, password, false))) {
|
||||
dispatchRenderEvent(parent)
|
||||
dispatchDecryptEvent(fullSlug, password)
|
||||
updateTitle(parent)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => {
|
||||
const fullSlug = container.dataset.slug as FullSlug
|
||||
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
|
||||
const password = passwordInput.value
|
||||
|
||||
if (!password) {
|
||||
passwordInput.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (await decryptWithPassword(container, password, true)) {
|
||||
dispatchRenderEvent(parent)
|
||||
dispatchDecryptEvent(fullSlug, password)
|
||||
updateTitle(parent)
|
||||
}
|
||||
}
|
||||
|
||||
addRenderListener(async (element) => {
|
||||
// Try auto-decryption for all encrypted content with data-decrypted="false"
|
||||
const encryptedElements = element.querySelectorAll(
|
||||
".encrypted-content[data-decrypted='false']",
|
||||
) as NodeListOf<HTMLElement>
|
||||
|
||||
for (const encryptedContainer of encryptedElements) {
|
||||
await tryAutoDecrypt(element, encryptedContainer)
|
||||
}
|
||||
|
||||
// Manual decryption handlers
|
||||
const buttons = element.querySelectorAll(".decrypt-button")
|
||||
|
||||
buttons.forEach((button) => {
|
||||
const handleClick = async function (this: HTMLElement) {
|
||||
const encryptedContainer = this.closest(".encrypted-content")!
|
||||
await manualDecrypt(element, 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
|
||||
element.querySelectorAll(".decrypt-password").forEach((input) => {
|
||||
const handleKeypress = async function (this: HTMLInputElement, e: Event) {
|
||||
const keyEvent = e as KeyboardEvent
|
||||
if (keyEvent.key === "Enter") {
|
||||
const encryptedContainer = this.closest(".encrypted-content")!
|
||||
await manualDecrypt(element, encryptedContainer as HTMLElement)
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("keypress", handleKeypress)
|
||||
// Check if window.addCleanup exists before using it
|
||||
if (window.addCleanup) {
|
||||
window.addCleanup(() => input.removeEventListener("keypress", handleKeypress))
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
import { FileTrieNode } from "../../util/fileTrie"
|
||||
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import { contentDecryptedEventListener } from "../../util/encryption"
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
|
||||
@ -86,7 +87,16 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
|
||||
const a = li.querySelector("a") as HTMLAnchorElement
|
||||
a.href = resolveRelative(currentSlug, node.slug)
|
||||
a.dataset.for = node.slug
|
||||
a.textContent = node.displayName
|
||||
|
||||
if (node.data?.encryptionResult) {
|
||||
a.textContent = "🔒 " + node.displayName
|
||||
|
||||
contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => {
|
||||
a.textContent = "🔓 " + node.displayName
|
||||
})
|
||||
} else {
|
||||
a.textContent = node.displayName
|
||||
}
|
||||
|
||||
if (currentSlug === node.slug) {
|
||||
a.classList.add("active")
|
||||
@ -111,6 +121,8 @@ function createFolderNode(
|
||||
const folderPath = node.slug
|
||||
folderContainer.dataset.folderpath = folderPath
|
||||
|
||||
let titleElement: HTMLElement
|
||||
|
||||
if (opts.folderClickBehavior === "link") {
|
||||
// Replace button with link for link behavior
|
||||
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
||||
@ -118,11 +130,20 @@ function createFolderNode(
|
||||
a.href = resolveRelative(currentSlug, folderPath)
|
||||
a.dataset.for = folderPath
|
||||
a.className = "folder-title"
|
||||
a.textContent = node.displayName
|
||||
titleElement = a
|
||||
button.replaceWith(a)
|
||||
} else {
|
||||
const span = titleContainer.querySelector(".folder-title") as HTMLElement
|
||||
span.textContent = node.displayName
|
||||
const span = titleContainer.querySelector(".folder-title-text") as HTMLElement
|
||||
titleElement = span
|
||||
}
|
||||
|
||||
if (node.data?.encryptionResult) {
|
||||
titleElement.textContent = "🔒 " + node.displayName
|
||||
contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => {
|
||||
titleElement.textContent = "🔓 " + node.displayName
|
||||
})
|
||||
} else {
|
||||
titleElement.textContent = node.displayName
|
||||
}
|
||||
|
||||
// if the saved state is collapsed or the default state is collapsed
|
||||
@ -173,6 +194,7 @@ async function setupExplorer(currentSlug: FullSlug) {
|
||||
)
|
||||
|
||||
const data = await fetchData
|
||||
|
||||
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||
const trie = FileTrieNode.fromEntries(entries)
|
||||
|
||||
@ -267,6 +289,7 @@ document.addEventListener("prenav", async () => {
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
|
||||
await setupExplorer(currentSlug)
|
||||
|
||||
// if mobile hamburger is visible, collapse by default
|
||||
|
||||
@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
v,
|
||||
]),
|
||||
)
|
||||
|
||||
const links: SimpleLinkData[] = []
|
||||
const tags: SimpleSlug[] = []
|
||||
const validLinks = new Set(data.keys())
|
||||
@ -575,6 +576,7 @@ function cleanupGlobalGraphs() {
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const slug = e.detail.url
|
||||
|
||||
addToVisited(simplifySlug(slug))
|
||||
|
||||
async function renderLocalGraph() {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { registerEscapeHandler, removeAllChildren, addRenderListener } from "./util"
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
@ -185,9 +185,11 @@ const cssVars = [
|
||||
] as const
|
||||
|
||||
let mermaidImport = undefined
|
||||
document.addEventListener("nav", async () => {
|
||||
const center = document.querySelector(".center") as HTMLElement
|
||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||
|
||||
addRenderListener(async (container: HTMLElement) => {
|
||||
const nodes = container.querySelectorAll(
|
||||
"code.mermaid:not([data-processed])",
|
||||
) as NodeListOf<HTMLElement>
|
||||
if (nodes.length === 0) return
|
||||
|
||||
mermaidImport ||= await import(
|
||||
@ -204,9 +206,9 @@ document.addEventListener("nav", async () => {
|
||||
async function renderMermaid() {
|
||||
// de-init any other diagrams
|
||||
for (const node of nodes) {
|
||||
node.removeAttribute("data-processed")
|
||||
const oldText = textMapping.get(node)
|
||||
if (oldText) {
|
||||
node.removeAttribute("data-processed")
|
||||
node.innerHTML = oldText
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
let activeAnchor: HTMLAnchorElement | null = null
|
||||
@ -37,6 +37,8 @@ async function mouseEnterHandler(
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
|
||||
dispatchRenderEvent(popoverInner)
|
||||
}
|
||||
|
||||
const targetUrl = new URL(link.href)
|
||||
@ -120,8 +122,9 @@ function clearActivePopover() {
|
||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
addRenderListener((element) => {
|
||||
const links = [...element.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
link.addEventListener("mouseleave", clearActivePopover)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { registerEscapeHandler, removeAllChildren, dispatchRenderEvent } from "./util"
|
||||
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||
import { contentDecryptedEventListener, decryptContent } from "../../util/encryption"
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
@ -88,6 +88,7 @@ const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
||||
const contextWindowWords = 30
|
||||
const numSearchResults = 8
|
||||
const numTagResults = 5
|
||||
const RENDER_DELAY_MS = 100
|
||||
|
||||
const tokenizeTerm = (term: string) => {
|
||||
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
||||
@ -309,10 +310,16 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
||||
|
||||
const formatForDisplay = (term: string, id: number) => {
|
||||
const slug = idDataMap[id]
|
||||
let title = data[slug].title
|
||||
|
||||
if (data[slug].encryptionResult) {
|
||||
title = (data[slug].decrypted ? "🔓 " : "🔒 ") + data[slug].title
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||
title: searchType === "tags" ? title : highlight(term, title ?? ""),
|
||||
content: highlight(term, data[slug].content ?? "", true),
|
||||
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||
}
|
||||
@ -433,6 +440,9 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||
)
|
||||
highlights[0]?.scrollIntoView({ block: "start" })
|
||||
await new Promise((resolve) => setTimeout(resolve, RENDER_DELAY_MS))
|
||||
|
||||
dispatchRenderEvent(previewInner)
|
||||
}
|
||||
|
||||
async function onType(e: HTMLElementEventMap["input"]) {
|
||||
@ -514,16 +524,54 @@ async function fillDocument(data: ContentIndex) {
|
||||
if (indexPopulated) return
|
||||
let id = 0
|
||||
const promises: Array<Promise<unknown>> = []
|
||||
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
||||
promises.push(
|
||||
index.addAsync(id++, {
|
||||
id,
|
||||
slug: slug as FullSlug,
|
||||
title: fileData.title,
|
||||
content: fileData.content,
|
||||
tags: fileData.tags,
|
||||
}),
|
||||
)
|
||||
for (const [slug, fileData] of Object.entries(data)) {
|
||||
if (fileData.encryptionResult) {
|
||||
let slugId = id
|
||||
promises.push(
|
||||
index.addAsync(id++, {
|
||||
id: id,
|
||||
slug: slug as FullSlug,
|
||||
title: fileData.title,
|
||||
content: "",
|
||||
tags: fileData.tags,
|
||||
}),
|
||||
)
|
||||
fileData.decrypted = false
|
||||
|
||||
promises.push(
|
||||
contentDecryptedEventListener(
|
||||
slug,
|
||||
fileData.hash!,
|
||||
fileData.encryptionConfig!,
|
||||
async (password) => {
|
||||
const decryptedContent = await decryptContent(
|
||||
fileData.encryptionResult!,
|
||||
fileData.encryptionConfig!,
|
||||
password,
|
||||
)
|
||||
fileData.decrypted = true
|
||||
|
||||
index.update(slugId++, {
|
||||
id: slugId,
|
||||
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)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import micromorph from "micromorph"
|
||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
import { fetchCanonical, dispatchRenderEvent } from "./util"
|
||||
|
||||
// adapted from `micromorph`
|
||||
// https://github.com/natemoo-re/micromorph
|
||||
@ -38,6 +38,9 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined
|
||||
function notifyNav(url: FullSlug) {
|
||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
// Also trigger render event for the whole document body
|
||||
dispatchRenderEvent(document.body)
|
||||
}
|
||||
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { addRenderListener } from "./util"
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
@ -34,11 +36,11 @@ function setupToc() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
addRenderListener((container: HTMLElement) => {
|
||||
setupToc()
|
||||
|
||||
// update toc entry highlighting
|
||||
observer.disconnect()
|
||||
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
||||
const headers = container.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
||||
headers.forEach((header) => observer.observe(header))
|
||||
})
|
||||
|
||||
@ -44,3 +44,16 @@ export async function fetchCanonical(url: URL): Promise<Response> {
|
||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||
}
|
||||
|
||||
export function addRenderListener(renderFn: (container: HTMLElement) => void) {
|
||||
document.addEventListener("render", (e: CustomEventMap["render"]) => {
|
||||
renderFn(e.detail.htmlElement)
|
||||
})
|
||||
}
|
||||
|
||||
export function dispatchRenderEvent(htmlElement: HTMLElement) {
|
||||
const event: CustomEventMap["render"] = new CustomEvent("render", {
|
||||
detail: { htmlElement },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
174
quartz/components/styles/encrypt.scss
Normal file
174
quartz/components/styles/encrypt.scss
Normal file
@ -0,0 +1,174 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.encrypted-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.encryption-notice {
|
||||
/* background: color-mix(in srgb, var(--lightgray) 60%, var(--light));
|
||||
*/
|
||||
border: 1px solid var(--lightgray);
|
||||
padding: 2rem 1.5rem 2rem;
|
||||
border-radius: 5px;
|
||||
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;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.decrypt-form {
|
||||
margin: 1rem 0 0 0;
|
||||
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);
|
||||
color: var(--dark);
|
||||
transition: border-color 0.2s ease;
|
||||
// background: var(--backgroundPrimary);
|
||||
|
||||
&: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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the decrypt form when encrypted content appears in popover and search
|
||||
.search-space,
|
||||
.popover {
|
||||
.encrypted-content .encryption-notice .decrypt-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.encryption-notice {
|
||||
/*
|
||||
border: 0;
|
||||
padding: 0;
|
||||
*/
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.decrypted-content-wrapper {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.encryption-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@ -65,6 +65,15 @@ export default {
|
||||
? `دقيقتان للقراءة`
|
||||
: `${minutes} دقائق للقراءة`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ محتوى محدود 🛡️",
|
||||
enterPassword: "ادخل كلمة المرور",
|
||||
decrypt: "فك التشفير",
|
||||
decrypting: "جاري فك التشفير...",
|
||||
incorrectPassword: "كلمة مرور خاطئة. حاول مرة أخرى.",
|
||||
decryptionFailed: "فشل فك التشفير، تحقق من السجلات",
|
||||
encryptedDescription: "هذا الملف مشفر. افتحه لرؤية المحتويات.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Contingut Restringit 🛡️",
|
||||
enterPassword: "Introduïu la contrasenya",
|
||||
decrypt: "Desxifrar",
|
||||
decrypting: "Desxifrant...",
|
||||
incorrectPassword: "Contrasenya incorrecta. Torneu-ho a intentar.",
|
||||
decryptionFailed: "Ha fallat el desxifratge, comproveu els registres",
|
||||
encryptedDescription: "Aquest fitxer està xifrat. Obriu-lo per veure els continguts.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min čtení`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Omezený obsah 🛡️",
|
||||
enterPassword: "Zadejte heslo",
|
||||
decrypt: "Dešifrovat",
|
||||
decrypting: "Dešifruji...",
|
||||
incorrectPassword: "Nesprávné heslo. Zkuste to znovu.",
|
||||
decryptionFailed: "Dešifrování selhalo, zkontrolujte protokoly",
|
||||
encryptedDescription: "Tento soubor je zašifrován. Otevřete jej pro zobrazení obsahu.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,16 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} Min. Lesezeit`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Eingeschränkter Inhalt 🛡️",
|
||||
enterPassword: "Passwort eingeben",
|
||||
decrypt: "Entschlüsseln",
|
||||
decrypting: "Entschlüsselt...",
|
||||
incorrectPassword: "Falsches Passwort. Bitte versuchen Sie es erneut.",
|
||||
decryptionFailed: "Entschlüsselung fehlgeschlagen, überprüfen Sie die Protokolle",
|
||||
encryptedDescription:
|
||||
"Diese Datei ist verschlüsselt. Öffnen Sie sie, um den Inhalt zu sehen.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -62,6 +62,15 @@ export interface Translation {
|
||||
contentMeta: {
|
||||
readingTime: (variables: { minutes: number }) => string
|
||||
}
|
||||
encryption: {
|
||||
title: string
|
||||
enterPassword: string
|
||||
decrypt: string
|
||||
decrypting: string
|
||||
incorrectPassword: string
|
||||
decryptionFailed: string
|
||||
encryptedDescription: string
|
||||
}
|
||||
}
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Restricted Content 🛡️",
|
||||
enterPassword: "Enter password",
|
||||
decrypt: "Decrypt",
|
||||
decrypting: "Decrypting...",
|
||||
incorrectPassword: "Incorrect password. Please try again.",
|
||||
decryptionFailed: "Decryption failed, check logs",
|
||||
encryptedDescription: "This file is encrypted. Open it to see the contents.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Restricted Content 🛡️",
|
||||
enterPassword: "Enter password",
|
||||
decrypt: "Decrypt",
|
||||
decrypting: "Decrypting...",
|
||||
incorrectPassword: "Incorrect password. Please try again.",
|
||||
decryptionFailed: "Decryption failed, check logs",
|
||||
encryptedDescription: "This file is encrypted. Open it to see the contents.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `Se lee en ${minutes} min`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Contenido Restringido 🛡️",
|
||||
enterPassword: "Ingrese contraseña",
|
||||
decrypt: "Desencriptar",
|
||||
decrypting: "Desencriptando...",
|
||||
incorrectPassword: "Contraseña incorrecta. Intente de nuevo.",
|
||||
decryptionFailed: "Desencriptación falló, revise los registros",
|
||||
encryptedDescription: "Este archivo está encriptado. Ábralo para ver los contenidos.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -60,6 +60,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ محتوای محدود 🛡️",
|
||||
enterPassword: "رمز عبور را وارد کنید",
|
||||
decrypt: "رمزگشایی",
|
||||
decrypting: "در حال رمزگشایی...",
|
||||
incorrectPassword: "رمز عبور اشتباه است. دوباره تلاش کنید.",
|
||||
decryptionFailed: "رمزگشایی ناموفق، لاگها را بررسی کنید",
|
||||
encryptedDescription: "این فایل رمزگذاری شده است. آن را باز کنید تا محتوا را ببینید.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min lukuaika`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Rajoitettu Sisältö 🛡️",
|
||||
enterPassword: "Anna salasana",
|
||||
decrypt: "Pura salaus",
|
||||
decrypting: "Puretaan salausta...",
|
||||
incorrectPassword: "Väärä salasana. Yritä uudelleen.",
|
||||
decryptionFailed: "Salauksen purku epäonnistui, tarkista lokit",
|
||||
encryptedDescription: "Tämä tiedosto on salattu. Avaa se nähdäksesi sisällön.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min de lecture`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Contenu Restreint 🛡️",
|
||||
enterPassword: "Entrez le mot de passe",
|
||||
decrypt: "Déchiffrer",
|
||||
decrypting: "Déchiffrement...",
|
||||
incorrectPassword: "Mot de passe incorrect. Veuillez réessayer.",
|
||||
decryptionFailed: "Échec du déchiffrement, vérifiez les journaux",
|
||||
encryptedDescription: "Ce fichier est chiffré. Ouvrez-le pour voir le contenu.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} perces olvasás`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Korlátozott Tartalom 🛡️",
|
||||
enterPassword: "Jelszó megadása",
|
||||
decrypt: "Visszafejtés",
|
||||
decrypting: "Visszafejtés...",
|
||||
incorrectPassword: "Helytelen jelszó. Kérjük, próbálja újra.",
|
||||
decryptionFailed: "A visszafejtés sikertelen, ellenőrizze a naplókat",
|
||||
encryptedDescription: "Ez a fájl titkosított. Nyissa meg a tartalom megtekintéséhez.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} menit baca`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Konten Terbatas 🛡️",
|
||||
enterPassword: "Masukkan kata sandi",
|
||||
decrypt: "Dekripsi",
|
||||
decrypting: "Mendekripsi...",
|
||||
incorrectPassword: "Kata sandi salah. Silakan coba lagi.",
|
||||
decryptionFailed: "Dekripsi gagal, periksa log",
|
||||
encryptedDescription: "File ini terenkripsi. Buka untuk melihat konten.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -60,6 +60,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => (minutes === 1 ? "1 minuto" : `${minutes} minuti`),
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Contenuto Riservato 🛡️",
|
||||
enterPassword: "Inserisci password",
|
||||
decrypt: "Decripta",
|
||||
decrypting: "Decriptando...",
|
||||
incorrectPassword: "Password errata. Riprova.",
|
||||
decryptionFailed: "Decriptazione fallita, controlla i log",
|
||||
encryptedDescription: "Questo file è criptato. Aprilo per vedere i contenuti.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ 制限されたコンテンツ 🛡️",
|
||||
enterPassword: "パスワードを入力",
|
||||
decrypt: "復号化",
|
||||
decrypting: "復号化中...",
|
||||
incorrectPassword: "パスワードが間違っています。もう一度お試しください。",
|
||||
decryptionFailed: "復号化に失敗しました。ログを確認してください",
|
||||
encryptedDescription: "このファイルは暗号化されています。内容を見るには開いてください。",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ 제한된 콘텐츠 🛡️",
|
||||
enterPassword: "비밀번호 입력",
|
||||
decrypt: "복호화",
|
||||
decrypting: "복호화 중...",
|
||||
incorrectPassword: "비밀번호가 틀렸습니다. 다시 시도해주세요.",
|
||||
decryptionFailed: "복호화에 실패했습니다. 로그를 확인하세요",
|
||||
encryptedDescription: "이 파일은 암호화되어 있습니다. 내용을 보려면 열어주세요.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min skaitymo`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Apribotas Turinys 🛡️",
|
||||
enterPassword: "Įvesti slaptažodį",
|
||||
decrypt: "Iššifruoti",
|
||||
decrypting: "Iššifruojama...",
|
||||
incorrectPassword: "Neteisingas slaptažodis. Bandykite dar kartą.",
|
||||
decryptionFailed: "Iššifravimas nepavyko, patikrinkite žurnalus",
|
||||
encryptedDescription: "Šis failas yra užšifruotas. Atidarykite jį, kad pamatytumėte turinį.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min lesning`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Begrenset Innhold 🛡️",
|
||||
enterPassword: "Skriv inn passord",
|
||||
decrypt: "Dekrypter",
|
||||
decrypting: "Dekrypterer...",
|
||||
incorrectPassword: "Feil passord. Prøv igjen.",
|
||||
decryptionFailed: "Dekryptering mislyktes, sjekk logger",
|
||||
encryptedDescription: "Denne filen er kryptert. Åpne den for å se innholdet.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -60,6 +60,15 @@ export default {
|
||||
readingTime: ({ minutes }) =>
|
||||
minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Beperkte Inhoud 🛡️",
|
||||
enterPassword: "Voer wachtwoord in",
|
||||
decrypt: "Ontsleutelen",
|
||||
decrypting: "Ontsleutelen...",
|
||||
incorrectPassword: "Onjuist wachtwoord. Probeer opnieuw.",
|
||||
decryptionFailed: "Ontsleuteling mislukt, controleer logs",
|
||||
encryptedDescription: "Dit bestand is versleuteld. Open het om de inhoud te zien.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min. czytania `,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Ograniczona Treść 🛡️",
|
||||
enterPassword: "Wprowadź hasło",
|
||||
decrypt: "Odszyfruj",
|
||||
decrypting: "Odszyfrowywanie...",
|
||||
incorrectPassword: "Nieprawidłowe hasło. Spróbuj ponownie.",
|
||||
decryptionFailed: "Odszyfrowanie nie powiodło się, sprawdź logi",
|
||||
encryptedDescription: "Ten plik jest zaszyfrowany. Otwórz go, aby zobaczyć zawartość.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `Leitura de ${minutes} min`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Conteúdo Restrito 🛡️",
|
||||
enterPassword: "Digite a senha",
|
||||
decrypt: "Descriptografar",
|
||||
decrypting: "Descriptografando...",
|
||||
incorrectPassword: "Senha incorreta. Tente novamente.",
|
||||
decryptionFailed: "Descriptografia falhou, verifique os logs",
|
||||
encryptedDescription: "Este arquivo está criptografado. Abra-o para ver o conteúdo.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -60,6 +60,15 @@ export default {
|
||||
readingTime: ({ minutes }) =>
|
||||
minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Conținut Restricționat 🛡️",
|
||||
enterPassword: "Introduceți parola",
|
||||
decrypt: "Decriptați",
|
||||
decrypting: "Se decriptează...",
|
||||
incorrectPassword: "Parolă incorectă. Încercați din nou.",
|
||||
decryptionFailed: "Decriptarea a eșuat, verificați jurnalele",
|
||||
encryptedDescription: "Acest fișier este criptat. Deschideți-l pentru a vedea conținutul.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -60,6 +60,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Ограниченный Контент 🛡️",
|
||||
enterPassword: "Введите пароль",
|
||||
decrypt: "Расшифровать",
|
||||
decrypting: "Расшифровка...",
|
||||
incorrectPassword: "Неверный пароль. Попробуйте снова.",
|
||||
decryptionFailed: "Расшифровка не удалась, проверьте логи",
|
||||
encryptedDescription: "Этот файл зашифрован. Откройте его, чтобы увидеть содержимое.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ เนื้อหาถูกจำกัด 🛡️",
|
||||
enterPassword: "ใส่รหัสผ่าน",
|
||||
decrypt: "ถอดรหัส",
|
||||
decrypting: "กำลังถอดรหัส...",
|
||||
incorrectPassword: "รหัสผ่านไม่ถูกต้อง กรุณาลองใหม่",
|
||||
decryptionFailed: "การถอดรหัสล้มเหลว ตรวจสอบบันทึก",
|
||||
encryptedDescription: "ไฟล์นี้ถูกเข้ารหัส เปิดเพื่อดูเนื้อหา",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Kısıtlı İçerik 🛡️",
|
||||
enterPassword: "Şifreyi girin",
|
||||
decrypt: "Şifreyi Çöz",
|
||||
decrypting: "Şifre çözülüyor...",
|
||||
incorrectPassword: "Yanlış şifre. Tekrar deneyin.",
|
||||
decryptionFailed: "Şifre çözme başarısız, logları kontrol edin",
|
||||
encryptedDescription: "Bu dosya şifrelenmiş. İçeriği görmek için açın.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} хв читання`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Обмежений Контент 🛡️",
|
||||
enterPassword: "Введіть пароль",
|
||||
decrypt: "Розшифрувати",
|
||||
decrypting: "Розшифрування...",
|
||||
incorrectPassword: "Неправильний пароль. Спробуйте ще раз.",
|
||||
decryptionFailed: "Розшифрування не вдалося, перевірте журнали",
|
||||
encryptedDescription: "Цей файл зашифрований. Відкрийте його, щоб побачити вміст.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} phút đọc`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ Nội Dung Bị Hạn Chế 🛡️",
|
||||
enterPassword: "Nhập mật khẩu",
|
||||
decrypt: "Giải mã",
|
||||
decrypting: "Đang giải mã...",
|
||||
incorrectPassword: "Mật khẩu sai. Vui lòng thử lại.",
|
||||
decryptionFailed: "Giải mã thất bại, kiểm tra nhật ký",
|
||||
encryptedDescription: "Tệp này đã được mã hóa. Mở để xem nội dung.",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes}分钟阅读`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ 受限内容 🛡️",
|
||||
enterPassword: "输入密码",
|
||||
decrypt: "解密",
|
||||
decrypting: "解密中...",
|
||||
incorrectPassword: "密码错误。请重试。",
|
||||
decryptionFailed: "解密失败,请检查日志",
|
||||
encryptedDescription: "此文件已加密。打开以查看内容。",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -59,6 +59,15 @@ export default {
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
|
||||
},
|
||||
encryption: {
|
||||
title: "🛡️ 受限內容 🛡️",
|
||||
enterPassword: "輸入密碼",
|
||||
decrypt: "解密",
|
||||
decrypting: "解密中...",
|
||||
incorrectPassword: "密碼錯誤。請重試。",
|
||||
decryptionFailed: "解密失敗,請檢查日誌",
|
||||
encryptedDescription: "此檔案已加密。打開以檢視內容。",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
|
||||
@ -261,6 +261,8 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
||||
window.addCleanup = () => {}
|
||||
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||
document.dispatchEvent(event)
|
||||
const renderEvent = new CustomEvent("render", { detail: { htmlElement: document.body } })
|
||||
document.dispatchEvent(renderEvent)
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@ import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption"
|
||||
|
||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||
|
||||
// Base content details without encryption-specific fields
|
||||
export type ContentDetails = {
|
||||
slug: FullSlug
|
||||
filePath: FilePath
|
||||
@ -19,6 +22,9 @@ export type ContentDetails = {
|
||||
richContent?: string
|
||||
date?: Date
|
||||
description?: string
|
||||
encryptionConfig?: CompleteCryptoConfig
|
||||
hash?: Hash
|
||||
encryptionResult?: EncryptionResult
|
||||
}
|
||||
|
||||
interface Options {
|
||||
@ -103,19 +109,25 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
const slug = file.data.slug!
|
||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||
linkIndex.set(slug, {
|
||||
const contentDetails: ContentDetails = {
|
||||
slug,
|
||||
filePath: file.data.relativePath!,
|
||||
title: file.data.frontmatter?.title!,
|
||||
links: file.data.links ?? [],
|
||||
tags: file.data.frontmatter?.tags ?? [],
|
||||
content: file.data.text ?? "",
|
||||
richContent: opts?.rssFullHtml
|
||||
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||
: undefined,
|
||||
richContent:
|
||||
opts?.rssFullHtml && !file.data.encryptionResult
|
||||
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||
: undefined,
|
||||
date: date,
|
||||
description: file.data.description ?? "",
|
||||
})
|
||||
encryptionConfig: file.data.encryptionConfig,
|
||||
hash: file.data.hash,
|
||||
encryptionResult: file.data.encryptionResult,
|
||||
}
|
||||
|
||||
linkIndex.set(slug, contentDetails)
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,6 +155,14 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
// remove description and from content index as nothing downstream
|
||||
// actually uses it. we only keep it in the index as we need it
|
||||
// for the RSS feed
|
||||
if (content.encryptionResult) {
|
||||
delete content.richContent
|
||||
} else {
|
||||
delete content.hash
|
||||
delete content.encryptionConfig
|
||||
delete content.encryptionResult
|
||||
}
|
||||
|
||||
delete content.description
|
||||
delete content.date
|
||||
return [slug, content]
|
||||
|
||||
@ -2,6 +2,7 @@ import { Root as HTMLRoot } from "hast"
|
||||
import { toString } from "hast-util-to-string"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { escapeHTML } from "../../util/escape"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export interface Options {
|
||||
descriptionLength: number
|
||||
@ -24,10 +25,17 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "Description",
|
||||
htmlPlugins() {
|
||||
htmlPlugins(ctx) {
|
||||
return [
|
||||
() => {
|
||||
return async (tree: HTMLRoot, file) => {
|
||||
if (file.data.encryptionConfig) {
|
||||
file.data.description =
|
||||
file.data.encryptionConfig.message ||
|
||||
i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription
|
||||
return
|
||||
}
|
||||
|
||||
let frontMatterDescription = file.data.frontmatter?.description
|
||||
let text = escapeHTML(toString(tree))
|
||||
|
||||
|
||||
351
quartz/plugins/transformers/encrypt.ts
Normal file
351
quartz/plugins/transformers/encrypt.ts
Normal file
@ -0,0 +1,351 @@
|
||||
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"
|
||||
import { i18n } from "../../i18n"
|
||||
import {
|
||||
SUPPORTED_ALGORITHMS,
|
||||
encryptContent,
|
||||
Hash,
|
||||
hashString,
|
||||
EncryptionResult,
|
||||
CompleteCryptoConfig,
|
||||
BaseCryptoConfig,
|
||||
EncryptionConfig,
|
||||
DirectoryConfig,
|
||||
} from "../../util/encryption"
|
||||
|
||||
// @ts-ignore
|
||||
import encryptScript from "../../components/scripts/encrypt.inline.ts"
|
||||
import encryptStyle from "../../components/styles/encrypt.scss"
|
||||
|
||||
// =============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// =============================================================================
|
||||
// Plugin configuration
|
||||
export interface PluginConfig extends CompleteCryptoConfig {
|
||||
encryptedFolders: Record<string, DirectoryConfig>
|
||||
}
|
||||
|
||||
// User-provided options (all optional)
|
||||
export type PluginOptions = Partial<PluginConfig>
|
||||
|
||||
// Internal normalized folder configuration
|
||||
interface NormalizedFolderConfig extends CompleteCryptoConfig {
|
||||
password: string
|
||||
path: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION MANAGEMENT
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_CONFIG: CompleteCryptoConfig = {
|
||||
algorithm: "aes-256-cbc",
|
||||
ttl: 3600 * 24 * 7, // 1 week
|
||||
message: "This content is encrypted.",
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a directory configuration into a complete configuration object
|
||||
*/
|
||||
function normalizeDirectoryConfig(
|
||||
dirConfig: DirectoryConfig,
|
||||
defaults: CompleteCryptoConfig,
|
||||
): EncryptionConfig {
|
||||
if (typeof dirConfig === "string") {
|
||||
return {
|
||||
...defaults,
|
||||
password: dirConfig,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm: dirConfig.algorithm ?? defaults.algorithm,
|
||||
ttl: dirConfig.ttl ?? defaults.ttl,
|
||||
message: dirConfig.message ?? defaults.message,
|
||||
password: dirConfig.password,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sorted list of folder configurations by path depth
|
||||
* This ensures parent configurations are processed before children
|
||||
*/
|
||||
function createFolderConfigHierarchy(
|
||||
folders: Record<string, DirectoryConfig>,
|
||||
globalDefaults: CompleteCryptoConfig,
|
||||
): NormalizedFolderConfig[] {
|
||||
const configs: NormalizedFolderConfig[] = []
|
||||
|
||||
for (const [path, config] of Object.entries(folders)) {
|
||||
const normalized = normalizeDirectoryConfig(config, globalDefaults)
|
||||
configs.push({
|
||||
...normalized,
|
||||
path: path.endsWith("/") ? path : path + "/",
|
||||
depth: path.split("/").filter(Boolean).length,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by depth (shallow to deep) to ensure proper inheritance
|
||||
return configs.sort((a, b) => a.depth - b.depth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges configurations following the inheritance chain
|
||||
* Deeper paths override shallower ones
|
||||
*/
|
||||
function mergeConfigurations(
|
||||
base: CompleteCryptoConfig,
|
||||
override: Partial<BaseCryptoConfig>,
|
||||
): CompleteCryptoConfig {
|
||||
return {
|
||||
algorithm: override.algorithm ?? base.algorithm,
|
||||
ttl: override.ttl ?? base.ttl,
|
||||
message: override.message ?? base.message,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the encryption configuration for a specific file path
|
||||
* Respects the directory hierarchy and inheritance
|
||||
*/
|
||||
function getConfigurationForPath(
|
||||
filePath: string,
|
||||
folderConfigs: NormalizedFolderConfig[],
|
||||
globalDefaults: CompleteCryptoConfig,
|
||||
): EncryptionConfig | undefined {
|
||||
let currentConfig: CompleteCryptoConfig = globalDefaults
|
||||
let password: string | undefined
|
||||
|
||||
// Apply configurations in order (shallow to deep)
|
||||
for (const folderConfig of folderConfigs) {
|
||||
if (filePath.startsWith(folderConfig.path)) {
|
||||
// Merge the configuration, inheriting from parent
|
||||
currentConfig = mergeConfigurations(currentConfig, folderConfig)
|
||||
password = folderConfig.password
|
||||
}
|
||||
}
|
||||
|
||||
// If no password was found in any matching folder, return undefined
|
||||
if (!password) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...currentConfig,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the plugin configuration
|
||||
*/
|
||||
function validateConfig(config: EncryptionConfig, file: VFile | null = null): void {
|
||||
let suffixedPath = ""
|
||||
|
||||
if (file && file.data && file.data.relativePath) {
|
||||
suffixedPath = `(in file: ${file.data.relativePath})`
|
||||
}
|
||||
|
||||
if (!SUPPORTED_ALGORITHMS.includes(config.algorithm)) {
|
||||
throw new Error(
|
||||
`[EncryptPlugin] Unsupported encryption algorithm: ${config.algorithm}. ` +
|
||||
`Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")} ${suffixedPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (config.ttl < 0) {
|
||||
throw new Error(`[EncryptPlugin] TTL cannot be negative. ${suffixedPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PLUGIN IMPLEMENTATION
|
||||
// =============================================================================
|
||||
|
||||
export const Encrypt: QuartzTransformerPlugin<PluginOptions> = (userOpts) => {
|
||||
// Merge user options with defaults
|
||||
const pluginConfig: PluginConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...userOpts,
|
||||
encryptedFolders: userOpts?.encryptedFolders ?? {},
|
||||
}
|
||||
|
||||
// Pre-process folder configurations for efficient lookup
|
||||
const folderConfigs = createFolderConfigHierarchy(pluginConfig.encryptedFolders, pluginConfig)
|
||||
|
||||
/**
|
||||
* Determines the final encryption configuration for a file
|
||||
* Priority: frontmatter > deepest matching folder > global defaults
|
||||
*/
|
||||
const getEncryptionConfig = (file: VFile): EncryptionConfig | undefined => {
|
||||
const frontmatter = file.data?.frontmatter
|
||||
const relativePath = file.data?.relativePath
|
||||
|
||||
// Check if file should be encrypted via frontmatter
|
||||
const shouldEncryptViaFrontmatter = frontmatter?.encrypt === true
|
||||
const frontmatterConfig = frontmatter?.encryptConfig as
|
||||
| (BaseCryptoConfig & { password?: string })
|
||||
| undefined
|
||||
|
||||
// Get folder-based configuration
|
||||
const folderConfig = relativePath
|
||||
? getConfigurationForPath(relativePath, folderConfigs, pluginConfig)
|
||||
: undefined
|
||||
|
||||
// If neither folder config nor frontmatter indicates encryption, skip
|
||||
if (!folderConfig && !shouldEncryptViaFrontmatter) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Build final configuration with proper precedence
|
||||
let finalConfig: EncryptionConfig | undefined
|
||||
|
||||
if (folderConfig && frontmatterConfig) {
|
||||
// Merge folder and frontmatter configs
|
||||
finalConfig = {
|
||||
algorithm: frontmatterConfig.algorithm ?? folderConfig.algorithm,
|
||||
ttl: frontmatterConfig.ttl ?? folderConfig.ttl,
|
||||
message: frontmatterConfig.message ?? folderConfig.message,
|
||||
password: frontmatterConfig.password ?? folderConfig.password,
|
||||
}
|
||||
} else if (folderConfig) {
|
||||
// Use folder config only
|
||||
finalConfig = folderConfig
|
||||
} else if (frontmatterConfig?.password) {
|
||||
// Use frontmatter config with global defaults
|
||||
finalConfig = {
|
||||
algorithm: frontmatterConfig.algorithm ?? pluginConfig.algorithm,
|
||||
ttl: frontmatterConfig.ttl ?? pluginConfig.ttl,
|
||||
message: frontmatterConfig.message ?? pluginConfig.message,
|
||||
password: frontmatterConfig.password,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate final configuration
|
||||
if (!finalConfig?.password) {
|
||||
console.warn(`[EncryptPlugin] No password configured for ${relativePath}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return finalConfig
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Encrypt",
|
||||
markdownPlugins() {
|
||||
return [
|
||||
() => {
|
||||
return async (_, file) => {
|
||||
const config = getEncryptionConfig(file)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
// Validate configuration
|
||||
validateConfig(config, file)
|
||||
|
||||
file.data.encryptionConfig = config
|
||||
file.data.hash = await hashString(config.password)
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
htmlPlugins(ctx) {
|
||||
return [
|
||||
() => {
|
||||
return async (tree: Root, file) => {
|
||||
const config = getEncryptionConfig(file)
|
||||
if (!config || !file.data.hash) {
|
||||
return tree
|
||||
}
|
||||
|
||||
const locale = ctx.cfg.configuration.locale
|
||||
const t = i18n(locale).components.encryption
|
||||
|
||||
// Encrypt the content
|
||||
const encryptionResult = await encryptContent(toHtml(tree), config.password, config)
|
||||
|
||||
// Store for later use
|
||||
file.data.encryptionResult = encryptionResult
|
||||
|
||||
// Create attributes for client-side decryption
|
||||
const attributes = [
|
||||
`data-config='${JSON.stringify({
|
||||
algorithm: config.algorithm,
|
||||
ttl: config.ttl,
|
||||
message: config.message,
|
||||
})}'`,
|
||||
`data-encrypted='${JSON.stringify(encryptionResult)}'`,
|
||||
`data-hash='${JSON.stringify(file.data.hash)}'`,
|
||||
`data-slug='${file.data.slug}'`,
|
||||
`data-decrypted='false'`,
|
||||
].join(" ")
|
||||
|
||||
// Create encrypted content placeholder
|
||||
const encryptedTree = fromHtml(
|
||||
`
|
||||
<div class="encrypted-content" ${attributes}>
|
||||
<div class="encryption-notice">
|
||||
<h3>${t.title}</h3>
|
||||
${config.message ? `<p>${config.message}</p>` : ""}
|
||||
<div class="decrypt-form">
|
||||
<input type="password" class="decrypt-password" placeholder="${t.enterPassword}" />
|
||||
<button class="decrypt-button">${t.decrypt}</button>
|
||||
</div>
|
||||
<div class="decrypt-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>${t.decrypting}</span>
|
||||
</div>
|
||||
<div class="decrypt-error" data-error="incorrect-password">
|
||||
${t.incorrectPassword}
|
||||
</div>
|
||||
<div class="decrypt-error" data-error="decryption-failed">
|
||||
${t.decryptionFailed}
|
||||
</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,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODULE AUGMENTATION
|
||||
// =============================================================================
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
encryptionConfig: EncryptionConfig
|
||||
encryptionResult: EncryptionResult
|
||||
hash: Hash
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import toml from "toml"
|
||||
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import { EncryptionConfig } from "../../util/encryption"
|
||||
|
||||
export interface Options {
|
||||
delimiters: string | [string, string]
|
||||
@ -119,6 +120,22 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
||||
|
||||
if (socialImage) data.socialImage = socialImage
|
||||
|
||||
const encrypted = coalesceAliases(data, ["encrypted", "encrypt"])
|
||||
if (encrypted) data.encrypt = true
|
||||
|
||||
const password = coalesceAliases(data, ["password"])
|
||||
if (password) data.encryptConfig = { password: password }
|
||||
|
||||
const encryptConfig = coalesceAliases(data, ["encryptConfig", "encrypt_config"])
|
||||
if (encryptConfig && typeof encryptConfig === "object") {
|
||||
data.encryptConfig = {
|
||||
password: encryptConfig.password || password,
|
||||
message: encryptConfig.message || undefined,
|
||||
algorithm: encryptConfig.algorithm || undefined,
|
||||
ttl: encryptConfig.ttl || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate slugs
|
||||
const uniqueSlugs = [...new Set(allSlugs)]
|
||||
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
|
||||
@ -152,6 +169,8 @@ declare module "vfile" {
|
||||
cssclasses: string[]
|
||||
socialImage: string
|
||||
comments: boolean | string
|
||||
encrypt: boolean
|
||||
encryptConfig: EncryptionConfig
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ export { CrawlLinks } from "./links"
|
||||
export { ObsidianFlavoredMarkdown } from "./ofm"
|
||||
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
|
||||
export { SyntaxHighlighting } from "./syntax"
|
||||
export { Encrypt } from "./encrypt"
|
||||
export { TableOfContents } from "./toc"
|
||||
export { HardLineBreaks } from "./linebreaks"
|
||||
export { RoamFlavoredMarkdown } from "./roam"
|
||||
|
||||
527
quartz/util/encryption.ts
Normal file
527
quartz/util/encryption.ts
Normal file
@ -0,0 +1,527 @@
|
||||
export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm"] as const
|
||||
|
||||
export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number]
|
||||
|
||||
// Result of hash operation
|
||||
export interface Hash {
|
||||
hash: string
|
||||
salt: string
|
||||
}
|
||||
|
||||
// Result of encryption operation
|
||||
export interface EncryptionResult {
|
||||
encryptedContent: string
|
||||
encryptionSalt: string
|
||||
iv?: string
|
||||
authTag?: string
|
||||
}
|
||||
|
||||
// Base crypto configuration without password
|
||||
export interface BaseCryptoConfig {
|
||||
algorithm?: SupportedEncryptionAlgorithm
|
||||
ttl?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Directory configuration can be partial or just a password string
|
||||
export type DirectoryConfig = (BaseCryptoConfig & { password: string }) | string
|
||||
|
||||
// Complete crypto configuration with all required fields
|
||||
export interface CompleteCryptoConfig {
|
||||
algorithm: SupportedEncryptionAlgorithm
|
||||
ttl: number
|
||||
message: string
|
||||
}
|
||||
|
||||
// Encryption configuration includes password
|
||||
export interface EncryptionConfig extends CompleteCryptoConfig {
|
||||
password: string
|
||||
}
|
||||
|
||||
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(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(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.substring(i * 2, 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: CompleteCryptoConfig,
|
||||
): 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 = crypto.getRandomValues(new Uint8Array(16))
|
||||
|
||||
// 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 {
|
||||
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,
|
||||
iv: arrayBufferToHex(initializationVector.buffer as ArrayBuffer),
|
||||
}
|
||||
|
||||
if (authTag) {
|
||||
result.authTag = arrayBufferToHex(authTag)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function decryptContent(
|
||||
encrypted: EncryptionResult,
|
||||
config: CompleteCryptoConfig,
|
||||
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 {
|
||||
throw new Error("Unsupported algorithm: " + config.algorithm)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
|
||||
return arrayBufferToString(decryptedBuffer)
|
||||
}
|
||||
|
||||
export async function searchForValidPassword(
|
||||
filePath: string,
|
||||
hash: Hash,
|
||||
config: CompleteCryptoConfig,
|
||||
blacklist: Set<string> | null = null,
|
||||
): Promise<string | undefined> {
|
||||
const passwords = getRelevantPasswords(filePath)
|
||||
|
||||
for (const password of passwords) {
|
||||
if (blacklist && blacklist.has(password)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (await verifyPasswordHash(password, hash)) {
|
||||
addPasswordToCache(password, filePath, config.ttl)
|
||||
return password
|
||||
}
|
||||
|
||||
if (blacklist) {
|
||||
blacklist.add(password)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 || cache[filePath].ttl === 0)) {
|
||||
// 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: CompleteCryptoConfig,
|
||||
callback: (password: string) => void,
|
||||
) {
|
||||
const blacklist = new Set<string>()
|
||||
|
||||
async function decryptionSuccessful(password: string) {
|
||||
addPasswordToCache(password, filePath, config.ttl)
|
||||
callback(password)
|
||||
}
|
||||
|
||||
async function listener(e: CustomEventMap["decrypt"]) {
|
||||
const password = e.detail.password
|
||||
|
||||
if (blacklist.has(password)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await verifyPasswordHash(password, hash)) {
|
||||
document.removeEventListener("decrypt", listener)
|
||||
await decryptionSuccessful(password)
|
||||
return
|
||||
} else {
|
||||
blacklist.add(password)
|
||||
}
|
||||
}
|
||||
|
||||
const password = await searchForValidPassword(filePath, hash, config, blacklist)
|
||||
if (password) {
|
||||
callback(password)
|
||||
}
|
||||
|
||||
document.addEventListener("decrypt", listener)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user