narrarium-astro-reader 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-dist/lib/content-crypto.d.ts +19 -0
- package/cli-dist/lib/content-crypto.d.ts.map +1 -0
- package/cli-dist/lib/content-crypto.js +45 -0
- package/cli-dist/lib/content-crypto.js.map +1 -0
- package/cli-dist/lib/reader-mode.d.ts +13 -0
- package/cli-dist/lib/reader-mode.d.ts.map +1 -1
- package/cli-dist/lib/reader-mode.js +21 -0
- package/cli-dist/lib/reader-mode.js.map +1 -1
- package/package.json +2 -2
- package/src/components/EncryptedHtml.astro +18 -0
- package/src/components/ReaderRuntime.astro +31 -6
- package/src/components/SiteSearch.astro +26 -4
- package/src/layouts/BaseLayout.astro +212 -2
- package/src/lib/content-crypto.ts +57 -0
- package/src/lib/reader-mode.ts +22 -0
- package/src/pages/chapters/[chapter].astro +20 -6
- package/src/pages/characters/[slug].astro +2 -1
- package/src/pages/factions/[slug].astro +2 -1
- package/src/pages/items/[slug].astro +2 -1
- package/src/pages/locations/[slug].astro +2 -1
- package/src/pages/secrets/[slug].astro +2 -1
- package/src/pages/timeline/[slug].astro +2 -1
- package/src/styles/global.css +96 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Return the singleton salt for this build process, creating it on first call. */
|
|
2
|
+
export declare function getBuildSalt(): Buffer;
|
|
3
|
+
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
4
|
+
export declare function getBuildSaltBase64(): string;
|
|
5
|
+
export interface EncryptedChunk {
|
|
6
|
+
/** Base64-encoded 12-byte random IV. */
|
|
7
|
+
iv: string;
|
|
8
|
+
/** Base64-encoded (ciphertext ∥ 16-byte GCM auth tag). */
|
|
9
|
+
ct: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
|
|
13
|
+
*
|
|
14
|
+
* The ciphertext field contains the encrypted bytes immediately followed by
|
|
15
|
+
* the 16-byte GCM authentication tag, so Web Crypto's AES-GCM decrypt can
|
|
16
|
+
* verify integrity without any additional framing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function encryptString(plaintext: string, password: string): EncryptedChunk;
|
|
19
|
+
//# sourceMappingURL=content-crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAcA,mFAAmF;AACnF,wBAAgB,YAAY,IAAI,MAAM,CAKrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAWjF"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Build-time AES-256-GCM content encryption utilities.
|
|
4
|
+
*
|
|
5
|
+
* A single random 16-byte salt is generated once per Node process (i.e. once
|
|
6
|
+
* per Astro build). It is embedded publicly in the built HTML via
|
|
7
|
+
* `data-crypto-salt` on `<body>` so the client can run PBKDF2 key derivation
|
|
8
|
+
* with the same parameters. A public salt is fine — it only prevents rainbow
|
|
9
|
+
* tables; the security comes from the password entropy.
|
|
10
|
+
*/
|
|
11
|
+
let _buildSalt = null;
|
|
12
|
+
/** Return the singleton salt for this build process, creating it on first call. */
|
|
13
|
+
export function getBuildSalt() {
|
|
14
|
+
if (!_buildSalt) {
|
|
15
|
+
_buildSalt = randomBytes(16);
|
|
16
|
+
}
|
|
17
|
+
return _buildSalt;
|
|
18
|
+
}
|
|
19
|
+
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
20
|
+
export function getBuildSaltBase64() {
|
|
21
|
+
return getBuildSalt().toString("base64");
|
|
22
|
+
}
|
|
23
|
+
function deriveKey(password, salt) {
|
|
24
|
+
return pbkdf2Sync(password, salt, 100_000, 32, "sha256");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
|
|
28
|
+
*
|
|
29
|
+
* The ciphertext field contains the encrypted bytes immediately followed by
|
|
30
|
+
* the 16-byte GCM authentication tag, so Web Crypto's AES-GCM decrypt can
|
|
31
|
+
* verify integrity without any additional framing.
|
|
32
|
+
*/
|
|
33
|
+
export function encryptString(plaintext, password) {
|
|
34
|
+
const salt = getBuildSalt();
|
|
35
|
+
const key = deriveKey(password, salt);
|
|
36
|
+
const iv = randomBytes(12);
|
|
37
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
38
|
+
const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
39
|
+
const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
|
|
40
|
+
return {
|
|
41
|
+
iv: iv.toString("base64"),
|
|
42
|
+
ct: Buffer.concat([body, tag]).toString("base64"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=content-crypto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-crypto.js","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;GAQG;AAEH,IAAI,UAAU,GAAkB,IAAI,CAAC;AAErC,mFAAmF;AACnF,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,UAAU,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAY;IAC/C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC3D,CAAC;AASD;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,QAAgB;IAC/D,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO;QACL,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACzB,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;KAClD,CAAC;AACJ,CAAC"}
|
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
export declare function isFullCanonMode(): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Returns the raw NARRARIUM_READER_PASSWORD env var value, or null when the
|
|
4
|
+
* variable is not set. Used at build time to derive the AES-256-GCM key for
|
|
5
|
+
* content encryption. Never embedded in the built HTML.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getReaderPassword(): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
|
|
10
|
+
* or null when the variable is not set. The hash is embedded in the built
|
|
11
|
+
* HTML and compared against the user's input at runtime via SubtleCrypto
|
|
12
|
+
* as a fast pre-filter before the more expensive PBKDF2 key derivation.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getReaderPasswordHash(): string | null;
|
|
2
15
|
//# sourceMappingURL=reader-mode.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reader-mode.d.ts","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"reader-mode.d.ts","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAGA,wBAAgB,eAAe,IAAI,OAAO,CAMzC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAEjD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,IAAI,CAIrD"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readReaderEnv } from "./env.js";
|
|
2
3
|
export function isFullCanonMode() {
|
|
3
4
|
const raw = String(readReaderEnv(["NARRARIUM_READER_CANON_MODE", "NARRARIUM_READER_ALLOW_FULL_CANON"]) ?? "")
|
|
@@ -5,4 +6,24 @@ export function isFullCanonMode() {
|
|
|
5
6
|
.toLowerCase();
|
|
6
7
|
return raw === "1" || raw === "true" || raw === "full" || raw === "author" || raw === "spoilers";
|
|
7
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Returns the raw NARRARIUM_READER_PASSWORD env var value, or null when the
|
|
11
|
+
* variable is not set. Used at build time to derive the AES-256-GCM key for
|
|
12
|
+
* content encryption. Never embedded in the built HTML.
|
|
13
|
+
*/
|
|
14
|
+
export function getReaderPassword() {
|
|
15
|
+
return readReaderEnv(["NARRARIUM_READER_PASSWORD"]) ?? null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
|
|
19
|
+
* or null when the variable is not set. The hash is embedded in the built
|
|
20
|
+
* HTML and compared against the user's input at runtime via SubtleCrypto
|
|
21
|
+
* as a fast pre-filter before the more expensive PBKDF2 key derivation.
|
|
22
|
+
*/
|
|
23
|
+
export function getReaderPasswordHash() {
|
|
24
|
+
const raw = readReaderEnv(["NARRARIUM_READER_PASSWORD"]);
|
|
25
|
+
if (!raw)
|
|
26
|
+
return null;
|
|
27
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
28
|
+
}
|
|
8
29
|
//# sourceMappingURL=reader-mode.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reader-mode.js","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,aAAa,CAAC,CAAC,6BAA6B,EAAE,mCAAmC,CAAC,CAAC,IAAI,EAAE,CAAC;SAC1G,IAAI,EAAE;SACN,WAAW,EAAE,CAAC;IAEjB,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,UAAU,CAAC;AACnG,CAAC"}
|
|
1
|
+
{"version":3,"file":"reader-mode.js","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,aAAa,CAAC,CAAC,6BAA6B,EAAE,mCAAmC,CAAC,CAAC,IAAI,EAAE,CAAC;SAC1G,IAAI,EAAE;SACN,WAAW,EAAE,CAAC;IAEjB,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,UAAU,CAAC;AACnG,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,aAAa,CAAC,CAAC,2BAA2B,CAAC,CAAC,IAAI,IAAI,CAAC;AAC9D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB;IACnC,MAAM,GAAG,GAAG,aAAa,CAAC,CAAC,2BAA2B,CAAC,CAAC,CAAC;IACzD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACxD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "narrarium-astro-reader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Astro reader and scaffolding CLI for Narrarium book repositories.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"test": "npm run build:cli && node --test test/**/*.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"narrarium": "^0.1.
|
|
53
|
+
"narrarium": "^0.1.27",
|
|
54
54
|
"astro": "^5.14.1",
|
|
55
55
|
"chokidar": "^4.0.3",
|
|
56
56
|
"marked": "^16.3.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getReaderPassword } from "../lib/reader-mode.js";
|
|
3
|
+
import { encryptString } from "../lib/content-crypto.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Raw HTML string to either render directly or encrypt at build time. */
|
|
7
|
+
html: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { html } = Astro.props;
|
|
11
|
+
const password = getReaderPassword();
|
|
12
|
+
const encrypted = password ? encryptString(html, password) : null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{encrypted
|
|
16
|
+
? <span data-enc-html data-enc-iv={encrypted.iv} data-enc-ct={encrypted.ct} hidden></span>
|
|
17
|
+
: <Fragment set:html={html} />
|
|
18
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { GlossaryEntry } from "../lib/glossary";
|
|
3
|
+
import { getReaderPassword } from "../lib/reader-mode.js";
|
|
4
|
+
import { encryptString } from "../lib/content-crypto.js";
|
|
3
5
|
|
|
4
6
|
interface Props {
|
|
5
7
|
glossary: GlossaryEntry[];
|
|
@@ -8,7 +10,9 @@ interface Props {
|
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
const { glossary, chapters } = Astro.props;
|
|
11
|
-
const
|
|
13
|
+
const password = getReaderPassword();
|
|
14
|
+
const rawGlossaryJson = JSON.stringify(glossary).replace(/</g, "\\u003c");
|
|
15
|
+
const glossaryEncrypted = password ? encryptString(rawGlossaryJson, password) : null;
|
|
12
16
|
const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
13
17
|
---
|
|
14
18
|
|
|
@@ -144,7 +148,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
144
148
|
<div class="tts-status" data-tts-status>Speech will resume from the saved sentence when available.</div>
|
|
145
149
|
</aside>
|
|
146
150
|
|
|
147
|
-
|
|
151
|
+
{glossaryEncrypted
|
|
152
|
+
? <script type="application/json" id="canon-glossary-data" data-enc-iv={glossaryEncrypted.iv} data-enc-ct={glossaryEncrypted.ct}></script>
|
|
153
|
+
: <script type="application/json" id="canon-glossary-data" set:html={rawGlossaryJson}></script>
|
|
154
|
+
}
|
|
148
155
|
<script type="application/json" id="reader-chapter-data" set:html={chaptersJson}></script>
|
|
149
156
|
|
|
150
157
|
<script>
|
|
@@ -160,21 +167,39 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
160
167
|
const ttsRateStorageKey = "narrarium-reader-tts-rate";
|
|
161
168
|
const ttsProgressStorageKey = "narrarium-reader-tts-progress";
|
|
162
169
|
const ttsPlayerDismissedStorageKey = "narrarium-reader-tts-player-dismissed";
|
|
163
|
-
const glossaryDataElement = document.getElementById("canon-glossary-data");
|
|
164
170
|
const chapterDataElement = document.getElementById("reader-chapter-data");
|
|
165
|
-
const glossaryEntries = glossaryDataElement ? JSON.parse(glossaryDataElement.textContent || "[]") : [];
|
|
166
171
|
const chapterEntries = chapterDataElement ? JSON.parse(chapterDataElement.textContent || "[]") : [];
|
|
167
172
|
const currentPageChapterRaw = document.body.dataset.readerChapterNumber;
|
|
168
173
|
const currentPageChapterNumber = currentPageChapterRaw ? Number(currentPageChapterRaw) : Number.NaN;
|
|
169
174
|
|
|
175
|
+
// glossaryEntries is populated after content decryption (or immediately when
|
|
176
|
+
// there is no password protection). Declared as let so _loadGlossaryAndInit
|
|
177
|
+
// can reassign before dependent initializers run.
|
|
178
|
+
// eslint-disable-next-line prefer-const
|
|
179
|
+
let glossaryEntries: any[] = [];
|
|
180
|
+
|
|
181
|
+
function _loadGlossaryAndInit() {
|
|
182
|
+
const el = document.getElementById("canon-glossary-data");
|
|
183
|
+
// Guard: skip if the element still carries encrypted attrs (not yet decrypted).
|
|
184
|
+
if (!el || el.hasAttribute("data-enc-iv")) return;
|
|
185
|
+
try { glossaryEntries = JSON.parse(el.textContent || "[]"); } catch { glossaryEntries = []; }
|
|
186
|
+
initializeCanonMentions();
|
|
187
|
+
initializeReadAloud();
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
initializeThemeToggle();
|
|
171
191
|
initializeReaderPreferences();
|
|
172
192
|
initializeReaderSettings();
|
|
173
193
|
initializeSearchOverlay();
|
|
174
194
|
initializeCanonModeToggle();
|
|
175
195
|
initializeReadingProgress();
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
|
|
197
|
+
// Wait for content to be available before initializing glossary-dependent features.
|
|
198
|
+
((window as any)._narrariumContentReady || Promise.resolve("plain")).then((state: string) => {
|
|
199
|
+
if (state !== "locked") _loadGlossaryAndInit();
|
|
200
|
+
});
|
|
201
|
+
// Re-init after manual password unlock (state was "locked" when promise resolved).
|
|
202
|
+
document.addEventListener("narrarium:content-unlocked", _loadGlossaryAndInit);
|
|
178
203
|
|
|
179
204
|
function initializeThemeToggle() {
|
|
180
205
|
const saved = readStorage(themeStorageKey);
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { SearchEntry } from "../lib/search";
|
|
3
|
+
import { getReaderPassword } from "../lib/reader-mode.js";
|
|
4
|
+
import { encryptString } from "../lib/content-crypto.js";
|
|
3
5
|
|
|
4
6
|
interface Props {
|
|
5
7
|
entries: SearchEntry[];
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
const { entries } = Astro.props;
|
|
9
|
-
const
|
|
11
|
+
const password = getReaderPassword();
|
|
12
|
+
const rawEntriesJson = JSON.stringify(entries).replace(/</g, "\\u003c");
|
|
13
|
+
const entriesEncrypted = password ? encryptString(rawEntriesJson, password) : null;
|
|
10
14
|
const filters = [
|
|
11
15
|
{ key: "all", label: "All" },
|
|
12
16
|
...Array.from(new Map(entries.map((entry) => [entry.kindKey, entry.kind])).entries()).map(([key, label]) => ({ key, label })),
|
|
@@ -40,15 +44,26 @@ const filters = [
|
|
|
40
44
|
</div>
|
|
41
45
|
</div>
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
{entriesEncrypted
|
|
48
|
+
? <script type="application/json" id="reader-search-data" data-enc-iv={entriesEncrypted.iv} data-enc-ct={entriesEncrypted.ct}></script>
|
|
49
|
+
: <script type="application/json" id="reader-search-data" set:html={rawEntriesJson}></script>
|
|
50
|
+
}
|
|
44
51
|
|
|
45
52
|
<script is:inline>
|
|
46
53
|
(() => {
|
|
47
54
|
const spoilerLimitStorageKey = "narrarium-reader-spoiler-limit";
|
|
48
|
-
const searchDataElement = document.getElementById("reader-search-data");
|
|
49
|
-
const searchEntries = searchDataElement ? JSON.parse(searchDataElement.textContent || "[]") : [];
|
|
50
55
|
const searchRoot = document.querySelector("[data-site-search]");
|
|
51
56
|
|
|
57
|
+
// searchEntries is populated once content is ready (after decryption when
|
|
58
|
+
// password-protected, or immediately for plain builds).
|
|
59
|
+
var searchEntries = [];
|
|
60
|
+
|
|
61
|
+
function loadSearchEntries() {
|
|
62
|
+
const el = document.getElementById("reader-search-data");
|
|
63
|
+
if (!el || el.hasAttribute("data-enc-iv")) return; // still encrypted
|
|
64
|
+
try { searchEntries = JSON.parse(el.textContent || "[]"); } catch { searchEntries = []; }
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
if (searchRoot) {
|
|
53
68
|
const input = searchRoot.querySelector("[data-search-input]");
|
|
54
69
|
const results = searchRoot.querySelector("[data-search-results]");
|
|
@@ -102,6 +117,13 @@ const filters = [
|
|
|
102
117
|
});
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
// Load search entries once content is available.
|
|
121
|
+
(window._narrariumContentReady || Promise.resolve("plain")).then(function (state) {
|
|
122
|
+
if (state !== "locked") loadSearchEntries();
|
|
123
|
+
});
|
|
124
|
+
// Re-load after manual password unlock.
|
|
125
|
+
document.addEventListener("narrarium:content-unlocked", loadSearchEntries);
|
|
126
|
+
|
|
105
127
|
function getSpoilerLimit() {
|
|
106
128
|
const value = Number(localStorage.getItem(spoilerLimitStorageKey) || "");
|
|
107
129
|
return Number.isFinite(value) ? value : null;
|
|
@@ -5,7 +5,8 @@ import SiteSearch from "../components/SiteSearch.astro";
|
|
|
5
5
|
import { listChapters } from "narrarium";
|
|
6
6
|
import { getBookRoot } from "../lib/book.js";
|
|
7
7
|
import { loadCanonGlossary } from "../lib/glossary.js";
|
|
8
|
-
import { isFullCanonMode } from "../lib/reader-mode.js";
|
|
8
|
+
import { isFullCanonMode, getReaderPasswordHash, getReaderPassword } from "../lib/reader-mode.js";
|
|
9
|
+
import { getBuildSaltBase64 } from "../lib/content-crypto.js";
|
|
9
10
|
import { loadSearchIndex } from "../lib/search.js";
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
@@ -29,6 +30,8 @@ const canonGlossary = await loadCanonGlossary(currentChapterNumber);
|
|
|
29
30
|
const searchIndex = await loadSearchIndex(currentChapterNumber);
|
|
30
31
|
const readerChapters = await listChapters(getBookRoot()).catch(() => []);
|
|
31
32
|
const fullCanonMode = isFullCanonMode();
|
|
33
|
+
const passwordHash = getReaderPasswordHash();
|
|
34
|
+
const cryptoSalt = getReaderPassword() ? getBuildSaltBase64() : null;
|
|
32
35
|
const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
33
36
|
---
|
|
34
37
|
|
|
@@ -59,7 +62,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
59
62
|
})();
|
|
60
63
|
</script>
|
|
61
64
|
</head>
|
|
62
|
-
|
|
65
|
+
<body class:list={[isChapterPage && "page-chapter"]} data-reader-chapter-number={currentChapterNumber} data-full-canon={fullCanonMode ? "true" : "false"} data-reader-password-hash={passwordHash ?? ""} data-crypto-salt={cryptoSalt ?? undefined}>
|
|
63
66
|
<script is:inline>
|
|
64
67
|
(() => {
|
|
65
68
|
try {
|
|
@@ -70,6 +73,138 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
70
73
|
} catch {}
|
|
71
74
|
})();
|
|
72
75
|
</script>
|
|
76
|
+
<script is:inline>
|
|
77
|
+
(function () {
|
|
78
|
+
/* ── Content-ready promise ────────────────────────────────────────────
|
|
79
|
+
Resolved values:
|
|
80
|
+
"plain" – no encryption, content available immediately
|
|
81
|
+
"locked" – encrypted, no stored key, waiting for user password
|
|
82
|
+
"decrypted" – encrypted, auto-decrypted from stored AES key
|
|
83
|
+
──────────────────────────────────────────────────────────────────── */
|
|
84
|
+
var salt = document.body.dataset.cryptoSalt;
|
|
85
|
+
|
|
86
|
+
if (!salt) {
|
|
87
|
+
// No encryption configured — content is always available.
|
|
88
|
+
window._narrariumContentReady = Promise.resolve("plain");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
93
|
+
function b64ToBytes(b64) {
|
|
94
|
+
var bin = atob(b64);
|
|
95
|
+
var bytes = new Uint8Array(bin.length);
|
|
96
|
+
for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
|
|
97
|
+
return bytes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function aesDecrypt(cryptoKey, ivB64, ctB64) {
|
|
101
|
+
return crypto.subtle.decrypt(
|
|
102
|
+
{ name: "AES-GCM", iv: b64ToBytes(ivB64) },
|
|
103
|
+
cryptoKey,
|
|
104
|
+
b64ToBytes(ctB64)
|
|
105
|
+
).then(function (buf) { return new TextDecoder().decode(buf); });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function decryptAllContent(cryptoKey) {
|
|
109
|
+
var tasks = [];
|
|
110
|
+
|
|
111
|
+
// Decrypt JSON script data tags
|
|
112
|
+
document.querySelectorAll('script[type="application/json"][data-enc-iv]').forEach(function (el) {
|
|
113
|
+
var iv = el.getAttribute("data-enc-iv");
|
|
114
|
+
var ct = el.getAttribute("data-enc-ct");
|
|
115
|
+
if (!iv || !ct) return;
|
|
116
|
+
tasks.push(
|
|
117
|
+
aesDecrypt(cryptoKey, iv, ct).then(function (plaintext) {
|
|
118
|
+
el.textContent = plaintext;
|
|
119
|
+
el.removeAttribute("data-enc-iv");
|
|
120
|
+
el.removeAttribute("data-enc-ct");
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Decrypt inline HTML placeholder spans
|
|
126
|
+
document.querySelectorAll("[data-enc-html]").forEach(function (el) {
|
|
127
|
+
var iv = el.getAttribute("data-enc-iv");
|
|
128
|
+
var ct = el.getAttribute("data-enc-ct");
|
|
129
|
+
if (!iv || !ct) return;
|
|
130
|
+
tasks.push(
|
|
131
|
+
aesDecrypt(cryptoKey, iv, ct).then(function (html) {
|
|
132
|
+
var tpl = document.createElement("template");
|
|
133
|
+
tpl.innerHTML = html;
|
|
134
|
+
el.parentNode.insertBefore(tpl.content, el);
|
|
135
|
+
el.parentNode.removeChild(el);
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return Promise.all(tasks);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Deferred promise setup ───────────────────────────────────────────
|
|
144
|
+
var _resolveReady;
|
|
145
|
+
window._narrariumContentReady = new Promise(function (res) { _resolveReady = res; });
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Decrypt all page content with the given CryptoKey, remove the lock
|
|
149
|
+
* class, and resolve _narrariumContentReady.
|
|
150
|
+
* @param {boolean} isAutoDecrypt - true when called from stored-key path;
|
|
151
|
+
* false (default) when called after the user submits the password form.
|
|
152
|
+
* When false, dispatches narrarium:content-unlocked so late-init scripts
|
|
153
|
+
* that already observed "locked" can re-initialize.
|
|
154
|
+
*/
|
|
155
|
+
window._narrariumDecryptPage = function (cryptoKey, isAutoDecrypt) {
|
|
156
|
+
return decryptAllContent(cryptoKey).then(function () {
|
|
157
|
+
document.body.classList.remove("reader-auth-locked");
|
|
158
|
+
_resolveReady("decrypted");
|
|
159
|
+
if (!isAutoDecrypt) {
|
|
160
|
+
document.dispatchEvent(new CustomEvent("narrarium:content-unlocked"));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ── Try auto-decrypt from stored AES key ─────────────────────────────
|
|
166
|
+
var storedKeyB64 = null;
|
|
167
|
+
try { storedKeyB64 = localStorage.getItem("narrarium-reader-aes-key"); } catch (_) {}
|
|
168
|
+
|
|
169
|
+
if (storedKeyB64) {
|
|
170
|
+
crypto.subtle.importKey(
|
|
171
|
+
"raw", b64ToBytes(storedKeyB64),
|
|
172
|
+
{ name: "AES-GCM", length: 256 },
|
|
173
|
+
false, ["decrypt"]
|
|
174
|
+
)
|
|
175
|
+
.then(function (key) { return window._narrariumDecryptPage(key, true); })
|
|
176
|
+
.catch(function () {
|
|
177
|
+
// Stored key is stale or corrupt — clear it and show lock screen.
|
|
178
|
+
try { localStorage.removeItem("narrarium-reader-aes-key"); } catch (_) {}
|
|
179
|
+
document.body.classList.add("reader-auth-locked");
|
|
180
|
+
_resolveReady("locked");
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
document.body.classList.add("reader-auth-locked");
|
|
184
|
+
_resolveReady("locked");
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
</script>
|
|
188
|
+
{passwordHash && (
|
|
189
|
+
<div id="reader-password-gate" role="dialog" aria-modal="true" aria-label="Access required">
|
|
190
|
+
<div class="reader-password-gate__inner">
|
|
191
|
+
<p class="eyebrow">Narrarium Reader</p>
|
|
192
|
+
<h1 class="reader-password-gate__title">Access Required</h1>
|
|
193
|
+
<p class="reader-password-gate__desc">Enter the password to access this book.</p>
|
|
194
|
+
<form id="reader-password-form" class="reader-password-gate__form" novalidate>
|
|
195
|
+
<input
|
|
196
|
+
type="password"
|
|
197
|
+
id="reader-password-input"
|
|
198
|
+
class="reader-password-gate__input"
|
|
199
|
+
placeholder="Password"
|
|
200
|
+
autocomplete="current-password"
|
|
201
|
+
/>
|
|
202
|
+
<button type="submit" class="reader-password-gate__button">Unlock</button>
|
|
203
|
+
</form>
|
|
204
|
+
<p id="reader-password-error" class="reader-password-gate__error" hidden>Incorrect password. Please try again.</p>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
73
208
|
<main class="shell">
|
|
74
209
|
<header class="masthead">
|
|
75
210
|
<a class="brand" href="./">Narrarium</a>
|
|
@@ -95,5 +230,80 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
95
230
|
</main>
|
|
96
231
|
<SiteSearch entries={searchIndex} />
|
|
97
232
|
<ReaderRuntime glossary={canonGlossary} chapters={readerChapters} currentChapterNumber={currentChapterNumber} />
|
|
233
|
+
{passwordHash && (
|
|
234
|
+
<script is:inline>
|
|
235
|
+
(function initPasswordGate() {
|
|
236
|
+
var gate = document.getElementById("reader-password-gate");
|
|
237
|
+
var form = document.getElementById("reader-password-form");
|
|
238
|
+
var input = document.getElementById("reader-password-input");
|
|
239
|
+
var errorEl = document.getElementById("reader-password-error");
|
|
240
|
+
var passwordHash = document.body.dataset.readerPasswordHash;
|
|
241
|
+
var cryptoSalt = document.body.dataset.cryptoSalt;
|
|
242
|
+
if (!gate || !form || !passwordHash) return;
|
|
243
|
+
if (input) { input.focus(); }
|
|
244
|
+
|
|
245
|
+
function buf2hex(buffer) {
|
|
246
|
+
return Array.from(new Uint8Array(buffer))
|
|
247
|
+
.map(function (b) { return b.toString(16).padStart(2, "0"); })
|
|
248
|
+
.join("");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function b64ToBytes(b64) {
|
|
252
|
+
var bin = atob(b64);
|
|
253
|
+
var bytes = new Uint8Array(bin.length);
|
|
254
|
+
for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
|
|
255
|
+
return bytes;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
form.addEventListener("submit", function (event) {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
var password = input ? input.value : "";
|
|
261
|
+
if (!password) return;
|
|
262
|
+
|
|
263
|
+
var encoded = new TextEncoder().encode(password);
|
|
264
|
+
|
|
265
|
+
// Quick SHA-256 check before the expensive PBKDF2 step.
|
|
266
|
+
crypto.subtle.digest("SHA-256", encoded).then(function (hashBuffer) {
|
|
267
|
+
if (buf2hex(hashBuffer) !== passwordHash) {
|
|
268
|
+
if (errorEl) { errorEl.hidden = false; }
|
|
269
|
+
if (input) { input.value = ""; input.focus(); }
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Hash matched — derive the AES-256-GCM key via PBKDF2.
|
|
274
|
+
var saltBytes = b64ToBytes(cryptoSalt);
|
|
275
|
+
crypto.subtle.importKey("raw", encoded, "PBKDF2", false, ["deriveKey"])
|
|
276
|
+
.then(function (keyMaterial) {
|
|
277
|
+
return crypto.subtle.deriveKey(
|
|
278
|
+
{ name: "PBKDF2", salt: saltBytes, iterations: 100000, hash: "SHA-256" },
|
|
279
|
+
keyMaterial,
|
|
280
|
+
{ name: "AES-GCM", length: 256 },
|
|
281
|
+
true, // extractable so we can export and store it
|
|
282
|
+
["decrypt"]
|
|
283
|
+
);
|
|
284
|
+
})
|
|
285
|
+
.then(function (aesKey) {
|
|
286
|
+
// Persist the raw key bytes so auto-decrypt works on reload.
|
|
287
|
+
crypto.subtle.exportKey("raw", aesKey).then(function (rawBuf) {
|
|
288
|
+
var rawBytes = new Uint8Array(rawBuf);
|
|
289
|
+
var b64 = btoa(String.fromCharCode.apply(null, Array.from(rawBytes)));
|
|
290
|
+
try { localStorage.setItem("narrarium-reader-aes-key", b64); } catch (_) {}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Decrypt the page — passes false = manual unlock, so the
|
|
294
|
+
// content-unlocked event will fire for late-init scripts.
|
|
295
|
+
return window._narrariumDecryptPage(aesKey, false);
|
|
296
|
+
})
|
|
297
|
+
.catch(function () {
|
|
298
|
+
if (errorEl) { errorEl.hidden = false; }
|
|
299
|
+
if (input) { input.value = ""; input.focus(); }
|
|
300
|
+
});
|
|
301
|
+
}).catch(function () {
|
|
302
|
+
if (errorEl) { errorEl.hidden = false; }
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
})();
|
|
306
|
+
</script>
|
|
307
|
+
)}
|
|
98
308
|
</body>
|
|
99
309
|
</html>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build-time AES-256-GCM content encryption utilities.
|
|
5
|
+
*
|
|
6
|
+
* A single random 16-byte salt is generated once per Node process (i.e. once
|
|
7
|
+
* per Astro build). It is embedded publicly in the built HTML via
|
|
8
|
+
* `data-crypto-salt` on `<body>` so the client can run PBKDF2 key derivation
|
|
9
|
+
* with the same parameters. A public salt is fine — it only prevents rainbow
|
|
10
|
+
* tables; the security comes from the password entropy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let _buildSalt: Buffer | null = null;
|
|
14
|
+
|
|
15
|
+
/** Return the singleton salt for this build process, creating it on first call. */
|
|
16
|
+
export function getBuildSalt(): Buffer {
|
|
17
|
+
if (!_buildSalt) {
|
|
18
|
+
_buildSalt = randomBytes(16);
|
|
19
|
+
}
|
|
20
|
+
return _buildSalt;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
24
|
+
export function getBuildSaltBase64(): string {
|
|
25
|
+
return getBuildSalt().toString("base64");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deriveKey(password: string, salt: Buffer): Buffer {
|
|
29
|
+
return pbkdf2Sync(password, salt, 100_000, 32, "sha256");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EncryptedChunk {
|
|
33
|
+
/** Base64-encoded 12-byte random IV. */
|
|
34
|
+
iv: string;
|
|
35
|
+
/** Base64-encoded (ciphertext ∥ 16-byte GCM auth tag). */
|
|
36
|
+
ct: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
|
|
41
|
+
*
|
|
42
|
+
* The ciphertext field contains the encrypted bytes immediately followed by
|
|
43
|
+
* the 16-byte GCM authentication tag, so Web Crypto's AES-GCM decrypt can
|
|
44
|
+
* verify integrity without any additional framing.
|
|
45
|
+
*/
|
|
46
|
+
export function encryptString(plaintext: string, password: string): EncryptedChunk {
|
|
47
|
+
const salt = getBuildSalt();
|
|
48
|
+
const key = deriveKey(password, salt);
|
|
49
|
+
const iv = randomBytes(12);
|
|
50
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
51
|
+
const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
52
|
+
const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
|
|
53
|
+
return {
|
|
54
|
+
iv: iv.toString("base64"),
|
|
55
|
+
ct: Buffer.concat([body, tag]).toString("base64"),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/lib/reader-mode.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readReaderEnv } from "./env.js";
|
|
2
3
|
|
|
3
4
|
export function isFullCanonMode(): boolean {
|
|
@@ -7,3 +8,24 @@ export function isFullCanonMode(): boolean {
|
|
|
7
8
|
|
|
8
9
|
return raw === "1" || raw === "true" || raw === "full" || raw === "author" || raw === "spoilers";
|
|
9
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns the raw NARRARIUM_READER_PASSWORD env var value, or null when the
|
|
14
|
+
* variable is not set. Used at build time to derive the AES-256-GCM key for
|
|
15
|
+
* content encryption. Never embedded in the built HTML.
|
|
16
|
+
*/
|
|
17
|
+
export function getReaderPassword(): string | null {
|
|
18
|
+
return readReaderEnv(["NARRARIUM_READER_PASSWORD"]) ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
|
|
23
|
+
* or null when the variable is not set. The hash is embedded in the built
|
|
24
|
+
* HTML and compared against the user's input at runtime via SubtleCrypto
|
|
25
|
+
* as a fast pre-filter before the more expensive PBKDF2 key derivation.
|
|
26
|
+
*/
|
|
27
|
+
export function getReaderPasswordHash(): string | null {
|
|
28
|
+
const raw = readReaderEnv(["NARRARIUM_READER_PASSWORD"]);
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
31
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { listChapters, pathExists } from "narrarium";
|
|
5
5
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
6
6
|
import ChapterPager from "../../components/ChapterPager.astro";
|
|
7
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
7
8
|
import LinkedValue from "../../components/LinkedValue.astro";
|
|
8
9
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
9
10
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
@@ -89,8 +90,9 @@ const renderedParagraphs = await Promise.all(
|
|
|
89
90
|
data-reader-marker-scope="chapter-intro"
|
|
90
91
|
data-tts-root="chapter-intro"
|
|
91
92
|
data-tts-label="Chapter opening"
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
>
|
|
94
|
+
<EncryptedHtml html={chapterHtml} />
|
|
95
|
+
</article>
|
|
94
96
|
)}
|
|
95
97
|
|
|
96
98
|
{chapterFigure && <AssetFigure figure={chapterFigure} className="scene-media focus-hidden" />}
|
|
@@ -156,7 +158,9 @@ const renderedParagraphs = await Promise.all(
|
|
|
156
158
|
</p>
|
|
157
159
|
)}
|
|
158
160
|
|
|
159
|
-
<div class="prose" data-tts-root={paragraph.anchorId} data-tts-label={paragraph.metadata.title}
|
|
161
|
+
<div class="prose" data-tts-root={paragraph.anchorId} data-tts-label={paragraph.metadata.title}>
|
|
162
|
+
<EncryptedHtml html={paragraph.html} />
|
|
163
|
+
</div>
|
|
160
164
|
{paragraph.figure && <AssetFigure figure={paragraph.figure} className="scene-media focus-hidden" />}
|
|
161
165
|
</article>
|
|
162
166
|
))
|
|
@@ -214,7 +218,7 @@ const renderedParagraphs = await Promise.all(
|
|
|
214
218
|
</aside>
|
|
215
219
|
|
|
216
220
|
<div class="focus-hint" data-reader-focus-hint aria-hidden="true">
|
|
217
|
-
|
|
221
|
+
Double-tap or double-click to show controls, or press a key.
|
|
218
222
|
</div>
|
|
219
223
|
|
|
220
224
|
<script is:inline>
|
|
@@ -240,6 +244,8 @@ const renderedParagraphs = await Promise.all(
|
|
|
240
244
|
let railPointerInside = false;
|
|
241
245
|
let focusHintShownForSession = false;
|
|
242
246
|
let refreshScheduled = false;
|
|
247
|
+
let lastTapTime = 0;
|
|
248
|
+
const doubleTapThresholdMs = 300;
|
|
243
249
|
|
|
244
250
|
applyFocusMode(readStorage(focusModeKey) === "true", false, { allowFullscreenRequest: false });
|
|
245
251
|
updateMarkerUi(readMarker());
|
|
@@ -247,8 +253,16 @@ const renderedParagraphs = await Promise.all(
|
|
|
247
253
|
|
|
248
254
|
window.addEventListener("scroll", scheduleSceneRefresh, { passive: true });
|
|
249
255
|
window.addEventListener("resize", scheduleSceneRefresh);
|
|
250
|
-
document.addEventListener("
|
|
251
|
-
document.addEventListener("
|
|
256
|
+
document.addEventListener("dblclick", handleFocusRailWake);
|
|
257
|
+
document.addEventListener("touchend", (event) => {
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
if (now - lastTapTime < doubleTapThresholdMs) {
|
|
260
|
+
handleFocusRailWake();
|
|
261
|
+
lastTapTime = 0;
|
|
262
|
+
} else {
|
|
263
|
+
lastTapTime = now;
|
|
264
|
+
}
|
|
265
|
+
}, { passive: true });
|
|
252
266
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
253
267
|
|
|
254
268
|
focusRail?.addEventListener("pointerenter", () => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listEntities, pathExists } from "narrarium";
|
|
4
4
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
5
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
5
6
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
6
7
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
7
8
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("character", entity);
|
|
|
53
54
|
|
|
54
55
|
{view.html && (
|
|
55
56
|
<section class="section">
|
|
56
|
-
<article class="scene prose"
|
|
57
|
+
<article class="scene prose"><EncryptedHtml html={view.html} /></article>
|
|
57
58
|
</section>
|
|
58
59
|
)}
|
|
59
60
|
</BaseLayout>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listEntities, pathExists } from "narrarium";
|
|
4
4
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
5
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
5
6
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
6
7
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
7
8
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("faction", entity);
|
|
|
53
54
|
|
|
54
55
|
{view.html && (
|
|
55
56
|
<section class="section">
|
|
56
|
-
<article class="scene prose"
|
|
57
|
+
<article class="scene prose"><EncryptedHtml html={view.html} /></article>
|
|
57
58
|
</section>
|
|
58
59
|
)}
|
|
59
60
|
</BaseLayout>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listEntities, pathExists } from "narrarium";
|
|
4
4
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
5
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
5
6
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
6
7
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
7
8
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("item", entity);
|
|
|
53
54
|
|
|
54
55
|
{view.html && (
|
|
55
56
|
<section class="section">
|
|
56
|
-
<article class="scene prose"
|
|
57
|
+
<article class="scene prose"><EncryptedHtml html={view.html} /></article>
|
|
57
58
|
</section>
|
|
58
59
|
)}
|
|
59
60
|
</BaseLayout>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listEntities, pathExists } from "narrarium";
|
|
4
4
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
5
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
5
6
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
6
7
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
7
8
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("location", entity);
|
|
|
53
54
|
|
|
54
55
|
{view.html && (
|
|
55
56
|
<section class="section">
|
|
56
|
-
<article class="scene prose"
|
|
57
|
+
<article class="scene prose"><EncryptedHtml html={view.html} /></article>
|
|
57
58
|
</section>
|
|
58
59
|
)}
|
|
59
60
|
</BaseLayout>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listEntities, pathExists } from "narrarium";
|
|
4
4
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
5
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
5
6
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
6
7
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
7
8
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("secret", entity);
|
|
|
53
54
|
|
|
54
55
|
{view.html && (
|
|
55
56
|
<section class="section">
|
|
56
|
-
<article class="scene prose"
|
|
57
|
+
<article class="scene prose"><EncryptedHtml html={view.html} /></article>
|
|
57
58
|
</section>
|
|
58
59
|
)}
|
|
59
60
|
</BaseLayout>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listEntities, pathExists } from "narrarium";
|
|
4
4
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
5
|
+
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
5
6
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
6
7
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
7
8
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("timeline-event", entity);
|
|
|
53
54
|
|
|
54
55
|
{view.html && (
|
|
55
56
|
<section class="section">
|
|
56
|
-
<article class="scene prose"
|
|
57
|
+
<article class="scene prose"><EncryptedHtml html={view.html} /></article>
|
|
57
58
|
</section>
|
|
58
59
|
)}
|
|
59
60
|
</BaseLayout>
|
package/src/styles/global.css
CHANGED
|
@@ -119,6 +119,7 @@ html[data-theme="dark"] .focus-rail__button.is-primary {
|
|
|
119
119
|
|
|
120
120
|
body.is-focus-mode {
|
|
121
121
|
background: var(--app-bg);
|
|
122
|
+
touch-action: manipulation;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
body.is-focus-mode .masthead,
|
|
@@ -1464,3 +1465,98 @@ mark {
|
|
|
1464
1465
|
gap: 0 1rem;
|
|
1465
1466
|
}
|
|
1466
1467
|
}
|
|
1468
|
+
|
|
1469
|
+
/* ─── Password gate ──────────────────────────────────────────── */
|
|
1470
|
+
#reader-password-gate {
|
|
1471
|
+
display: none;
|
|
1472
|
+
position: fixed;
|
|
1473
|
+
inset: 0;
|
|
1474
|
+
z-index: 200;
|
|
1475
|
+
background: var(--app-bg);
|
|
1476
|
+
align-items: center;
|
|
1477
|
+
justify-content: center;
|
|
1478
|
+
flex-direction: column;
|
|
1479
|
+
padding: 1.5rem;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
body.reader-auth-locked {
|
|
1483
|
+
overflow: hidden;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
body.reader-auth-locked #reader-password-gate {
|
|
1487
|
+
display: flex;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
.reader-password-gate__inner {
|
|
1491
|
+
width: min(380px, 100%);
|
|
1492
|
+
text-align: center;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
.reader-password-gate__title {
|
|
1496
|
+
font-size: clamp(1.5rem, 3vw, 2rem);
|
|
1497
|
+
margin: 0.5rem 0 0;
|
|
1498
|
+
letter-spacing: -0.02em;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
.reader-password-gate__desc {
|
|
1502
|
+
margin: 0.75rem 0 0;
|
|
1503
|
+
color: var(--text-muted);
|
|
1504
|
+
font-size: 0.95rem;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
.reader-password-gate__form {
|
|
1508
|
+
display: flex;
|
|
1509
|
+
flex-direction: column;
|
|
1510
|
+
gap: 0.75rem;
|
|
1511
|
+
margin-top: 1.75rem;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
.reader-password-gate__input {
|
|
1515
|
+
width: 100%;
|
|
1516
|
+
padding: 0.7rem 1rem;
|
|
1517
|
+
border: 1px solid var(--line);
|
|
1518
|
+
border-radius: 10px;
|
|
1519
|
+
background: var(--surface);
|
|
1520
|
+
color: var(--text);
|
|
1521
|
+
font: inherit;
|
|
1522
|
+
font-size: 1rem;
|
|
1523
|
+
text-align: center;
|
|
1524
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
.reader-password-gate__input:focus {
|
|
1528
|
+
outline: none;
|
|
1529
|
+
border-color: var(--accent);
|
|
1530
|
+
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
.reader-password-gate__button {
|
|
1534
|
+
display: inline-flex;
|
|
1535
|
+
align-items: center;
|
|
1536
|
+
justify-content: center;
|
|
1537
|
+
padding: 0.65rem 1.5rem;
|
|
1538
|
+
border: 1px solid var(--accent);
|
|
1539
|
+
border-radius: 100px;
|
|
1540
|
+
background: var(--accent);
|
|
1541
|
+
color: #fff;
|
|
1542
|
+
font: inherit;
|
|
1543
|
+
font-size: 0.95rem;
|
|
1544
|
+
font-weight: 600;
|
|
1545
|
+
cursor: pointer;
|
|
1546
|
+
transition: background 120ms ease, border-color 120ms ease;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
.reader-password-gate__button:hover {
|
|
1550
|
+
background: color-mix(in srgb, var(--accent) 85%, #000);
|
|
1551
|
+
border-color: color-mix(in srgb, var(--accent) 85%, #000);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
.reader-password-gate__error {
|
|
1555
|
+
margin: 0.75rem 0 0;
|
|
1556
|
+
color: #c0392b;
|
|
1557
|
+
font-size: 0.875rem;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
html[data-theme="dark"] .reader-password-gate__error {
|
|
1561
|
+
color: #e74c3c;
|
|
1562
|
+
}
|