Skip to content

Commit

Permalink
feat: allow to set password-protected notes
Browse files Browse the repository at this point in the history
  • Loading branch information
dynamotn committed Oct 6, 2024
1 parent af14ca7 commit 5a27b0a
Show file tree
Hide file tree
Showing 18 changed files with 340 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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.
- `enablePassProtected`: whether to enable [[Password protected]] on your site.
- `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;
Expand Down
16 changes: 16 additions & 0 deletions docs/features/Password Protected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Password Protected
---

Some notes may be sensitive, IE. Internal company documentation, business notes, personal health data 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, or confidential business data, 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.

## Configuration

- Disable password protected notes: set the `enablePasswordProtected` field in `quartz.config.ts` to be `false`.
- Style: `quartz/components/styles/passwordProtected.scss`
- Script: `quartz/components/scripts/decrypt.inline.ts`
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,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",
"serve-handler": "^6.1.5",
Expand Down
1 change: 1 addition & 0 deletions quartz.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const config: QuartzConfig = {
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
enablePassProtected: true,
analytics: {
provider: "plausible",
},
Expand Down
2 changes: 2 additions & 0 deletions quartz/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface GlobalConfiguration {
enableSPA: boolean
/** Whether to display Wikipedia-style popovers when hovering over links */
enablePopovers: boolean
/** Whether to enable password protected page rendering */
enablePassProtected: boolean
/** Analytics mode */
analytics: Analytics
/** Glob patterns to not search */
Expand Down
29 changes: 29 additions & 0 deletions quartz/components/pages/EncryptedContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { i18n } from "../../i18n"

const EncryptedContent: QuartzComponent = ({ iteration, 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>{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={iteration}>{encryptedContent}</pre>
</div>
<article id="content"></article>
</>
)
}

export default (() => EncryptedContent) satisfies QuartzComponentConstructor
18 changes: 15 additions & 3 deletions quartz/components/renderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -53,13 +55,13 @@ export function pageResources(
}
}

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
Expand Down Expand Up @@ -195,6 +197,7 @@ export function renderPage(
} = components
const Header = HeaderConstructor()
const Body = BodyConstructor()
const Encrypted = EncryptedContent()

const LeftComponent = (
<div class="left sidebar">
Expand All @@ -213,6 +216,15 @@ export function renderPage(
)

const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"

var content = (<Content {...componentData} />)
if (componentData.fileData.frontmatter?.password) {
componentData.iteration = 2e6
componentData.encryptedContent = await getEncryptedPayload(render(content), componentData.fileData.frontmatter.password, componentData.iteration)

Check failure on line 223 in quartz/components/renderPage.tsx

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Argument of type '{}' is not assignable to parameter of type 'string'.

Check failure on line 223 in quartz/components/renderPage.tsx

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Argument of type '{}' is not assignable to parameter of type 'string'.
content = (
<Encrypted {...componentData} />
)
}
const doc = (
<html lang={lang}>
<Head {...componentData} />
Expand All @@ -233,7 +245,7 @@ export function renderPage(
))}
</div>
</div>
<Content {...componentData} />
{content}
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (
Expand Down
167 changes: 167 additions & 0 deletions quartz/components/scripts/decrypt.inline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { base64 } from 'rfc4648'

function find<T>(selector: string): T {

Check failure on line 3 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Function lacks ending return statement and return type does not include 'undefined'.

Check failure on line 3 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Function lacks ending return statement and return type does not include 'undefined'.
const element = document.querySelector(selector) as T
if (element) return element
}

let salt: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array, iterations: number

async function decryptHTML() {
const pl = find<HTMLPreElement>('pre[data-i]')
if (!pl) {
return
}

find<HTMLFormElement>('form').addEventListener('submit', async (event) => {
event.preventDefault()
await decrypt()
})
const pwd = find<HTMLInputElement>('.pwd')
if (!subtle) {
error('modern')
pwd.disabled = true
}

show(find<HTMLDivElement>('#lock'))
if (!pl.innerHTML) {
pwd.disabled = true
error('empty')
return
}
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(find<HTMLDivElement>('#load'))
show(find<HTMLFormElement>('form'))
pwd.focus()
}
}

document.addEventListener('DOMContentLoaded', decryptHTML)
document.addEventListener('nav', decryptHTML)

const subtle =
window.crypto?.subtle ||
(window.crypto as unknown as { webkitSubtle: Crypto['subtle'] })
?.webkitSubtle

function show(element: Element) {
element.classList.remove('hidden')
}

function hide(element: Element) {
element.classList.add('hidden')
}

function error(code: string) {
const msg = find<HTMLParagraphElement>('#msg')
msg.innerText = msg.getAttribute('data-' + code!)

Check failure on line 71 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Type 'string | null' is not assignable to type 'string'.

Check failure on line 71 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Type 'string | null' is not assignable to type 'string'.
}

async function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}

async function decrypt() {
find<HTMLDivElement>('#load').lastElementChild.innerText = 'Decrypting...'

Check failure on line 79 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Object is possibly 'null'.

Check failure on line 79 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Property 'innerText' does not exist on type 'Element'.

Check failure on line 79 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Object is possibly 'null'.

Check failure on line 79 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Property 'innerText' does not exist on type 'Element'.
show(find<HTMLDivElement>('#load'))
hide(find<HTMLFormElement>('form'))
const pwd = find<HTMLInputElement>('.pwd')
await sleep(60)

try {
const decrypted = await decryptFile(
{ salt, iv, ciphertext, iterations },
pwd.value,
)

const article = find<HTMLArticleElement>('#content')

Check failure on line 91 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Cannot find name 'HTMLArticleElement'.

Check failure on line 91 in quartz/components/scripts/decrypt.inline.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Cannot find name 'HTMLArticleElement'.
article.innerHTML = decrypted
hide(find<HTMLDivElement>('#lock'))
} catch (e) {
hide(find<HTMLDivElement>('#load'))
show(find<HTMLFormElement>('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'

try {
sessionStorage[document.body.dataset.slug!] = JSON.stringify(await subtle.exportKey('jwk', key))
} catch (e) {
console.error(e)
}

return decoder.decode(data)
}
30 changes: 30 additions & 0 deletions quartz/components/styles/passProtected.scss
Original file line number Diff line number Diff line change
@@ -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: .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: .6em 1em;
}
9 changes: 9 additions & 0 deletions quartz/i18n/locales/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,14 @@ 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
}
}
}
9 changes: 9 additions & 0 deletions quartz/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,14 @@ 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.",
},
},
} as const satisfies Translation
2 changes: 1 addition & 1 deletion quartz/plugins/emitters/404.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down
Loading

0 comments on commit 5a27b0a

Please sign in to comment.