Compare commits

...

5 Commits

Author SHA1 Message Date
Trần Đức Nam
1d275e4ddb
Merge 142a08a6f8bdf593714704888e5be5b9316ad883 into 7be47742a6dc86f22d148ca9d304f7a9eea318cf 2025-01-31 06:46:48 -05:00
dependabot[bot]
7be47742a6
chore(deps): bump the production-dependencies group across 1 directory with 3 updates (#1744)
Some checks failed
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Build and Test / publish-tag (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-31 06:46:45 -05:00
Trần Đức Nam
142a08a6f8
feat: add encryption translation for lt-LT and th-TH 2025-01-22 17:56:53 +07:00
Trần Đức Nam
10063e1247
fix: convert password to string instead of stringify by JSON 2025-01-21 16:04:53 +07:00
Trần Đức Nam
d595a9e6b7
feat: allow to set password-protected notes 2025-01-21 16:04:53 +07:00
44 changed files with 653 additions and 23 deletions

View File

@ -24,6 +24,9 @@ This part of the configuration concerns anything that can affect the whole site.
- `pageTitleSuffix`: a string added to the end of the page title. This only applies to the browser tab title, not the title shown at the top of the page.
- `enableSPA`: whether to enable [[SPA Routing]] on your site.
- `enablePopovers`: whether to enable [[popover previews]] on your site.
- `passProtected`: what to use [[Password Protected]] on your site.
- `enabled`: whether to enable password protected
- `iteration`: iteration of key derivation, default is `2e6`
- `analytics`: what to use for analytics on your site. Values can be
- `null`: don't use analytics;
- `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics;

View File

@ -0,0 +1,32 @@
---
title: Password Protected
---
Some notes may be sensitive, i.e. non-public personal projects, contacts, meeting notes and such. It would be really useful to be able to protect some pages or group of pages so they don't appear to everyone, while still allowing them to be published.
By adding a password to your note's frontmatter, you can create an extra layer of security, ensuring that only authorized individuals can access your content. Whether you're safeguarding personal journals, project plans, this feature provides the peace of mind you need.
## How it works
Simply add a password field to your note's frontmatter and set your desired password. When you try to view the note, you'll be prompted to enter the password. If the password is correct, the note will be unlocked. Once unlocked, you can access the note until you clear your browser cookies.
### Security techniques
- Key Derivation: Utilizes PBKDF2 for generating secure encryption keys.
- Unique Salt for Each Encryption: A unique salt is generated every time the encrypt method is used, enhancing security.
- Encryption/Decryption: Implements AES-GCM for robust data encryption and decryption.
- Encoding/Decoding: Use base64 to convert non-textual encrypted data in HTML
### Disclaimer
- Use it at your own risk
- You need to choose a strong password and share it only to trusted users
- You need to secure your notes and Quartz repository in private mode on Github/Gitlab/Bitbucket... or use your own Git server
- You can use other WAF tools to enhance security, based on URL of notes that Quartz build for you, e.g. Cloudflare WAF, AWS WAF, Google Cloud Armor...
## Configuration
- Enable password protected notes: set the `passwordProtected.enabled` field in `quartz.config.ts` to be `true`.
- Change iteration count of key derivation: set the `passwordProtected.iteration` filed in `quartz.config.ts` to any bigger than 2e6.
- Style: `quartz/components/styles/passwordProtected.scss`
- Script: `quartz/components/scripts/decrypt.inline.ts`

34
package-lock.json generated
View File

@ -34,7 +34,7 @@
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"pixi.js": "^8.6.6",
"pixi.js": "^8.7.3",
"preact": "^10.25.4",
"preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1",
@ -55,6 +55,7 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-smartypants": "^3.0.2",
"rfc4648": "^1.5.3",
"rfdc": "^1.4.1",
"rimraf": "^6.0.1",
"satori": "^0.12.1",
@ -79,10 +80,10 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.6",
"@types/node": "^22.12.0",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.13",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"esbuild": "^0.24.2",
"prettier": "^3.4.2",
@ -1914,10 +1915,11 @@
}
},
"node_modules/@types/node": {
"version": "22.10.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.6.tgz",
"integrity": "sha512-qNiuwC4ZDAUNcY47xgaSuS92cjf8JbSUoaKS77bmLG1rU7MlATVSiw/IlrjtIyyskXBZ8KkNfjK/P5na7rgXbQ==",
"version": "22.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
"integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
@ -1943,10 +1945,11 @@
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ=="
},
"node_modules/@types/ws": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
"integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz",
"integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
@ -5583,9 +5586,10 @@
}
},
"node_modules/pixi.js": {
"version": "8.6.6",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.6.6.tgz",
"integrity": "sha512-o5pw7G2yuIrnBx0G4npBlmFp+XGNcapI/Ufs62rRj/4XKxc1Zo74YJr/BtEXcXTraTKd+pQvYOLvnfxRjxBMvQ==",
"version": "8.7.3",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.7.3.tgz",
"integrity": "sha512-wfWlhJYnGx1s4f2yoouevQjaeacbJ12LTkJGa+n9AIYNIjOnmJylBtZ2mARX7iFk3mr2xv0wuo//XPe2hk5OBw==",
"license": "MIT",
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
@ -6125,6 +6129,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfc4648": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz",
"integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==",
"license": "MIT"
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",

View File

@ -60,7 +60,7 @@
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"pixi.js": "^8.6.6",
"pixi.js": "^8.7.3",
"preact": "^10.25.4",
"preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1",
@ -81,6 +81,7 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-smartypants": "^3.0.2",
"rfc4648": "^1.5.3",
"rfdc": "^1.4.1",
"rimraf": "^6.0.1",
"satori": "^0.12.1",
@ -102,10 +103,10 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.6",
"@types/node": "^22.12.0",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.13",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"esbuild": "^0.24.2",
"prettier": "^3.4.2",

View File

@ -20,6 +20,10 @@ const config: QuartzConfig = {
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
generateSocialImages: false,
passProtected: {
enabled: false,
iteration: 2e6,
},
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,

View File

@ -44,6 +44,13 @@ export type Analytics =
projectId?: string
}
export type PassProtected = {
/** Whether to enable password protected page rendering */
enabled: boolean
/** Iteration of derived key to encrypt page */
iteration: number
}
export interface GlobalConfiguration {
pageTitle: string
pageTitleSuffix?: string
@ -57,6 +64,8 @@ export interface GlobalConfiguration {
ignorePatterns: string[]
/** Whether to use created, modified, or published as the default type of date */
defaultDateType: ValidDateType
/** Password protected page rendering */
passProtected: PassProtected
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
* Quartz will avoid using this as much as possible and use relative URLs most of the time
*/

View File

@ -250,7 +250,7 @@ export async function handleBuild(argv) {
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
text = text.replace("export ", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)

View File

@ -0,0 +1,41 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { i18n } from "../../i18n"
const EncryptedContent: QuartzComponent = ({ encryptedContent, cfg }: QuartzComponentProps) => {
return (
<>
<div id="lock">
<div
id="msg"
data-wrong={i18n(cfg.locale).pages.encryptedContent.wrongPassword}
data-modern={i18n(cfg.locale).pages.encryptedContent.modernBrowser}
data-empty={i18n(cfg.locale).pages.encryptedContent.noPayload}
>
{i18n(cfg.locale).pages.encryptedContent.enterPassword}
</div>
<div id="load">
<p class="spinner"></p>
<p id="load-text" data-decrypt={i18n(cfg.locale).pages.encryptedContent.decrypting}>
{i18n(cfg.locale).pages.encryptedContent.loading}
</p>
</div>
<form class="hidden">
<input
type="password"
class="pwd"
name="pwd"
aria-label={i18n(cfg.locale).pages.encryptedContent.password}
autofocus
/>
<input type="submit" value={i18n(cfg.locale).pages.encryptedContent.submit} />
</form>
<pre class="hidden" data-i={cfg.passProtected?.iteration}>
{encryptedContent}
</pre>
</div>
<article id="content"></article>
</>
)
}
export default (() => EncryptedContent) satisfies QuartzComponentConstructor

View File

@ -2,7 +2,9 @@ import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import EncryptedContent from "./pages/EncryptedContent"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { getEncryptedPayload } from "../util/encrypt"
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
@ -77,13 +79,13 @@ export function pageResources(
return resources
}
export function renderPage(
export async function renderPage(
cfg: GlobalConfiguration,
slug: FullSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
): string {
): Promise<string> {
// make a deep copy of the tree so we don't remove the transclusion references
// for the file cached in contentMap in build.ts
const root = clone(componentData.tree) as Root
@ -219,6 +221,7 @@ export function renderPage(
} = components
const Header = HeaderConstructor()
const Body = BodyConstructor()
const Encrypted = EncryptedContent()
const LeftComponent = (
<div class="left sidebar">
@ -237,6 +240,16 @@ export function renderPage(
)
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
let content = <Content {...componentData} />
if (cfg.passProtected?.enabled && componentData.fileData.frontmatter?.password) {
componentData.encryptedContent = await getEncryptedPayload(
render(content),
componentData.fileData.frontmatter.password.toString(),
cfg.passProtected?.iteration,
)
content = <Encrypted {...componentData} />
}
const doc = (
<html lang={lang}>
<Head {...componentData} />
@ -257,7 +270,7 @@ export function renderPage(
))}
</div>
</div>
<Content {...componentData} />
{content}
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (

View File

@ -0,0 +1,168 @@
import { base64 } from "rfc4648"
// @ts-ignore:next-line
function find<T>(selector: string): T {
const element = document.querySelector(selector) as T
if (element) return element
}
let salt: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array, iterations: number
const subtle =
window.crypto?.subtle ||
(window.crypto as unknown as { webkitSubtle: Crypto["subtle"] })?.webkitSubtle
let pl: HTMLPreElement,
form: HTMLFormElement,
pwd: HTMLInputElement,
load: HTMLDivElement,
loadText: HTMLElement,
lock: HTMLDivElement,
msg: HTMLParagraphElement,
article: HTMLElement
async function decryptHTML() {
pl = find<HTMLPreElement>("pre[data-i]")
form = find<HTMLFormElement>("form")
pwd = find<HTMLInputElement>(".pwd")
load = find<HTMLDivElement>("#load")
loadText = find<HTMLElement>("#load-text")
lock = find<HTMLDivElement>("#lock")
msg = find<HTMLParagraphElement>("#msg")
article = find<HTMLElement>("#content")
if (!pl || !form || !pwd) {
return
}
pwd.value = ""
if (!subtle) {
pwd.disabled = true
error("modern")
return
}
show(lock)
if (!pl.innerHTML) {
pwd.disabled = true
error("empty")
return
}
form.addEventListener("submit", async (event) => {
event.preventDefault()
await decrypt()
})
iterations = Number(pl.dataset.i)
const bytes = base64.parse(pl.innerHTML)
salt = bytes.slice(0, 32)
iv = bytes.slice(32, 32 + 16)
ciphertext = bytes.slice(32 + 16)
if (location.hash) {
const parts = location.href.split("#")
pwd.value = parts[1]
history.replaceState(null, "", parts[0])
}
if (sessionStorage[document.body.dataset.slug!] || pwd.value) {
await decrypt()
} else {
hide(load)
show(form)
pwd.focus()
}
}
document.addEventListener("nav", decryptHTML)
function show(element: Element) {
element.classList.remove("hidden")
}
function hide(element: Element) {
element.classList.add("hidden")
}
function error(code: string) {
msg.innerText = msg.getAttribute("data-" + code) || ""
}
async function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
async function decrypt() {
loadText.innerText = loadText.getAttribute("data-decrypt") || ""
show(load)
hide(form)
await sleep(60)
try {
const decrypted = await decryptFile({ salt, iv, ciphertext, iterations }, pwd.value)
article.innerHTML = decrypted
hide(lock)
} catch (e) {
hide(load)
show(form)
if (sessionStorage[document.body.dataset.slug!]) {
sessionStorage.removeItem(document.body.dataset.slug!)
} else {
error("wrong")
}
pwd.value = ""
pwd.focus()
}
}
async function deriveKey(
salt: Uint8Array,
password: string,
iterations: number,
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const baseKey = await subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
"deriveKey",
])
return await subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
true,
["decrypt"],
)
}
async function importKey(key: JsonWebKey) {
return subtle.importKey("jwk", key, "AES-GCM", true, ["decrypt"])
}
async function decryptFile(
{
salt,
iv,
ciphertext,
iterations,
}: {
salt: Uint8Array
iv: Uint8Array
ciphertext: Uint8Array
iterations: number
},
password: string,
) {
const decoder = new TextDecoder()
const key = sessionStorage[document.body.dataset.slug!]
? await importKey(JSON.parse(sessionStorage[document.body.dataset.slug!]))
: await deriveKey(salt, password, iterations)
const data = new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
if (!data) throw "Malformed data"
sessionStorage[document.body.dataset.slug!] = JSON.stringify(await subtle.exportKey("jwk", key))
return decoder.decode(data)
}

View File

@ -0,0 +1,30 @@
.hidden {
display: none;
}
.pwd {
box-sizing: border-box;
font-family: var(--bodyFont);
color: var(--dark);
border: 1px solid var(--lightgray);
padding: 0.5em 1em;
background: var(--light);
border-radius: 7px;
width: 70%;
margin-bottom: 2em;
margin-right: 2em;
}
.pwd + input {
font-style: normal;
font-weight: 700;
font-size: 15px;
line-height: 1;
color: var(--light);
padding: 7px 15px;
background-color: var(--dark);
margin-top: 1px;
border-radius: 7px;
position: relative;
padding: 0.6em 1em;
}

View File

@ -22,6 +22,7 @@ export type QuartzComponent = ComponentType<QuartzComponentProps> & {
css?: string
beforeDOMLoaded?: string
afterDOMLoaded?: string
encryptedContent?: string
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (

View File

@ -85,5 +85,15 @@ export default {
showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`,
totalTags: ({ count }) => `يوجد ${count} أوسمة.`,
},
encryptedContent: {
loading: " التحميل...",
password: "كلمة المرور",
submit: "إرسال",
enterPassword: "الصفحة مقفلة بشكل افتراضي. يرجى إدخال كلمة المرور لفتح القفل:",
modernBrowser: ".استخدام متصفح حديث.",
wrongPassword: "خاطئة. يرجى إعادة إدخال كلمة المرور لفتح القفل:",
noPayload: "حمولة مشفرة.",
decrypting: "جاري فك التشفير...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,16 @@ export default {
showingFirst: ({ count }) => `Mostrant les primeres ${count} etiquetes.`,
totalTags: ({ count }) => `S'han trobat ${count} etiquetes en total.`,
},
encryptedContent: {
loading: "Carregant...",
password: "Contrasenya",
submit: "Enviar",
enterPassword:
"Aquesta pàgina està bloquejada per defecte. Introduïu la contrasenya per desbloquejarla:",
modernBrowser: "Utilitzeu un navegador modern.",
wrongPassword: "Contrasenya incorrecta. Introduïu de nou la contrasenya per desbloquejar:",
noPayload: "No hi ha càrrega útil xifrada.",
decrypting: "Desxifrant...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Zobrazují se první ${count} tagy.`,
totalTags: ({ count }) => `Nalezeno celkem ${count} tagů.`,
},
encryptedContent: {
loading: "Načítání...",
password: "Heslo",
submit: "Odeslat",
enterPassword: "Tato stránka je standardně uzamčena. Zadejte heslo pro odemknutí:",
modernBrowser: "Použijte moderní prohlížeč.",
wrongPassword: "Nesprávné heslo. Zadejte heslo znovu pro odemknutí:",
noPayload: "Není žádné šifrované užitečné zatížení.",
decrypting: "Dekódování...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,17 @@ export default {
showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`,
totalTags: ({ count }) => `${count} Tags insgesamt.`,
},
encryptedContent: {
loading: "Wird geladen...",
password: "Passwort",
submit: "Senden",
enterPassword:
"Diese Seite ist standardmäßig gesperrt. Bitte geben Sie das Passwort ein, um sie zu entsperren:",
modernBrowser: "Bitte verwenden Sie einen modernen Browser.",
wrongPassword:
"Falsches Passwort. Bitte geben Sie das Passwort erneut ein, um zu entsperren:",
noPayload: "Keine verschlüsselte Nutzlast.",
decrypting: "Entschlüsseln...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export interface Translation {
showingFirst: (variables: { count: number }) => string
totalTags: (variables: { count: number }) => string
}
encryptedContent: {
loading: string
password: string
submit: string
enterPassword: string
modernBrowser: string
wrongPassword: string
noPayload: string
decrypting: string
}
}
}

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Showing first ${count} tags.`,
totalTags: ({ count }) => `Found ${count} total tags.`,
},
encryptedContent: {
loading: "Loading...",
password: "Password",
submit: "Submit",
enterPassword: "This page is locked by default. Please enter passsword to unlock:",
modernBrowser: "Please use a modern browser.",
wrongPassword: "Wrong password. Please re-enter passsword to unlock:",
noPayload: "No encrypted payload.",
decrypting: "Decrypting...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Showing first ${count} tags.`,
totalTags: ({ count }) => `Found ${count} total tags.`,
},
encryptedContent: {
loading: "Loading...",
password: "Password",
submit: "Submit",
enterPassword: "This page is locked by default. Please enter passsword to unlock:",
modernBrowser: "Please use a modern browser.",
wrongPassword: "Wrong password. Please re-enter passsword to unlock:",
noPayload: "No encrypted payload.",
decrypting: "Decrypting...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,16 @@ export default {
showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`,
totalTags: ({ count }) => `Se han encontrado ${count} etiquetas en total.`,
},
encryptedContent: {
loading: "Cargando...",
password: "Contraseña",
submit: "Enviar",
enterPassword:
"Esta página está bloqueada por defecto. Introduzca la contraseña para desbloquearla:",
modernBrowser: "Utilice un navegador moderno.",
wrongPassword: "Contraseña incorrecta. Vuelva a introducir la contraseña para desbloquear:",
noPayload: "No hay ninguna carga útil cifrada.",
decrypting: "Descifrando...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,16 @@ export default {
showingFirst: ({ count }) => `در حال نمایش ${count} برچسب.`,
totalTags: ({ count }) => `${count} برچسب یافت شد.`,
},
encryptedContent: {
loading: "در حال بارگذاری...",
password: "رمز عبور",
submit: "ارسال کردن",
enterPassword:
"این صفحه به طور پیش فرض قفل شده است. لطفا رمز عبور را برای باز کردن قفل وارد کنید:",
modernBrowser: "لطفا از یک مرورگر مدرن استفاده کنید.",
wrongPassword: "رمز عبور اشتباه است. لطفا رمز عبور را دوباره وارد کنید تا قفل باز شود:",
noPayload: "هیچ محموله رمزگذاری شده ای وجود ندارد.",
decrypting: "در حال رمزگشایی...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,17 @@ export default {
showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
},
encryptedContent: {
loading: "Chargement en cours...",
password: "Mot de passe",
submit: "Soumettre",
enterPassword:
"Cette page est verrouillée par défaut. Veuillez entrer le mot de passe pour déverrouiller :",
modernBrowser: "Veuillez utiliser un navigateur moderne.",
wrongPassword:
"Mot de passe incorrect. Veuillez saisir à nouveau le mot de passe pour déverrouiller :",
noPayload: "Aucune charge utile cryptée.",
decrypting: "Décryptage en cours...",
},
},
} as const satisfies Translation

View File

@ -78,5 +78,16 @@ export default {
showingFirst: ({ count }) => `Első ${count} címke megjelenítve.`,
totalTags: ({ count }) => `Összesen ${count} címke található.`,
},
encryptedContent: {
loading: "Betöltés...",
password: "Jelszó",
submit: "Küldés",
enterPassword:
"Ez az oldal alapértelmezés szerint zárolva van. Kérjük, adja meg a jelszót a feloldáshoz:",
modernBrowser: "Kérjük, használjon modern böngészőt.",
wrongPassword: "Helytelen jelszó. Kérjük, adja meg újra a jelszót a feloldáshoz:",
noPayload: "Nincs titkosított hasznos teher.",
decrypting: "Dekódolás...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,16 @@ export default {
showingFirst: ({ count }) => `Prime ${count} etichette.`,
totalTags: ({ count }) => `Trovate ${count} etichette totali.`,
},
encryptedContent: {
loading: "Caricamento in corso...",
password: "Password",
submit: "Invia",
enterPassword:
"Questa pagina è bloccata per impostazione predefinita. Inserisci la password per sbloccare:",
modernBrowser: "Si prega di utilizzare un browser moderno.",
wrongPassword: "Password errato. Si prega di reinserire la password per sbloccare:",
noPayload: "Nessun payload crittografato.",
decrypting: "Decifrazione in corso...",
},
},
} as const satisfies Translation

View File

@ -78,5 +78,17 @@ export default {
showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`,
totalTags: ({ count }) => `${count}個のタグを表示中`,
},
encryptedContent: {
loading: "読み込み中...",
password: "パスワード",
submit: "送信",
enterPassword:
"このページはデフォルトでロックされています。ロックを解除するにはパスワードを入力してください:",
modernBrowser: "最新のブラウザを使用してください。",
wrongPassword:
"パスワードが間違っています。ロックを解除するにはパスワードを再度入力してください:",
noPayload: "暗号化されたペイロードはありません。",
decrypting: "解読中...",
},
},
} as const satisfies Translation

