narrarium-astro-reader 0.1.33 → 0.1.37
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 +14 -1
- package/cli-dist/lib/content-crypto.d.ts.map +1 -1
- package/cli-dist/lib/content-crypto.js +27 -5
- package/cli-dist/lib/content-crypto.js.map +1 -1
- package/cli-dist/lib/evaluations.d.ts +17 -0
- package/cli-dist/lib/evaluations.d.ts.map +1 -0
- package/cli-dist/lib/evaluations.js +45 -0
- package/cli-dist/lib/evaluations.js.map +1 -0
- package/cli-dist/pages/epub.enc.d.ts +17 -0
- package/cli-dist/pages/epub.enc.d.ts.map +1 -0
- package/cli-dist/pages/epub.enc.js +50 -0
- package/cli-dist/pages/epub.enc.js.map +1 -0
- package/package.json +1 -1
- package/src/components/EvaluationModal.astro +28 -0
- package/src/layouts/BaseLayout.astro +80 -1
- package/src/lib/content-crypto.ts +28 -5
- package/src/lib/evaluations.ts +66 -0
- package/src/pages/chapters/[chapter].astro +69 -6
- package/src/pages/epub.enc.ts +54 -0
- package/src/styles/global.css +74 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Return the singleton salt for this build process, creating it on first call. */
|
|
1
|
+
/** Return the singleton salt for this build/dev process, creating it on first call. */
|
|
2
2
|
export declare function getBuildSalt(): Buffer;
|
|
3
3
|
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
4
4
|
export declare function getBuildSaltBase64(): string;
|
|
@@ -30,4 +30,17 @@ export declare function encryptString(plaintext: string, password: string): Encr
|
|
|
30
30
|
* the result equals `CANARY_PLAINTEXT` — no fast-hash oracle in the HTML.
|
|
31
31
|
*/
|
|
32
32
|
export declare function encryptCanary(password: string): EncryptedChunk;
|
|
33
|
+
/**
|
|
34
|
+
* Encrypt a raw Buffer with AES-256-GCM using the same PBKDF2-derived build
|
|
35
|
+
* key. Returns raw `iv` and `ct` Buffers for binary file endpoints.
|
|
36
|
+
*
|
|
37
|
+
* Wire format (concatenate before serving):
|
|
38
|
+
* [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
|
|
39
|
+
*
|
|
40
|
+
* Client side: `bytes.slice(0, 12)` = IV, `bytes.slice(12)` = ciphertext+tag.
|
|
41
|
+
*/
|
|
42
|
+
export declare function encryptBufferRaw(data: Buffer, password: string): {
|
|
43
|
+
iv: Buffer;
|
|
44
|
+
ct: Buffer;
|
|
45
|
+
};
|
|
33
46
|
//# sourceMappingURL=content-crypto.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAiBA,uFAAuF;AACvF,wBAAgB,YAAY,IAAI,MAAM,CAOrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C,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;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,CAE9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAQ3F"}
|
|
@@ -8,14 +8,18 @@ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
|
8
8
|
* with the same parameters. A public salt is fine — it only prevents rainbow
|
|
9
9
|
* tables; the security comes from the password entropy.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Use a process-global symbol so the singleton survives Vite module re-evaluation
|
|
12
|
+
// in dev mode (HMR). Without this, each Astro page request may get a fresh module
|
|
13
|
+
// instance with a new random salt, making the canary and content salts diverge.
|
|
14
|
+
const SALT_GLOBAL_KEY = Symbol.for("narrarium.buildSalt");
|
|
15
|
+
/** Return the singleton salt for this build/dev process, creating it on first call. */
|
|
13
16
|
export function getBuildSalt() {
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
const g = globalThis;
|
|
18
|
+
if (!g[SALT_GLOBAL_KEY]) {
|
|
19
|
+
g[SALT_GLOBAL_KEY] = randomBytes(16);
|
|
16
20
|
console.info("[narrarium-reader] Content encryption enabled (AES-256-GCM).");
|
|
17
21
|
}
|
|
18
|
-
return
|
|
22
|
+
return g[SALT_GLOBAL_KEY];
|
|
19
23
|
}
|
|
20
24
|
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
21
25
|
export function getBuildSaltBase64() {
|
|
@@ -59,4 +63,22 @@ export function encryptString(plaintext, password) {
|
|
|
59
63
|
export function encryptCanary(password) {
|
|
60
64
|
return encryptString(CANARY_PLAINTEXT, password);
|
|
61
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Encrypt a raw Buffer with AES-256-GCM using the same PBKDF2-derived build
|
|
68
|
+
* key. Returns raw `iv` and `ct` Buffers for binary file endpoints.
|
|
69
|
+
*
|
|
70
|
+
* Wire format (concatenate before serving):
|
|
71
|
+
* [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
|
|
72
|
+
*
|
|
73
|
+
* Client side: `bytes.slice(0, 12)` = IV, `bytes.slice(12)` = ciphertext+tag.
|
|
74
|
+
*/
|
|
75
|
+
export function encryptBufferRaw(data, password) {
|
|
76
|
+
const salt = getBuildSalt();
|
|
77
|
+
const key = deriveKey(password, salt);
|
|
78
|
+
const iv = randomBytes(12);
|
|
79
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
80
|
+
const body = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
81
|
+
const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
|
|
82
|
+
return { iv, ct: Buffer.concat([body, tag]) };
|
|
83
|
+
}
|
|
62
84
|
//# sourceMappingURL=content-crypto.js.map
|
|
@@ -1 +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,
|
|
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,kFAAkF;AAClF,kFAAkF;AAClF,gFAAgF;AAChF,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;AAE1D,uFAAuF;AACvF,MAAM,UAAU,YAAY;IAC1B,MAAM,CAAC,GAAG,UAAuE,CAAC;IAClF,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,EAAE,CAAC;QACxB,CAAC,CAAC,eAAe,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QACrC,OAAO,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,CAAC,CAAC,eAAe,CAAE,CAAC;AAC7B,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;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,cAAc,CAAC;AAS/C;;;;;;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;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,OAAO,aAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,QAAgB;IAC7D,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,IAAI,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAClE,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC;AAChD,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface EvaluationData {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
htmlContent: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Load the chapter-level evaluation from
|
|
8
|
+
* `evaluations/chapters/<chapterSlug>.md`, or return null if the file does
|
|
9
|
+
* not exist (evaluation has not been run yet).
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadChapterEvaluation(chapterSlug: string): Promise<EvaluationData | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Load the paragraph-level evaluation from
|
|
14
|
+
* `evaluations/paragraphs/<chapterSlug>/<paragraphSlug>.md`, or return null.
|
|
15
|
+
*/
|
|
16
|
+
export declare function loadParagraphEvaluation(chapterSlug: string, paragraphSlug: string): Promise<EvaluationData | null>;
|
|
17
|
+
//# sourceMappingURL=evaluations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"evaluations.d.ts","sourceRoot":"","sources":["../../src/lib/evaluations.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAe/F;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAwBhC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { parseNarrariumMarkdownDocument } from "narrarium";
|
|
5
|
+
import { getBookRoot } from "./book.js";
|
|
6
|
+
/**
|
|
7
|
+
* Load the chapter-level evaluation from
|
|
8
|
+
* `evaluations/chapters/<chapterSlug>.md`, or return null if the file does
|
|
9
|
+
* not exist (evaluation has not been run yet).
|
|
10
|
+
*/
|
|
11
|
+
export async function loadChapterEvaluation(chapterSlug) {
|
|
12
|
+
const root = getBookRoot();
|
|
13
|
+
const filePath = path.join(root, "evaluations", "chapters", `${chapterSlug}.md`);
|
|
14
|
+
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
15
|
+
if (!raw)
|
|
16
|
+
return null;
|
|
17
|
+
const doc = parseNarrariumMarkdownDocument(`evaluations/chapters/${chapterSlug}.md`, raw);
|
|
18
|
+
const fm = doc.frontmatter;
|
|
19
|
+
const htmlContent = await marked.parse(doc.body ?? "");
|
|
20
|
+
return {
|
|
21
|
+
id: String(fm.id ?? `evaluation:chapter:${chapterSlug}`),
|
|
22
|
+
title: String(fm.title ?? "Chapter Evaluation"),
|
|
23
|
+
htmlContent,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Load the paragraph-level evaluation from
|
|
28
|
+
* `evaluations/paragraphs/<chapterSlug>/<paragraphSlug>.md`, or return null.
|
|
29
|
+
*/
|
|
30
|
+
export async function loadParagraphEvaluation(chapterSlug, paragraphSlug) {
|
|
31
|
+
const root = getBookRoot();
|
|
32
|
+
const filePath = path.join(root, "evaluations", "paragraphs", chapterSlug, `${paragraphSlug}.md`);
|
|
33
|
+
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
34
|
+
if (!raw)
|
|
35
|
+
return null;
|
|
36
|
+
const doc = parseNarrariumMarkdownDocument(`evaluations/paragraphs/${chapterSlug}/${paragraphSlug}.md`, raw);
|
|
37
|
+
const fm = doc.frontmatter;
|
|
38
|
+
const htmlContent = await marked.parse(doc.body ?? "");
|
|
39
|
+
return {
|
|
40
|
+
id: String(fm.id ?? `evaluation:paragraph:${chapterSlug}:${paragraphSlug}`),
|
|
41
|
+
title: String(fm.title ?? "Scene Evaluation"),
|
|
42
|
+
htmlContent,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=evaluations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"evaluations.js","sourceRoot":"","sources":["../../src/lib/evaluations.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,8BAA8B,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAQxC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,WAAmB;IAC7D,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,WAAW,KAAK,CAAC,CAAC;IACjF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC/D,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,GAAG,GAAG,8BAA8B,CAAC,wBAAwB,WAAW,KAAK,EAAE,GAAG,CAAC,CAAC;IAC1F,MAAM,EAAE,GAAG,GAAG,CAAC,WAAsC,CAAC;IACtD,MAAM,WAAW,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAEvD,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,sBAAsB,WAAW,EAAE,CAAC;QACxD,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC,KAAK,IAAI,oBAAoB,CAAC;QAC/C,WAAW;KACZ,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,WAAmB,EACnB,aAAqB;IAErB,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CACxB,IAAI,EACJ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,GAAG,aAAa,KAAK,CACtB,CAAC;IACF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC/D,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,GAAG,GAAG,8BAA8B,CACxC,0BAA0B,WAAW,IAAI,aAAa,KAAK,EAC3D,GAAG,CACJ,CAAC;IACF,MAAM,EAAE,GAAG,GAAG,CAAC,WAAsC,CAAC;IACtD,MAAM,WAAW,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAEvD,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,wBAAwB,WAAW,IAAI,aAAa,EAAE,CAAC;QAC3E,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC,KAAK,IAAI,kBAAkB,CAAC;QAC7C,WAAW;KACZ,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
/**
|
|
3
|
+
* Build-time endpoint that generates the book EPUB and — when a reader
|
|
4
|
+
* password is configured — encrypts it with the same AES-256-GCM key used
|
|
5
|
+
* for page content.
|
|
6
|
+
*
|
|
7
|
+
* Wire format (encrypted):
|
|
8
|
+
* [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
|
|
9
|
+
*
|
|
10
|
+
* The client downloads this blob, slices the IV from the first 12 bytes, and
|
|
11
|
+
* decrypts the rest with the already-derived AES key stored in localStorage.
|
|
12
|
+
*
|
|
13
|
+
* Wire format (no password):
|
|
14
|
+
* Raw EPUB bytes (application/epub+zip).
|
|
15
|
+
*/
|
|
16
|
+
export declare const GET: APIRoute;
|
|
17
|
+
//# sourceMappingURL=epub.enc.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"epub.enc.d.ts","sourceRoot":"","sources":["../../src/pages/epub.enc.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAUtC;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,GAAG,EAAE,QA6BjB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { exportEpub, pathExists } from "narrarium";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { getBookRoot } from "../lib/book.js";
|
|
7
|
+
import { getReaderPassword } from "../lib/reader-mode.js";
|
|
8
|
+
import { encryptBufferRaw } from "../lib/content-crypto.js";
|
|
9
|
+
/**
|
|
10
|
+
* Build-time endpoint that generates the book EPUB and — when a reader
|
|
11
|
+
* password is configured — encrypts it with the same AES-256-GCM key used
|
|
12
|
+
* for page content.
|
|
13
|
+
*
|
|
14
|
+
* Wire format (encrypted):
|
|
15
|
+
* [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
|
|
16
|
+
*
|
|
17
|
+
* The client downloads this blob, slices the IV from the first 12 bytes, and
|
|
18
|
+
* decrypts the rest with the already-derived AES key stored in localStorage.
|
|
19
|
+
*
|
|
20
|
+
* Wire format (no password):
|
|
21
|
+
* Raw EPUB bytes (application/epub+zip).
|
|
22
|
+
*/
|
|
23
|
+
export const GET = async () => {
|
|
24
|
+
const root = getBookRoot();
|
|
25
|
+
const ready = await pathExists(path.join(root, "book.md"));
|
|
26
|
+
if (!ready) {
|
|
27
|
+
return new Response("Book not found", { status: 404 });
|
|
28
|
+
}
|
|
29
|
+
const password = getReaderPassword();
|
|
30
|
+
const tempId = randomBytes(8).toString("hex");
|
|
31
|
+
const tempPath = path.join(tmpdir(), `narrarium-epub-${tempId}.epub`);
|
|
32
|
+
try {
|
|
33
|
+
await exportEpub(root, { outputPath: tempPath });
|
|
34
|
+
const epubBytes = await readFile(tempPath);
|
|
35
|
+
if (password) {
|
|
36
|
+
const { iv, ct } = encryptBufferRaw(epubBytes, password);
|
|
37
|
+
const combined = Buffer.concat([iv, ct]);
|
|
38
|
+
return new Response(combined, {
|
|
39
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return new Response(epubBytes, {
|
|
43
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await rm(tempPath, { force: true }).catch(() => { });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=epub.enc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"epub.enc.js","sourceRoot":"","sources":["../../src/pages/epub.enc.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,GAAG,GAAa,KAAK,IAAI,EAAE;IACtC,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC;IAC3D,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,IAAI,QAAQ,CAAC,gBAAgB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,MAAM,QAAQ,GAAG,iBAAiB,EAAE,CAAC;IACrC,MAAM,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,kBAAkB,MAAM,OAAO,CAAC,CAAC;IAEtE,IAAI,CAAC;QACH,MAAM,UAAU,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAAC;QACjD,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAE3C,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,gBAAgB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;YACzD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;YACzC,OAAO,IAAI,QAAQ,CAAC,QAAQ,EAAE;gBAC5B,OAAO,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE;aACxD,CAAC,CAAC;QACL,CAAC;QAED,OAAO,IAAI,QAAQ,CAAC,SAAS,EAAE;YAC7B,OAAO,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE;SACxD,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACtD,CAAC;AACH,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
dialogId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
htmlContent: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { dialogId, title, htmlContent } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<dialog class="eval-dialog" id={dialogId} aria-label={title}>
|
|
12
|
+
<div class="eval-dialog__inner">
|
|
13
|
+
<div class="eval-dialog__header">
|
|
14
|
+
<h2 class="eval-dialog__title">{title}</h2>
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
class="eval-dialog__close"
|
|
18
|
+
aria-label="Close evaluation"
|
|
19
|
+
data-eval-close={dialogId}
|
|
20
|
+
>
|
|
21
|
+
✕
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="eval-dialog__body prose">
|
|
25
|
+
<Fragment set:html={htmlContent} />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</dialog>
|
|
@@ -185,7 +185,22 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
185
185
|
// before we touch any page content.
|
|
186
186
|
return aesDecrypt(key, canaryIv, canaryCt).then(function (plaintext) {
|
|
187
187
|
if (plaintext !== "narrarium-ok") throw new Error("stale");
|
|
188
|
-
|
|
188
|
+
// Expose the key so the EPUB download handler can reuse it.
|
|
189
|
+
window._narrariumAesKey = key;
|
|
190
|
+
// Guard against a race where the auto-decrypt micro-task resolves
|
|
191
|
+
// before the HTML parser has emitted <main> and its encrypted spans.
|
|
192
|
+
// If the DOM is still being parsed, defer until DOMContentLoaded.
|
|
193
|
+
function waitForDom() {
|
|
194
|
+
if (document.readyState === "loading") {
|
|
195
|
+
return new Promise(function (resolve) {
|
|
196
|
+
document.addEventListener("DOMContentLoaded", resolve, { once: true });
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return Promise.resolve();
|
|
200
|
+
}
|
|
201
|
+
return waitForDom().then(function () {
|
|
202
|
+
return window._narrariumDecryptPage(key, true);
|
|
203
|
+
});
|
|
189
204
|
});
|
|
190
205
|
})
|
|
191
206
|
.then(function () {
|
|
@@ -246,6 +261,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
246
261
|
<div class="masthead-actions">
|
|
247
262
|
<a class="chip reader-status" data-continue-link href="./" hidden>Continue reading</a>
|
|
248
263
|
<button type="button" class="masthead-btn" data-tts-panel-toggle aria-label="Open read aloud controls" aria-pressed="false" hidden>🔊</button>
|
|
264
|
+
<button type="button" class="masthead-btn" data-epub-download aria-label="Download EPUB" hidden>⬇</button>
|
|
249
265
|
<button type="button" class="masthead-btn" data-search-toggle aria-label="Search">🔍</button>
|
|
250
266
|
<button type="button" class="masthead-btn" data-reader-settings-toggle aria-label="Open reading settings">⚙</button>
|
|
251
267
|
</div>
|
|
@@ -314,6 +330,9 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
314
330
|
try { localStorage.setItem("narrarium-reader-aes-key", b64); } catch (_) {}
|
|
315
331
|
});
|
|
316
332
|
|
|
333
|
+
// Expose the key so the EPUB download handler can reuse it.
|
|
334
|
+
window._narrariumAesKey = aesKey;
|
|
335
|
+
|
|
317
336
|
// Decrypt the page — passes false = manual unlock, so the
|
|
318
337
|
// content-unlocked event will fire for late-init scripts.
|
|
319
338
|
return window._narrariumDecryptPage(aesKey, false);
|
|
@@ -327,5 +346,65 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
|
|
|
327
346
|
})();
|
|
328
347
|
</script>
|
|
329
348
|
)}
|
|
349
|
+
<script is:inline>
|
|
350
|
+
/* ── EPUB download ──────────────────────────────────────────────────────
|
|
351
|
+
The epub.enc static endpoint is always generated at build time.
|
|
352
|
+
• Encrypted build: client fetches, decrypts with the stored AES key,
|
|
353
|
+
creates an in-memory Blob and triggers a browser <a download>.
|
|
354
|
+
• Plain build: client fetches, wraps in a Blob, triggers download.
|
|
355
|
+
The button is hidden until _narrariumContentReady resolves.
|
|
356
|
+
──────────────────────────────────────────────────────────────────────── */
|
|
357
|
+
(function () {
|
|
358
|
+
var btn = document.querySelector("[data-epub-download]");
|
|
359
|
+
if (!btn) return;
|
|
360
|
+
|
|
361
|
+
window._narrariumContentReady.then(function (state) {
|
|
362
|
+
if (state === "decrypted" || state === "plain") {
|
|
363
|
+
btn.removeAttribute("hidden");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
btn.addEventListener("click", function () {
|
|
368
|
+
var isEncrypted = Boolean(document.body.dataset.cryptoSalt);
|
|
369
|
+
var epubUrl = new URL("epub.enc", document.baseURI).toString();
|
|
370
|
+
|
|
371
|
+
btn.setAttribute("disabled", "");
|
|
372
|
+
|
|
373
|
+
fetch(epubUrl)
|
|
374
|
+
.then(function (r) {
|
|
375
|
+
if (!r.ok) throw new Error("fetch failed: " + r.status);
|
|
376
|
+
return r.arrayBuffer();
|
|
377
|
+
})
|
|
378
|
+
.then(function (buf) {
|
|
379
|
+
if (isEncrypted) {
|
|
380
|
+
var aesKey = window._narrariumAesKey;
|
|
381
|
+
if (!aesKey) throw new Error("no key");
|
|
382
|
+
var bytes = new Uint8Array(buf);
|
|
383
|
+
var iv = bytes.slice(0, 12);
|
|
384
|
+
var ct = bytes.slice(12);
|
|
385
|
+
return crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, aesKey, ct);
|
|
386
|
+
}
|
|
387
|
+
return buf;
|
|
388
|
+
})
|
|
389
|
+
.then(function (plainBuf) {
|
|
390
|
+
var blob = new Blob([plainBuf], { type: "application/epub+zip" });
|
|
391
|
+
var url = URL.createObjectURL(blob);
|
|
392
|
+
var a = document.createElement("a");
|
|
393
|
+
a.href = url;
|
|
394
|
+
a.download = "book.epub";
|
|
395
|
+
document.body.appendChild(a);
|
|
396
|
+
a.click();
|
|
397
|
+
document.body.removeChild(a);
|
|
398
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
|
|
399
|
+
})
|
|
400
|
+
.catch(function (err) {
|
|
401
|
+
console.error("[narrarium] EPUB download failed", err);
|
|
402
|
+
})
|
|
403
|
+
.finally(function () {
|
|
404
|
+
btn.removeAttribute("disabled");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
})();
|
|
408
|
+
</script>
|
|
330
409
|
</body>
|
|
331
410
|
</html>
|
|
@@ -10,15 +10,19 @@ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
|
10
10
|
* tables; the security comes from the password entropy.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Use a process-global symbol so the singleton survives Vite module re-evaluation
|
|
14
|
+
// in dev mode (HMR). Without this, each Astro page request may get a fresh module
|
|
15
|
+
// instance with a new random salt, making the canary and content salts diverge.
|
|
16
|
+
const SALT_GLOBAL_KEY = Symbol.for("narrarium.buildSalt");
|
|
14
17
|
|
|
15
|
-
/** Return the singleton salt for this build process, creating it on first call. */
|
|
18
|
+
/** Return the singleton salt for this build/dev process, creating it on first call. */
|
|
16
19
|
export function getBuildSalt(): Buffer {
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const g = globalThis as typeof globalThis & { [key: symbol]: Buffer | undefined };
|
|
21
|
+
if (!g[SALT_GLOBAL_KEY]) {
|
|
22
|
+
g[SALT_GLOBAL_KEY] = randomBytes(16);
|
|
19
23
|
console.info("[narrarium-reader] Content encryption enabled (AES-256-GCM).");
|
|
20
24
|
}
|
|
21
|
-
return
|
|
25
|
+
return g[SALT_GLOBAL_KEY]!;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/** Base64-encoded build salt, ready to embed in an HTML attribute. */
|
|
@@ -74,3 +78,22 @@ export function encryptString(plaintext: string, password: string): EncryptedChu
|
|
|
74
78
|
export function encryptCanary(password: string): EncryptedChunk {
|
|
75
79
|
return encryptString(CANARY_PLAINTEXT, password);
|
|
76
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Encrypt a raw Buffer with AES-256-GCM using the same PBKDF2-derived build
|
|
84
|
+
* key. Returns raw `iv` and `ct` Buffers for binary file endpoints.
|
|
85
|
+
*
|
|
86
|
+
* Wire format (concatenate before serving):
|
|
87
|
+
* [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
|
|
88
|
+
*
|
|
89
|
+
* Client side: `bytes.slice(0, 12)` = IV, `bytes.slice(12)` = ciphertext+tag.
|
|
90
|
+
*/
|
|
91
|
+
export function encryptBufferRaw(data: Buffer, password: string): { iv: Buffer; ct: Buffer } {
|
|
92
|
+
const salt = getBuildSalt();
|
|
93
|
+
const key = deriveKey(password, salt);
|
|
94
|
+
const iv = randomBytes(12);
|
|
95
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
96
|
+
const body = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
97
|
+
const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
|
|
98
|
+
return { iv, ct: Buffer.concat([body, tag]) };
|
|
99
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { parseNarrariumMarkdownDocument } from "narrarium";
|
|
5
|
+
import { getBookRoot } from "./book.js";
|
|
6
|
+
|
|
7
|
+
export interface EvaluationData {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
htmlContent: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load the chapter-level evaluation from
|
|
15
|
+
* `evaluations/chapters/<chapterSlug>.md`, or return null if the file does
|
|
16
|
+
* not exist (evaluation has not been run yet).
|
|
17
|
+
*/
|
|
18
|
+
export async function loadChapterEvaluation(chapterSlug: string): Promise<EvaluationData | null> {
|
|
19
|
+
const root = getBookRoot();
|
|
20
|
+
const filePath = path.join(root, "evaluations", "chapters", `${chapterSlug}.md`);
|
|
21
|
+
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
22
|
+
if (!raw) return null;
|
|
23
|
+
|
|
24
|
+
const doc = parseNarrariumMarkdownDocument(`evaluations/chapters/${chapterSlug}.md`, raw);
|
|
25
|
+
const fm = doc.frontmatter as Record<string, unknown>;
|
|
26
|
+
const htmlContent = await marked.parse(doc.body ?? "");
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
id: String(fm.id ?? `evaluation:chapter:${chapterSlug}`),
|
|
30
|
+
title: String(fm.title ?? "Chapter Evaluation"),
|
|
31
|
+
htmlContent,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load the paragraph-level evaluation from
|
|
37
|
+
* `evaluations/paragraphs/<chapterSlug>/<paragraphSlug>.md`, or return null.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadParagraphEvaluation(
|
|
40
|
+
chapterSlug: string,
|
|
41
|
+
paragraphSlug: string,
|
|
42
|
+
): Promise<EvaluationData | null> {
|
|
43
|
+
const root = getBookRoot();
|
|
44
|
+
const filePath = path.join(
|
|
45
|
+
root,
|
|
46
|
+
"evaluations",
|
|
47
|
+
"paragraphs",
|
|
48
|
+
chapterSlug,
|
|
49
|
+
`${paragraphSlug}.md`,
|
|
50
|
+
);
|
|
51
|
+
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
52
|
+
if (!raw) return null;
|
|
53
|
+
|
|
54
|
+
const doc = parseNarrariumMarkdownDocument(
|
|
55
|
+
`evaluations/paragraphs/${chapterSlug}/${paragraphSlug}.md`,
|
|
56
|
+
raw,
|
|
57
|
+
);
|
|
58
|
+
const fm = doc.frontmatter as Record<string, unknown>;
|
|
59
|
+
const htmlContent = await marked.parse(doc.body ?? "");
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
id: String(fm.id ?? `evaluation:paragraph:${chapterSlug}:${paragraphSlug}`),
|
|
63
|
+
title: String(fm.title ?? "Scene Evaluation"),
|
|
64
|
+
htmlContent,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -5,6 +5,7 @@ import { listChapters, pathExists } from "narrarium";
|
|
|
5
5
|
import AssetFigure from "../../components/AssetFigure.astro";
|
|
6
6
|
import ChapterPager from "../../components/ChapterPager.astro";
|
|
7
7
|
import EncryptedHtml from "../../components/EncryptedHtml.astro";
|
|
8
|
+
import EvaluationModal from "../../components/EvaluationModal.astro";
|
|
8
9
|
import LinkedValue from "../../components/LinkedValue.astro";
|
|
9
10
|
import MetadataSection from "../../components/MetadataSection.astro";
|
|
10
11
|
import RelatedLinks from "../../components/RelatedLinks.astro";
|
|
@@ -12,6 +13,8 @@ import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
|
12
13
|
import { loadAssetFigure } from "../../lib/assets";
|
|
13
14
|
import { loadRelatedCanonLinks, loadStoryMentionLinks } from "../../lib/canon";
|
|
14
15
|
import { getBookRoot, loadChapterPageData } from "../../lib/book";
|
|
16
|
+
import { loadChapterEvaluation, loadParagraphEvaluation } from "../../lib/evaluations";
|
|
17
|
+
import { isFullCanonMode } from "../../lib/reader-mode";
|
|
15
18
|
|
|
16
19
|
export async function getStaticPaths() {
|
|
17
20
|
const root = getBookRoot();
|
|
@@ -41,13 +44,23 @@ const chapterId = String(chapter.metadata.id);
|
|
|
41
44
|
const chapterRelatedLinks = await loadRelatedCanonLinks(chapterId, [chapter.metadata.pov, chapter.metadata.timeline_ref]);
|
|
42
45
|
const chapterStoryLinks = await loadStoryMentionLinks(chapterId, chapter.metadata.number);
|
|
43
46
|
const chapterFigure = await loadAssetFigure(String(chapter.metadata.id), chapter.metadata.title);
|
|
47
|
+
|
|
48
|
+
// Full-canon mode: load evaluations for author view.
|
|
49
|
+
const fullCanonMode = isFullCanonMode();
|
|
50
|
+
const chapterEvaluation = fullCanonMode ? await loadChapterEvaluation(chapterSlug) : null;
|
|
51
|
+
|
|
44
52
|
const renderedParagraphs = await Promise.all(
|
|
45
|
-
chapter.paragraphs.map(async (paragraph) =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
chapter.paragraphs.map(async (paragraph) => {
|
|
54
|
+
const paragraphSlug = path.basename(paragraph.path, ".md");
|
|
55
|
+
return {
|
|
56
|
+
...paragraph,
|
|
57
|
+
paragraphSlug,
|
|
58
|
+
anchorId: `scene-${paragraphSlug}`,
|
|
59
|
+
figure: await loadAssetFigure(String(paragraph.metadata.id), paragraph.metadata.title),
|
|
60
|
+
html: await marked.parse(paragraph.body),
|
|
61
|
+
evaluation: fullCanonMode ? await loadParagraphEvaluation(chapterSlug, paragraphSlug) : null,
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
51
64
|
);
|
|
52
65
|
---
|
|
53
66
|
|
|
@@ -66,6 +79,11 @@ const renderedParagraphs = await Promise.all(
|
|
|
66
79
|
<button type="button" class="chip is-action" data-bookmark-toggle>Save bookmark</button>
|
|
67
80
|
<button type="button" class="chip is-action" data-tts-trigger="chapter" hidden>Read aloud</button>
|
|
68
81
|
<button type="button" class="chip is-action" data-reader-focus-toggle>Full read</button>
|
|
82
|
+
{chapterEvaluation && (
|
|
83
|
+
<button type="button" class="chip is-action eval-trigger" data-eval-open={`eval-chapter-${chapterSlug}`}>
|
|
84
|
+
Evaluation
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
69
87
|
</div>
|
|
70
88
|
</section>
|
|
71
89
|
|
|
@@ -148,6 +166,11 @@ const renderedParagraphs = await Promise.all(
|
|
|
148
166
|
Mark voice start
|
|
149
167
|
</button>
|
|
150
168
|
<button type="button" class="chip is-action" data-tts-trigger={paragraph.anchorId} hidden>Read aloud</button>
|
|
169
|
+
{paragraph.evaluation && (
|
|
170
|
+
<button type="button" class="chip is-action eval-trigger" data-eval-open={`eval-paragraph-${paragraph.paragraphSlug}`}>
|
|
171
|
+
Evaluation
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
151
174
|
</div>
|
|
152
175
|
</div>
|
|
153
176
|
|
|
@@ -221,6 +244,24 @@ const renderedParagraphs = await Promise.all(
|
|
|
221
244
|
Double-tap or double-click to show controls, or press a key.
|
|
222
245
|
</div>
|
|
223
246
|
|
|
247
|
+
{/* Evaluation modals — full-canon mode only */}
|
|
248
|
+
{chapterEvaluation && (
|
|
249
|
+
<EvaluationModal
|
|
250
|
+
dialogId={`eval-chapter-${chapterSlug}`}
|
|
251
|
+
title={chapterEvaluation.title}
|
|
252
|
+
htmlContent={chapterEvaluation.htmlContent}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
{renderedParagraphs.map((paragraph) =>
|
|
256
|
+
paragraph.evaluation ? (
|
|
257
|
+
<EvaluationModal
|
|
258
|
+
dialogId={`eval-paragraph-${paragraph.paragraphSlug}`}
|
|
259
|
+
title={paragraph.evaluation.title}
|
|
260
|
+
htmlContent={paragraph.evaluation.htmlContent}
|
|
261
|
+
/>
|
|
262
|
+
) : null
|
|
263
|
+
)}
|
|
264
|
+
|
|
224
265
|
<script is:inline>
|
|
225
266
|
(() => {
|
|
226
267
|
const focusModeKey = "narrarium-reader-focus-mode";
|
|
@@ -548,6 +589,28 @@ const renderedParagraphs = await Promise.all(
|
|
|
548
589
|
function isEditable(node) {
|
|
549
590
|
return node instanceof HTMLElement && (node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName));
|
|
550
591
|
}
|
|
592
|
+
|
|
593
|
+
// ── Evaluation modal wiring ─────────────────────────────────────────
|
|
594
|
+
document.querySelectorAll("[data-eval-open]").forEach(function (btn) {
|
|
595
|
+
btn.addEventListener("click", function () {
|
|
596
|
+
var dialogId = btn.getAttribute("data-eval-open");
|
|
597
|
+
var dialog = dialogId ? document.getElementById(dialogId) : null;
|
|
598
|
+
if (dialog instanceof HTMLDialogElement) dialog.showModal();
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
document.querySelectorAll("[data-eval-close]").forEach(function (btn) {
|
|
602
|
+
btn.addEventListener("click", function () {
|
|
603
|
+
var dialogId = btn.getAttribute("data-eval-close");
|
|
604
|
+
var dialog = dialogId ? document.getElementById(dialogId) : null;
|
|
605
|
+
if (dialog instanceof HTMLDialogElement) dialog.close();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
// Close on backdrop click (click lands directly on <dialog>, not its children).
|
|
609
|
+
document.querySelectorAll(".eval-dialog").forEach(function (dialog) {
|
|
610
|
+
dialog.addEventListener("click", function (event) {
|
|
611
|
+
if (event.target === dialog) /** @type {HTMLDialogElement} */ (dialog).close();
|
|
612
|
+
});
|
|
613
|
+
});
|
|
551
614
|
})();
|
|
552
615
|
</script>
|
|
553
616
|
</BaseLayout>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { exportEpub, pathExists } from "narrarium";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { getBookRoot } from "../lib/book.js";
|
|
8
|
+
import { getReaderPassword } from "../lib/reader-mode.js";
|
|
9
|
+
import { encryptBufferRaw } from "../lib/content-crypto.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build-time endpoint that generates the book EPUB and — when a reader
|
|
13
|
+
* password is configured — encrypts it with the same AES-256-GCM key used
|
|
14
|
+
* for page content.
|
|
15
|
+
*
|
|
16
|
+
* Wire format (encrypted):
|
|
17
|
+
* [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
|
|
18
|
+
*
|
|
19
|
+
* The client downloads this blob, slices the IV from the first 12 bytes, and
|
|
20
|
+
* decrypts the rest with the already-derived AES key stored in localStorage.
|
|
21
|
+
*
|
|
22
|
+
* Wire format (no password):
|
|
23
|
+
* Raw EPUB bytes (application/epub+zip).
|
|
24
|
+
*/
|
|
25
|
+
export const GET: APIRoute = async () => {
|
|
26
|
+
const root = getBookRoot();
|
|
27
|
+
const ready = await pathExists(path.join(root, "book.md"));
|
|
28
|
+
if (!ready) {
|
|
29
|
+
return new Response("Book not found", { status: 404 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const password = getReaderPassword();
|
|
33
|
+
const tempId = randomBytes(8).toString("hex");
|
|
34
|
+
const tempPath = path.join(tmpdir(), `narrarium-epub-${tempId}.epub`);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await exportEpub(root, { outputPath: tempPath });
|
|
38
|
+
const epubBytes = await readFile(tempPath);
|
|
39
|
+
|
|
40
|
+
if (password) {
|
|
41
|
+
const { iv, ct } = encryptBufferRaw(epubBytes, password);
|
|
42
|
+
const combined = Buffer.concat([iv, ct]);
|
|
43
|
+
return new Response(combined, {
|
|
44
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return new Response(epubBytes, {
|
|
49
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
50
|
+
});
|
|
51
|
+
} finally {
|
|
52
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
53
|
+
}
|
|
54
|
+
};
|
package/src/styles/global.css
CHANGED
|
@@ -934,7 +934,7 @@ h3 {
|
|
|
934
934
|
display: grid;
|
|
935
935
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
|
936
936
|
gap: 1rem;
|
|
937
|
-
align-items:
|
|
937
|
+
align-items: stretch;
|
|
938
938
|
border: 1px solid var(--line);
|
|
939
939
|
background: var(--surface);
|
|
940
940
|
padding: 0.9rem 1.25rem;
|
|
@@ -943,7 +943,7 @@ h3 {
|
|
|
943
943
|
}
|
|
944
944
|
|
|
945
945
|
.pager-link {
|
|
946
|
-
display:
|
|
946
|
+
display: flex;
|
|
947
947
|
align-items: center;
|
|
948
948
|
gap: 0.4rem;
|
|
949
949
|
padding: 0.5rem 0.75rem;
|
|
@@ -974,6 +974,7 @@ h3 {
|
|
|
974
974
|
.pager-jump {
|
|
975
975
|
display: grid;
|
|
976
976
|
gap: 0.3rem;
|
|
977
|
+
align-content: center;
|
|
977
978
|
min-width: 0;
|
|
978
979
|
}
|
|
979
980
|
|
|
@@ -1588,3 +1589,74 @@ body.reader-auth-locked #reader-password-gate {
|
|
|
1588
1589
|
html[data-theme="dark"] .reader-password-gate__error {
|
|
1589
1590
|
color: #e74c3c;
|
|
1590
1591
|
}
|
|
1592
|
+
|
|
1593
|
+
/* ─── Evaluation modal ──────────────────────────────────────── */
|
|
1594
|
+
.eval-dialog {
|
|
1595
|
+
position: fixed;
|
|
1596
|
+
inset: 0;
|
|
1597
|
+
z-index: 300;
|
|
1598
|
+
width: min(720px, calc(100% - 2rem));
|
|
1599
|
+
max-height: calc(100dvh - 4rem);
|
|
1600
|
+
margin: auto;
|
|
1601
|
+
padding: 0;
|
|
1602
|
+
border: 1px solid var(--line);
|
|
1603
|
+
border-radius: 12px;
|
|
1604
|
+
background: var(--surface);
|
|
1605
|
+
color: var(--text);
|
|
1606
|
+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
|
|
1607
|
+
overflow: hidden;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
.eval-dialog::backdrop {
|
|
1611
|
+
background: rgba(0, 0, 0, 0.5);
|
|
1612
|
+
backdrop-filter: blur(3px);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
.eval-dialog__inner {
|
|
1616
|
+
display: flex;
|
|
1617
|
+
flex-direction: column;
|
|
1618
|
+
max-height: calc(100dvh - 4rem);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
.eval-dialog__header {
|
|
1622
|
+
display: flex;
|
|
1623
|
+
align-items: center;
|
|
1624
|
+
justify-content: space-between;
|
|
1625
|
+
gap: 1rem;
|
|
1626
|
+
padding: 1rem 1.5rem;
|
|
1627
|
+
border-bottom: 1px solid var(--line);
|
|
1628
|
+
flex-shrink: 0;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
.eval-dialog__title {
|
|
1632
|
+
margin: 0;
|
|
1633
|
+
font-size: 1rem;
|
|
1634
|
+
font-weight: 600;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
.eval-dialog__close {
|
|
1638
|
+
flex-shrink: 0;
|
|
1639
|
+
display: flex;
|
|
1640
|
+
align-items: center;
|
|
1641
|
+
justify-content: center;
|
|
1642
|
+
width: 2rem;
|
|
1643
|
+
height: 2rem;
|
|
1644
|
+
border-radius: 50%;
|
|
1645
|
+
border: 1px solid var(--line);
|
|
1646
|
+
background: transparent;
|
|
1647
|
+
color: var(--text-muted);
|
|
1648
|
+
cursor: pointer;
|
|
1649
|
+
font-size: 0.875rem;
|
|
1650
|
+
transition: background 120ms ease, color 120ms ease;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
.eval-dialog__close:hover {
|
|
1654
|
+
background: var(--surface-muted);
|
|
1655
|
+
color: var(--text);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
.eval-dialog__body {
|
|
1659
|
+
overflow-y: auto;
|
|
1660
|
+
padding: 1.5rem;
|
|
1661
|
+
flex: 1 1 auto;
|
|
1662
|
+
}
|