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`)
|
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`)
|
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.
|
3. Once the page is done loading, the page will dispatch two custom synthetic browser events:
|
||||||
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.
|
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.
|
||||||
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.
|
- 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]].
|
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).
|
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
|
```ts
|
||||||
document.addEventListener("nav", () => {
|
document.addEventListener("nav", (e) => {
|
||||||
// do page specific logic here
|
// runs only on page navigation
|
||||||
// e.g. attach event listeners
|
// e.detail.url contains the new page URL
|
||||||
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
|
const currentUrl = e.detail.url
|
||||||
toggleSwitch.addEventListener("change", switchTheme)
|
console.log(`Navigated to: ${currentUrl}`)
|
||||||
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`
|
- `published`
|
||||||
- `publishDate`
|
- `publishDate`
|
||||||
- `date`
|
- `date`
|
||||||
|
- encrypt
|
||||||
|
- `encrypt`
|
||||||
|
- `encrypted`
|
||||||
|
- encryptConfig
|
||||||
|
- Overrides for the [[plugins/Encrypt|encryptConfig]]
|
||||||
|
- password
|
||||||
|
- `password`
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
8
index.d.ts
vendored
8
index.d.ts
vendored
@ -7,9 +7,15 @@ declare module "*.scss" {
|
|||||||
interface CustomEventMap {
|
interface CustomEventMap {
|
||||||
prenav: CustomEvent<{}>
|
prenav: CustomEvent<{}>
|
||||||
nav: CustomEvent<{ url: FullSlug }>
|
nav: CustomEvent<{ url: FullSlug }>
|
||||||
|
render: CustomEvent<{ htmlElement: HTMLElement }>
|
||||||
|
decrypt: CustomEvent<{ filePath: FullSlug; password: string }>
|
||||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentIndex = Record<FullSlug, ContentDetails>
|
type DecryptedFlag = {
|
||||||
|
decrypted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentIndex = Record<FullSlug, ContentDetails & DecryptedFlag>
|
||||||
declare const fetchData: Promise<ContentIndex>
|
declare const fetchData: Promise<ContentIndex>
|
||||||
|
|||||||
@ -70,8 +70,13 @@ const config: QuartzConfig = {
|
|||||||
Plugin.GitHubFlavoredMarkdown(),
|
Plugin.GitHubFlavoredMarkdown(),
|
||||||
Plugin.TableOfContents(),
|
Plugin.TableOfContents(),
|
||||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||||
Plugin.Description(),
|
|
||||||
Plugin.Latex({ renderEngine: "katex" }),
|
Plugin.Latex({ renderEngine: "katex" }),
|
||||||
|
Plugin.Encrypt({
|
||||||
|
algorithm: "aes-256-cbc",
|
||||||
|
encryptedFolders: {},
|
||||||
|
ttl: 3600 * 24 * 7, // A week
|
||||||
|
}),
|
||||||
|
Plugin.Description(),
|
||||||
],
|
],
|
||||||
filters: [Plugin.RemoveDrafts()],
|
filters: [Plugin.RemoveDrafts()],
|
||||||
emitters: [
|
emitters: [
|
||||||
|
|||||||
@ -4,7 +4,12 @@ import { classNames } from "../util/lang"
|
|||||||
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||||
const title = fileData.frontmatter?.title
|
const title = fileData.frontmatter?.title
|
||||||
if (title) {
|
if (title) {
|
||||||
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
return (
|
||||||
|
<h1 class={classNames(displayClass, "article-title")}>
|
||||||
|
{fileData.encryptionResult && <span className="article-title-icon">🔒 </span>}
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const defaultOptions: Options = {
|
|||||||
return node
|
return node
|
||||||
},
|
},
|
||||||
sortFn: (a, b) => {
|
sortFn: (a, b) => {
|
||||||
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||||
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||||
@ -146,7 +146,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<button class="folder-button">
|
<button class="folder-button">
|
||||||
<span class="folder-title"></span>
|
<span class="folder-title folder-title-text"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export default ((opts?: Partial<Options>) => {
|
|||||||
displayClass,
|
displayClass,
|
||||||
cfg,
|
cfg,
|
||||||
}: QuartzComponentProps) => {
|
}: QuartzComponentProps) => {
|
||||||
if (!fileData.toc) {
|
if (!fileData.toc || fileData.encryptionResult) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ export default ((opts?: Partial<Options>) => {
|
|||||||
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||||
|
|
||||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||||
if (!fileData.toc) {
|
if (!fileData.toc || fileData.encryptionResult) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { addRenderListener } from "./util"
|
||||||
|
|
||||||
function toggleCallout(this: HTMLElement) {
|
function toggleCallout(this: HTMLElement) {
|
||||||
const outerBlock = this.parentElement!
|
const outerBlock = this.parentElement!
|
||||||
outerBlock.classList.toggle("is-collapsed")
|
outerBlock.classList.toggle("is-collapsed")
|
||||||
@ -7,8 +9,8 @@ function toggleCallout(this: HTMLElement) {
|
|||||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupCallout() {
|
function setupCallout(container: HTMLElement) {
|
||||||
const collapsible = document.getElementsByClassName(
|
const collapsible = container.getElementsByClassName(
|
||||||
`callout is-collapsible`,
|
`callout is-collapsible`,
|
||||||
) as HTMLCollectionOf<HTMLElement>
|
) as HTMLCollectionOf<HTMLElement>
|
||||||
for (const div of collapsible) {
|
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 { getFullSlug } from "../../util/path"
|
||||||
|
import { addRenderListener } from "./util"
|
||||||
|
|
||||||
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
addRenderListener((container: HTMLElement) => {
|
||||||
const checkboxes = document.querySelectorAll(
|
const checkboxes = container.querySelectorAll(
|
||||||
"input.checkbox-toggle",
|
"input.checkbox-toggle",
|
||||||
) as NodeListOf<HTMLInputElement>
|
) as NodeListOf<HTMLInputElement>
|
||||||
checkboxes.forEach((el, index) => {
|
checkboxes.forEach((el, index) => {
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
|
import { addRenderListener } from "./util"
|
||||||
|
|
||||||
const svgCopy =
|
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>'
|
'<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 =
|
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>'
|
'<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", () => {
|
addRenderListener((container: HTMLElement) => {
|
||||||
const els = document.getElementsByTagName("pre")
|
const els = container.getElementsByTagName("pre")
|
||||||
for (let i = 0; i < els.length; i++) {
|
for (let i = 0; i < els.length; i++) {
|
||||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||||
if (codeBlock) {
|
if (codeBlock) {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { addRenderListener } from "./util"
|
||||||
|
|
||||||
const changeTheme = (e: CustomEventMap["themechange"]) => {
|
const changeTheme = (e: CustomEventMap["themechange"]) => {
|
||||||
const theme = e.detail.theme
|
const theme = e.detail.theme
|
||||||
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
|
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
|
||||||
@ -59,8 +61,8 @@ type GiscusElement = Omit<HTMLElement, "dataset"> & {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
addRenderListener((container: HTMLElement) => {
|
||||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
const giscusContainer = container.querySelector(".giscus") as GiscusElement
|
||||||
if (!giscusContainer) {
|
if (!giscusContainer) {
|
||||||
return
|
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 { FileTrieNode } from "../../util/fileTrie"
|
||||||
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||||
|
import { contentDecryptedEventListener } from "../../util/encryption"
|
||||||
|
|
||||||
type MaybeHTMLElement = HTMLElement | undefined
|
type MaybeHTMLElement = HTMLElement | undefined
|
||||||
|
|
||||||
@ -86,7 +87,16 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
|
|||||||
const a = li.querySelector("a") as HTMLAnchorElement
|
const a = li.querySelector("a") as HTMLAnchorElement
|
||||||
a.href = resolveRelative(currentSlug, node.slug)
|
a.href = resolveRelative(currentSlug, node.slug)
|
||||||
a.dataset.for = 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) {
|
if (currentSlug === node.slug) {
|
||||||
a.classList.add("active")
|
a.classList.add("active")
|
||||||
@ -111,6 +121,8 @@ function createFolderNode(
|
|||||||
const folderPath = node.slug
|
const folderPath = node.slug
|
||||||
folderContainer.dataset.folderpath = folderPath
|
folderContainer.dataset.folderpath = folderPath
|
||||||
|
|
||||||
|
let titleElement: HTMLElement
|
||||||
|
|
||||||
if (opts.folderClickBehavior === "link") {
|
if (opts.folderClickBehavior === "link") {
|
||||||
// Replace button with link for link behavior
|
// Replace button with link for link behavior
|
||||||
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
||||||
@ -118,11 +130,20 @@ function createFolderNode(
|
|||||||
a.href = resolveRelative(currentSlug, folderPath)
|
a.href = resolveRelative(currentSlug, folderPath)
|
||||||
a.dataset.for = folderPath
|
a.dataset.for = folderPath
|
||||||
a.className = "folder-title"
|
a.className = "folder-title"
|
||||||
a.textContent = node.displayName
|
titleElement = a
|
||||||
button.replaceWith(a)
|
button.replaceWith(a)
|
||||||
} else {
|
} else {
|
||||||
const span = titleContainer.querySelector(".folder-title") as HTMLElement
|
const span = titleContainer.querySelector(".folder-title-text") as HTMLElement
|
||||||
span.textContent = node.displayName
|
titleElement = span
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.data?.encryptionResult) {
|
||||||
|
titleElement.textContent = "🔒 " + node.displayName
|
||||||
|
contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => {
|
||||||
|
titleElement.textContent = "🔓 " + node.displayName
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
titleElement.textContent = node.displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the saved state is collapsed or the default state is collapsed
|
// 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 data = await fetchData
|
||||||
|
|
||||||
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||||
const trie = FileTrieNode.fromEntries(entries)
|
const trie = FileTrieNode.fromEntries(entries)
|
||||||
|
|
||||||
@ -267,6 +289,7 @@ document.addEventListener("prenav", async () => {
|
|||||||
|
|
||||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||||
const currentSlug = e.detail.url
|
const currentSlug = e.detail.url
|
||||||
|
|
||||||
await setupExplorer(currentSlug)
|
await setupExplorer(currentSlug)
|
||||||
|
|
||||||
// if mobile hamburger is visible, collapse by default
|
// if mobile hamburger is visible, collapse by default
|
||||||
|
|||||||
@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
|||||||
v,
|
v,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const links: SimpleLinkData[] = []
|
const links: SimpleLinkData[] = []
|
||||||
const tags: SimpleSlug[] = []
|
const tags: SimpleSlug[] = []
|
||||||
const validLinks = new Set(data.keys())
|
const validLinks = new Set(data.keys())
|
||||||
@ -575,6 +576,7 @@ function cleanupGlobalGraphs() {
|
|||||||
|
|
||||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||||
const slug = e.detail.url
|
const slug = e.detail.url
|
||||||
|
|
||||||
addToVisited(simplifySlug(slug))
|
addToVisited(simplifySlug(slug))
|
||||||
|
|
||||||
async function renderLocalGraph() {
|
async function renderLocalGraph() {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
import { registerEscapeHandler, removeAllChildren, addRenderListener } from "./util"
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
x: number
|
x: number
|
||||||
@ -185,9 +185,11 @@ const cssVars = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
let mermaidImport = undefined
|
let mermaidImport = undefined
|
||||||
document.addEventListener("nav", async () => {
|
|
||||||
const center = document.querySelector(".center") as HTMLElement
|
addRenderListener(async (container: HTMLElement) => {
|
||||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
const nodes = container.querySelectorAll(
|
||||||
|
"code.mermaid:not([data-processed])",
|
||||||
|
) as NodeListOf<HTMLElement>
|
||||||
if (nodes.length === 0) return
|
if (nodes.length === 0) return
|
||||||
|
|
||||||
mermaidImport ||= await import(
|
mermaidImport ||= await import(
|
||||||
@ -204,9 +206,9 @@ document.addEventListener("nav", async () => {
|
|||||||
async function renderMermaid() {
|
async function renderMermaid() {
|
||||||
// de-init any other diagrams
|
// de-init any other diagrams
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
node.removeAttribute("data-processed")
|
|
||||||
const oldText = textMapping.get(node)
|
const oldText = textMapping.get(node)
|
||||||
if (oldText) {
|
if (oldText) {
|
||||||
|
node.removeAttribute("data-processed")
|
||||||
node.innerHTML = oldText
|
node.innerHTML = oldText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||||
import { normalizeRelativeURLs } from "../../util/path"
|
import { normalizeRelativeURLs } from "../../util/path"
|
||||||
import { fetchCanonical } from "./util"
|
import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util"
|
||||||
|
|
||||||
const p = new DOMParser()
|
const p = new DOMParser()
|
||||||
let activeAnchor: HTMLAnchorElement | null = null
|
let activeAnchor: HTMLAnchorElement | null = null
|
||||||
@ -37,6 +37,8 @@ async function mouseEnterHandler(
|
|||||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatchRenderEvent(popoverInner)
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = new URL(link.href)
|
const targetUrl = new URL(link.href)
|
||||||
@ -120,8 +122,9 @@ function clearActivePopover() {
|
|||||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
addRenderListener((element) => {
|
||||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
const links = [...element.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||||
|
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||||
link.addEventListener("mouseleave", clearActivePopover)
|
link.addEventListener("mouseleave", clearActivePopover)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
|
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
|
||||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
import { registerEscapeHandler, removeAllChildren, dispatchRenderEvent } from "./util"
|
||||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
|
||||||
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||||
|
import { contentDecryptedEventListener, decryptContent } from "../../util/encryption"
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number
|
id: number
|
||||||
@ -88,6 +88,7 @@ const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
|||||||
const contextWindowWords = 30
|
const contextWindowWords = 30
|
||||||
const numSearchResults = 8
|
const numSearchResults = 8
|
||||||
const numTagResults = 5
|
const numTagResults = 5
|
||||||
|
const RENDER_DELAY_MS = 100
|
||||||
|
|
||||||
const tokenizeTerm = (term: string) => {
|
const tokenizeTerm = (term: string) => {
|
||||||
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
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 formatForDisplay = (term: string, id: number) => {
|
||||||
const slug = idDataMap[id]
|
const slug = idDataMap[id]
|
||||||
|
let title = data[slug].title
|
||||||
|
|
||||||
|
if (data[slug].encryptionResult) {
|
||||||
|
title = (data[slug].decrypted ? "🔓 " : "🔒 ") + data[slug].title
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
slug,
|
slug,
|
||||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
title: searchType === "tags" ? title : highlight(term, title ?? ""),
|
||||||
content: highlight(term, data[slug].content ?? "", true),
|
content: highlight(term, data[slug].content ?? "", true),
|
||||||
tags: highlightTags(term.substring(1), data[slug].tags),
|
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||||
}
|
}
|
||||||
@ -433,6 +440,9 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
|||||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||||
)
|
)
|
||||||
highlights[0]?.scrollIntoView({ block: "start" })
|
highlights[0]?.scrollIntoView({ block: "start" })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RENDER_DELAY_MS))
|
||||||
|
|
||||||
|
dispatchRenderEvent(previewInner)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onType(e: HTMLElementEventMap["input"]) {
|
async function onType(e: HTMLElementEventMap["input"]) {
|
||||||
@ -514,16 +524,54 @@ async function fillDocument(data: ContentIndex) {
|
|||||||
if (indexPopulated) return
|
if (indexPopulated) return
|
||||||
let id = 0
|
let id = 0
|
||||||
const promises: Array<Promise<unknown>> = []
|
const promises: Array<Promise<unknown>> = []
|
||||||
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
for (const [slug, fileData] of Object.entries(data)) {
|
||||||
promises.push(
|
if (fileData.encryptionResult) {
|
||||||
index.addAsync(id++, {
|
let slugId = id
|
||||||
id,
|
promises.push(
|
||||||
slug: slug as FullSlug,
|
index.addAsync(id++, {
|
||||||
title: fileData.title,
|
id: id,
|
||||||
content: fileData.content,
|
slug: slug as FullSlug,
|
||||||
tags: fileData.tags,
|
title: fileData.title,
|
||||||
}),
|
content: "",
|
||||||
)
|
tags: fileData.tags,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fileData.decrypted = false
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
contentDecryptedEventListener(
|
||||||
|
slug,
|
||||||
|
fileData.hash!,
|
||||||
|
fileData.encryptionConfig!,
|
||||||
|
async (password) => {
|
||||||
|
const decryptedContent = await decryptContent(
|
||||||
|
fileData.encryptionResult!,
|
||||||
|
fileData.encryptionConfig!,
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
fileData.decrypted = true
|
||||||
|
|
||||||
|
index.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)
|
await Promise.all(promises)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import micromorph from "micromorph"
|
import micromorph from "micromorph"
|
||||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||||
import { fetchCanonical } from "./util"
|
import { fetchCanonical, dispatchRenderEvent } from "./util"
|
||||||
|
|
||||||
// adapted from `micromorph`
|
// adapted from `micromorph`
|
||||||
// https://github.com/natemoo-re/micromorph
|
// https://github.com/natemoo-re/micromorph
|
||||||
@ -38,6 +38,9 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined
|
|||||||
function notifyNav(url: FullSlug) {
|
function notifyNav(url: FullSlug) {
|
||||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||||
document.dispatchEvent(event)
|
document.dispatchEvent(event)
|
||||||
|
|
||||||
|
// Also trigger render event for the whole document body
|
||||||
|
dispatchRenderEvent(document.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { addRenderListener } from "./util"
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const slug = entry.target.id
|
const slug = entry.target.id
|
||||||
@ -34,11 +36,11 @@ function setupToc() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
addRenderListener((container: HTMLElement) => {
|
||||||
setupToc()
|
setupToc()
|
||||||
|
|
||||||
// update toc entry highlighting
|
// update toc entry highlighting
|
||||||
observer.disconnect()
|
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))
|
headers.forEach((header) => observer.observe(header))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -44,3 +44,16 @@ export async function fetchCanonical(url: URL): Promise<Response> {
|
|||||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
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} دقائق للقراءة`,
|
: `${minutes} دقائق للقراءة`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ محتوى محدود 🛡️",
|
||||||
|
enterPassword: "ادخل كلمة المرور",
|
||||||
|
decrypt: "فك التشفير",
|
||||||
|
decrypting: "جاري فك التشفير...",
|
||||||
|
incorrectPassword: "كلمة مرور خاطئة. حاول مرة أخرى.",
|
||||||
|
decryptionFailed: "فشل فك التشفير، تحقق من السجلات",
|
||||||
|
encryptedDescription: "هذا الملف مشفر. افتحه لرؤية المحتويات.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min čtení`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,16 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} Min. Lesezeit`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -62,6 +62,15 @@ export interface Translation {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: (variables: { minutes: number }) => string
|
readingTime: (variables: { minutes: number }) => string
|
||||||
}
|
}
|
||||||
|
encryption: {
|
||||||
|
title: string
|
||||||
|
enterPassword: string
|
||||||
|
decrypt: string
|
||||||
|
decrypting: string
|
||||||
|
incorrectPassword: string
|
||||||
|
decryptionFailed: string
|
||||||
|
encryptedDescription: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `Se lee en ${minutes} min`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -60,6 +60,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`,
|
readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ محتوای محدود 🛡️",
|
||||||
|
enterPassword: "رمز عبور را وارد کنید",
|
||||||
|
decrypt: "رمزگشایی",
|
||||||
|
decrypting: "در حال رمزگشایی...",
|
||||||
|
incorrectPassword: "رمز عبور اشتباه است. دوباره تلاش کنید.",
|
||||||
|
decryptionFailed: "رمزگشایی ناموفق، لاگها را بررسی کنید",
|
||||||
|
encryptedDescription: "این فایل رمزگذاری شده است. آن را باز کنید تا محتوا را ببینید.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min lukuaika`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min de lecture`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} perces olvasás`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} menit baca`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -60,6 +60,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => (minutes === 1 ? "1 minuto" : `${minutes} minuti`),
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ 制限されたコンテンツ 🛡️",
|
||||||
|
enterPassword: "パスワードを入力",
|
||||||
|
decrypt: "復号化",
|
||||||
|
decrypting: "復号化中...",
|
||||||
|
incorrectPassword: "パスワードが間違っています。もう一度お試しください。",
|
||||||
|
decryptionFailed: "復号化に失敗しました。ログを確認してください",
|
||||||
|
encryptedDescription: "このファイルは暗号化されています。内容を見るには開いてください。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ 제한된 콘텐츠 🛡️",
|
||||||
|
enterPassword: "비밀번호 입력",
|
||||||
|
decrypt: "복호화",
|
||||||
|
decrypting: "복호화 중...",
|
||||||
|
incorrectPassword: "비밀번호가 틀렸습니다. 다시 시도해주세요.",
|
||||||
|
decryptionFailed: "복호화에 실패했습니다. 로그를 확인하세요",
|
||||||
|
encryptedDescription: "이 파일은 암호화되어 있습니다. 내용을 보려면 열어주세요.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min skaitymo`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min lesning`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -60,6 +60,15 @@ export default {
|
|||||||
readingTime: ({ minutes }) =>
|
readingTime: ({ minutes }) =>
|
||||||
minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} min. czytania `,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `Leitura de ${minutes} min`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -60,6 +60,15 @@ export default {
|
|||||||
readingTime: ({ minutes }) =>
|
readingTime: ({ minutes }) =>
|
||||||
minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -60,6 +60,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`,
|
readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ Ограниченный Контент 🛡️",
|
||||||
|
enterPassword: "Введите пароль",
|
||||||
|
decrypt: "Расшифровать",
|
||||||
|
decrypting: "Расшифровка...",
|
||||||
|
incorrectPassword: "Неверный пароль. Попробуйте снова.",
|
||||||
|
decryptionFailed: "Расшифровка не удалась, проверьте логи",
|
||||||
|
encryptedDescription: "Этот файл зашифрован. Откройте его, чтобы увидеть содержимое.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
|
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ เนื้อหาถูกจำกัด 🛡️",
|
||||||
|
enterPassword: "ใส่รหัสผ่าน",
|
||||||
|
decrypt: "ถอดรหัส",
|
||||||
|
decrypting: "กำลังถอดรหัส...",
|
||||||
|
incorrectPassword: "รหัสผ่านไม่ถูกต้อง กรุณาลองใหม่",
|
||||||
|
decryptionFailed: "การถอดรหัสล้มเหลว ตรวจสอบบันทึก",
|
||||||
|
encryptedDescription: "ไฟล์นี้ถูกเข้ารหัส เปิดเพื่อดูเนื้อหา",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} хв читання`,
|
readingTime: ({ minutes }) => `${minutes} хв читання`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ Обмежений Контент 🛡️",
|
||||||
|
enterPassword: "Введіть пароль",
|
||||||
|
decrypt: "Розшифрувати",
|
||||||
|
decrypting: "Розшифрування...",
|
||||||
|
incorrectPassword: "Неправильний пароль. Спробуйте ще раз.",
|
||||||
|
decryptionFailed: "Розшифрування не вдалося, перевірте журнали",
|
||||||
|
encryptedDescription: "Цей файл зашифрований. Відкрийте його, щоб побачити вміст.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes} phút đọc`,
|
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: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `${minutes}分钟阅读`,
|
readingTime: ({ minutes }) => `${minutes}分钟阅读`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ 受限内容 🛡️",
|
||||||
|
enterPassword: "输入密码",
|
||||||
|
decrypt: "解密",
|
||||||
|
decrypting: "解密中...",
|
||||||
|
incorrectPassword: "密码错误。请重试。",
|
||||||
|
decryptionFailed: "解密失败,请检查日志",
|
||||||
|
encryptedDescription: "此文件已加密。打开以查看内容。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export default {
|
|||||||
contentMeta: {
|
contentMeta: {
|
||||||
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
|
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
|
||||||
},
|
},
|
||||||
|
encryption: {
|
||||||
|
title: "🛡️ 受限內容 🛡️",
|
||||||
|
enterPassword: "輸入密碼",
|
||||||
|
decrypt: "解密",
|
||||||
|
decrypting: "解密中...",
|
||||||
|
incorrectPassword: "密碼錯誤。請重試。",
|
||||||
|
decryptionFailed: "解密失敗,請檢查日誌",
|
||||||
|
encryptedDescription: "此檔案已加密。打開以檢視內容。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
rss: {
|
rss: {
|
||||||
|
|||||||
@ -261,6 +261,8 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
|||||||
window.addCleanup = () => {}
|
window.addCleanup = () => {}
|
||||||
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||||
document.dispatchEvent(event)
|
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 { toHtml } from "hast-util-to-html"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { i18n } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
|
import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption"
|
||||||
|
|
||||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||||
|
|
||||||
|
// Base content details without encryption-specific fields
|
||||||
export type ContentDetails = {
|
export type ContentDetails = {
|
||||||
slug: FullSlug
|
slug: FullSlug
|
||||||
filePath: FilePath
|
filePath: FilePath
|
||||||
@ -19,6 +22,9 @@ export type ContentDetails = {
|
|||||||
richContent?: string
|
richContent?: string
|
||||||
date?: Date
|
date?: Date
|
||||||
description?: string
|
description?: string
|
||||||
|
encryptionConfig?: CompleteCryptoConfig
|
||||||
|
hash?: Hash
|
||||||
|
encryptionResult?: EncryptionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
@ -103,19 +109,25 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
const slug = file.data.slug!
|
const slug = file.data.slug!
|
||||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
linkIndex.set(slug, {
|
const contentDetails: ContentDetails = {
|
||||||
slug,
|
slug,
|
||||||
filePath: file.data.relativePath!,
|
filePath: file.data.relativePath!,
|
||||||
title: file.data.frontmatter?.title!,
|
title: file.data.frontmatter?.title!,
|
||||||
links: file.data.links ?? [],
|
links: file.data.links ?? [],
|
||||||
tags: file.data.frontmatter?.tags ?? [],
|
tags: file.data.frontmatter?.tags ?? [],
|
||||||
content: file.data.text ?? "",
|
content: file.data.text ?? "",
|
||||||
richContent: opts?.rssFullHtml
|
richContent:
|
||||||
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
opts?.rssFullHtml && !file.data.encryptionResult
|
||||||
: undefined,
|
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||||
|
: undefined,
|
||||||
date: date,
|
date: date,
|
||||||
description: file.data.description ?? "",
|
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
|
// remove description and from content index as nothing downstream
|
||||||
// actually uses it. we only keep it in the index as we need it
|
// actually uses it. we only keep it in the index as we need it
|
||||||
// for the RSS feed
|
// for the RSS feed
|
||||||
|
if (content.encryptionResult) {
|
||||||
|
delete content.richContent
|
||||||
|
} else {
|
||||||
|
delete content.hash
|
||||||
|
delete content.encryptionConfig
|
||||||
|
delete content.encryptionResult
|
||||||
|
}
|
||||||
|
|
||||||
delete content.description
|
delete content.description
|
||||||
delete content.date
|
delete content.date
|
||||||
return [slug, content]
|
return [slug, content]
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Root as HTMLRoot } from "hast"
|
|||||||
import { toString } from "hast-util-to-string"
|
import { toString } from "hast-util-to-string"
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import { escapeHTML } from "../../util/escape"
|
import { escapeHTML } from "../../util/escape"
|
||||||
|
import { i18n } from "../../i18n"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
descriptionLength: number
|
descriptionLength: number
|
||||||
@ -24,10 +25,17 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
const opts = { ...defaultOptions, ...userOpts }
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
return {
|
return {
|
||||||
name: "Description",
|
name: "Description",
|
||||||
htmlPlugins() {
|
htmlPlugins(ctx) {
|
||||||
return [
|
return [
|
||||||
() => {
|
() => {
|
||||||
return async (tree: HTMLRoot, file) => {
|
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 frontMatterDescription = file.data.frontmatter?.description
|
||||||
let text = escapeHTML(toString(tree))
|
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 { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
|
||||||
import { QuartzPluginData } from "../vfile"
|
import { QuartzPluginData } from "../vfile"
|
||||||
import { i18n } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
|
import { EncryptionConfig } from "../../util/encryption"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
delimiters: string | [string, string]
|
delimiters: string | [string, string]
|
||||||
@ -119,6 +120,22 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
|
|
||||||
if (socialImage) data.socialImage = socialImage
|
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
|
// Remove duplicate slugs
|
||||||
const uniqueSlugs = [...new Set(allSlugs)]
|
const uniqueSlugs = [...new Set(allSlugs)]
|
||||||
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
|
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
|
||||||
@ -152,6 +169,8 @@ declare module "vfile" {
|
|||||||
cssclasses: string[]
|
cssclasses: string[]
|
||||||
socialImage: string
|
socialImage: string
|
||||||
comments: boolean | string
|
comments: boolean | string
|
||||||
|
encrypt: boolean
|
||||||
|
encryptConfig: EncryptionConfig
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export { CrawlLinks } from "./links"
|
|||||||
export { ObsidianFlavoredMarkdown } from "./ofm"
|
export { ObsidianFlavoredMarkdown } from "./ofm"
|
||||||
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
|
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
|
||||||
export { SyntaxHighlighting } from "./syntax"
|
export { SyntaxHighlighting } from "./syntax"
|
||||||
|
export { Encrypt } from "./encrypt"
|
||||||
export { TableOfContents } from "./toc"
|
export { TableOfContents } from "./toc"
|
||||||
export { HardLineBreaks } from "./linebreaks"
|
export { HardLineBreaks } from "./linebreaks"
|
||||||
export { RoamFlavoredMarkdown } from "./roam"
|
export { RoamFlavoredMarkdown } from "./roam"
|
||||||
|
|||||||
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