View File

@ -78,5 +78,15 @@ export default {
showingFirst: ({ count }) => `처음 ${count}개의 태그`,
totalTags: ({ count }) => `${count}개의 태그를 찾았습니다.`,
},
encryptedContent: {
loading: "로딩 중...",
password: "비밀번호",
submit: "제출",
enterPassword: "이 페이지는 기본적으로 잠겨 있습니다. 잠금을 해제하려면 암호를 입력하십시오:",
modernBrowser: "최신 브라우저를 사용하십시오.",
wrongPassword: "비밀번호가 잘못되었습니다. 잠금을 해제하려면 암호를 다시 입력하십시오:",
noPayload: "암호화된 페이로드가 없습니다.",
decrypting: "해독 중...",
},
},
} as const satisfies Translation

View File

@ -100,5 +100,16 @@ export default {
? `Rasta iš viso ${count} žymos.`
: `Rasta iš viso ${count} žymų.`,
},
encryptedContent: {
loading: "Loading...",
password: "Slaptažodis",
submit: "Pateikti",
enterPassword:
"Šis puslapis yra užrakintas pagal numatytuosius nustatymus. Norėdami atrakinti, įveskite slaptažodį:",
modernBrowser: "Prašome naudoti modernią naršyklę.",
wrongPassword: "Neteisingas slaptažodis. Norėdami atrakinti, iš naujo įveskite slaptažodį:",
noPayload: "Nėra užšifruoto naudingojo krovinio.",
decrypting: "Iššifruojama...",
},
},
} as const satisfies Translation

View File

@ -82,5 +82,17 @@ export default {
count === 1 ? "Eerste label tonen." : `Eerste ${count} labels tonen.`,
totalTags: ({ count }) => `${count} labels gevonden.`,
},
encryptedContent: {
loading: "Bezig met laden...",
password: "Wachtwoord",
submit: "Verzenden",
enterPassword:
"Deze pagina is standaard vergrendeld. Voer alstublieft uw wachtwoord in om te ontgrendelen:",
modernBrowser: "Gebruik alstublieft een moderne browser.",
wrongPassword:
"Verkeerd wachtwoord. Voer alstublieft uw wachtwoord opnieuw in om te ontgrendelen:",
noPayload: "Geen versleutelde payload.",
decrypting: "Ontsleutelen..",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Pokazuje ${count} pierwszych znaczników.`,
totalTags: ({ count }) => `Znalezionych wszystkich znaczników: ${count}.`,
},
encryptedContent: {
loading: "Ładowanie...",
password: "Hasło",
submit: "Wyślij",
enterPassword: "Ta strona jest domyślnie zablokowana. Wprowadź hasło, aby ją odblokować:",
modernBrowser: "Użyj nowoczesnej przeglądarki.",
wrongPassword: "Senha incorreta. Digite a senha novamente para desbloquear:",
noPayload: "Nie ma zaszyfrowanego ładunku.",
decrypting: "Deszyfrowanie...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Mostrando as ${count} primeiras tags.`,
totalTags: ({ count }) => `Encontradas ${count} tags.`,
},
encryptedContent: {
loading: "Carregando...",
password: "Senha",
submit: "Enviar",
enterPassword: "Esta página está bloqueada por padrão. Digite a senha para desbloquear:",
modernBrowser: "Use um navegador moderno.",
wrongPassword: "Parolă greșită. Vă rugăm să reintroduceți parola pentru a debloca:",
noPayload: "Não há nenhuma carga útil criptografada.",
decrypting: "Descifrando...",
},
},
} as const satisfies Translation

View File

@ -81,5 +81,16 @@ export default {
showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`,
totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`,
},
encryptedContent: {
loading: "Se încarcă...",
password: "Parolă",
submit: "Trimite",
enterPassword:
"Această pagină este blocată în mod implicit. Introduceți parola pentru a debloca:",
modernBrowser: "Vă rugăm să utilizați un browser modern.",
wrongPassword: "Неправильний пароль. Будь ласка, введіть пароль ще раз, щоб розблокувати:",
noPayload: "Nu există nicio sarcină utilă criptată.",
decrypting: "Decriptare...",
},
},
} as const satisfies Translation

View File

@ -82,6 +82,17 @@ export default {
`Показыва${getForm(count, "ется", "ются", "ются")} ${count} тег${getForm(count, "", "а", "ов")}`,
totalTags: ({ count }) => `Всего ${count} тег${getForm(count, "", "а", "ов")}`,
},
encryptedContent: {
loading: "Загрузка...",
password: "Пароль",
submit: "Отправить",
enterPassword:
"Эта страница заблокирована по умолчанию. Пожалуйста, введите пароль для разблокировки:",
modernBrowser: "Пожалуйста, используйте современный браузер.",
wrongPassword: "Неверный пароль. Пожалуйста, введите пароль еще раз для разблокировки:",
noPayload: "Нет зашифрованной полезной нагрузки.",
decrypting: "Расшифровка...",
},
},
} as const satisfies Translation

View File

@ -78,5 +78,15 @@ export default {
showingFirst: ({ count }) => `แสดง ${count} แท็กแรก`,
totalTags: ({ count }) => `มีทั้งหมด ${count} แท็ก`,
},
encryptedContent: {
loading: "กำลังโหลด...",
password: "รหัสผ่าน",
submit: "ส่ง",
enterPassword: "หน้านี้ถูกล็อคโดยค่าเริ่มต้น กรุณากรอกรหัสผ่านเพื่อปลดล็อค:",
modernBrowser: "กรุณาใช้เบราว์เซอร์ที่ทันสมัย",
wrongPassword: "รหัสผ่านผิด กรุณากรอกรหัสผ่านอีกครั้งเพื่อปลดล็อค:",
noPayload: "ไม่มีเพย์โหลดที่เข้ารหัส",
decrypting: "กำลังถอดรหัส...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,16 @@ export default {
showingFirst: ({ count }) => `İlk ${count} etiket gösteriliyor.`,
totalTags: ({ count }) => `Toplam ${count} adet etiket bulundu.`,
},
encryptedContent: {
loading: "Yükleniyor...",
password: "Şifre",
submit: "Gönder",
enterPassword:
"Bu sayfa varsayılan olarak kilitlidir. Kilidi açmak için lütfen şifreyi girin:",
modernBrowser: "Lütfen modern bir tarayıcı kullanın.",
wrongPassword: "Yanlış şifre. Kilidi açmak için lütfen şifreyi tekrar girin:",
noPayload: "Şifrelenmiş yük yok.",
decrypting: "Şifre çözülüyor...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Показ перших ${count} міток.`,
totalTags: ({ count }) => `Всього знайдено міток: ${count}.`,
},
encryptedContent: {
loading: "Завантаження...",
password: "Пароль",
submit: "Надіслати",
enterPassword: "Ця сторінка за замовчуванням заблокована. Введіть пароль, щоб розблокувати:",
modernBrowser: "Будь ласка, використовуйте сучасний браузер.",
wrongPassword: "Неправильний пароль. Будь ласка, введіть пароль ще раз, щоб розблокувати:",
noPayload: "Немає зашифрованого корисного навантаження.",
decrypting: "Розшифровка...",
},
},
} as const satisfies Translation

View File

@ -80,5 +80,15 @@ export default {
showingFirst: ({ count }) => `Hiển thị trước ${count} thẻ.`,
totalTags: ({ count }) => `Tìm thấy ${count} thẻ tổng cộng.`,
},
encryptedContent: {
loading: "Đang tải...",
password: "Mật khẩu",
submit: "Mở khóa",
enterPassword: "Trang này bị khóa theo mặc định. Vui lòng điền mật khẩu để mở khóa:",
modernBrowser: "Vui lòng sử dụng trình duyệt mới nhất.",
wrongPassword: "Sai mật khẩu. Vui lòng điền lại mật khẩu để mở khóa:",
noPayload: "Không có nội dung được mã hóa.",
decrypting: "Đang giải mã...",
},
},
} as const satisfies Translation

View File

@ -78,5 +78,15 @@ export default {
showingFirst: ({ count }) => `显示前${count}个标签。`,
totalTags: ({ count }) => `总共有${count}个标签。`,
},
encryptedContent: {
loading: "正在加载...",
password: "密码",
submit: "提交",
enterPassword: "此页面默认锁定。请输入密码解锁:",
modernBrowser: "请使用现代浏览器。",
wrongPassword: "密码错误。请重新输入密码解锁:",
noPayload: "没有加密的有效负载。",
decrypting: "解密中...",
},
},
} as const satisfies Translation

View File

@ -78,5 +78,15 @@ export default {
showingFirst: ({ count }) => `顯示前 ${count} 個標籤。`,
totalTags: ({ count }) => `總共有 ${count} 個標籤。`,
},
encryptedContent: {
loading: "正在加載...",
password: "密碼",
submit: "提交",
enterPassword: "此頁面預設鎖定。請輸入密碼解鎖:",
modernBrowser: "請使用現代瀏覽器。",
wrongPassword: "密碼錯誤。請重新輸入密碼解鎖:",
noPayload: "沒有加密的有效負載。",
decrypting: "解密中...",
},
},
} as const satisfies Translation

View File

@ -58,7 +58,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
return [
await write({
ctx,
content: renderPage(cfg, slug, componentData, opts, externalResources),
content: await renderPage(cfg, slug, componentData, opts, externalResources),
slug,
ext: ".html",
}),

View File

@ -5,8 +5,11 @@ import { QuartzEmitterPlugin } from "../types"
import spaRouterScript from "../../components/scripts/spa.inline"
// @ts-ignore
import popoverScript from "../../components/scripts/popover.inline"
// @ts-ignore
import decryptScript from "../../components/scripts/decrypt.inline"
import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
import passProtectedStyle from "../../components/styles/passProtected.scss"
import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles } from "../../util/theme"
@ -77,6 +80,11 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
componentResources.css.push(popoverStyle)
}
if (cfg.passProtected?.enabled) {
componentResources.afterDOMLoaded.push(decryptScript)
componentResources.css.push(passProtectedStyle)
}
if (cfg.analytics?.provider === "google") {
const tagId = cfg.analytics.tagId
componentResources.afterDOMLoaded.push(`

View File

@ -117,7 +117,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const content = await renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
ctx,
content,

View File

@ -118,7 +118,7 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const content = await renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
ctx,
content,

View File

@ -126,7 +126,7 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const content = await renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
ctx,
content,

33
quartz/util/encrypt.ts Normal file
View File

@ -0,0 +1,33 @@
import { base64 } from "rfc4648"
import { getRandomValues, subtle } from "crypto"
export async function getEncryptedPayload(
content: string,
password: string,
iterations: number = 2e6,
) {
const encoder = new TextEncoder()
const salt = getRandomValues(new Uint8Array(32))
const baseKey = await subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
"deriveKey",
])
const key = await subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"],
)
const iv = getRandomValues(new Uint8Array(16))
const ciphertext = new Uint8Array(
await subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(content)),
)
const totalLength = salt.length + iv.length + ciphertext.length
const mergedData = new Uint8Array(totalLength)
mergedData.set(salt)
mergedData.set(iv, salt.length)
mergedData.set(ciphertext, salt.length + iv.length)
return base64.stringify(mergedData)
}