@moldesk/visualizer 0.0.0-stage → 0.0.5
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/dist/assets/MOLSTAR_LICENSE.txt +21 -0
- package/dist/assets/THIRD_PARTY_NOTICES.txt +5 -0
- package/dist/assets/bundle.json +18 -0
- package/dist/assets/viewer.css +114 -0
- package/dist/assets/viewer.js +7724 -0
- package/dist/bundle.d.ts +26 -0
- package/dist/bundle.js +48 -0
- package/dist/contract.d.ts +146 -0
- package/dist/contract.js +433 -0
- package/dist/contract.test.d.ts +1 -0
- package/dist/contract.test.js +78 -0
- package/dist/csv.d.ts +16 -0
- package/dist/csv.js +93 -0
- package/dist/csv.test.d.ts +1 -0
- package/dist/csv.test.js +30 -0
- package/dist/export.d.ts +23 -0
- package/dist/export.js +61 -0
- package/dist/fixtures.test.d.ts +1 -0
- package/dist/fixtures.test.js +61 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/limits.d.ts +24 -0
- package/dist/limits.js +28 -0
- package/dist/report.d.ts +53 -0
- package/dist/report.js +195 -0
- package/dist/update.d.ts +41 -0
- package/dist/update.js +147 -0
- package/dist/update.test.d.ts +1 -0
- package/dist/update.test.js +167 -0
- package/package.json +51 -3
- package/README.md +0 -3
package/dist/report.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Report envelope, trusted HTML template, and strict extraction/decoding.
|
|
2
|
+
// Nothing here executes or evaluates report content.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { REPORT_LIMITS } from "./limits.js";
|
|
5
|
+
import { sha256Hex, TEMPLATE_VERSION } from "./bundle.js";
|
|
6
|
+
import { ContractError, validateReport, } from "./contract.js";
|
|
7
|
+
export const ENVELOPE_MAGIC = "moldesk-molecular-report";
|
|
8
|
+
export const ENVELOPE_ELEMENT_ID = "moldesk-report";
|
|
9
|
+
const ENVELOPE_OPEN = `<script type="application/json" id="${ENVELOPE_ELEMENT_ID}">`;
|
|
10
|
+
const SCRIPT_CLOSE = "</script>";
|
|
11
|
+
export class ReportFormatError extends Error {
|
|
12
|
+
code = "INVALID_REPORT";
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ReportFormatError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Builds a validated report. Resource hashes are over the original bytes. */
|
|
19
|
+
export function createReport(input) {
|
|
20
|
+
const report = {
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
id: input.id ?? randomUUID(),
|
|
23
|
+
title: input.title,
|
|
24
|
+
resources: input.resources.map((r) => ({
|
|
25
|
+
id: r.id,
|
|
26
|
+
label: r.label,
|
|
27
|
+
format: r.format,
|
|
28
|
+
sha256: sha256Hex(r.bytes),
|
|
29
|
+
sizeBytes: r.bytes.byteLength,
|
|
30
|
+
encoding: "base64",
|
|
31
|
+
data: Buffer.from(r.bytes).toString("base64"),
|
|
32
|
+
})),
|
|
33
|
+
scenes: input.scenes,
|
|
34
|
+
panels: input.panels,
|
|
35
|
+
provenance: { createdAt: input.createdAt ?? new Date().toISOString(), ...input.provenance },
|
|
36
|
+
};
|
|
37
|
+
return validateReport(report);
|
|
38
|
+
}
|
|
39
|
+
export function buildEnvelope(report, bundle, lastUpdatedAt) {
|
|
40
|
+
const payload = Buffer.from(JSON.stringify(report), "utf8").toString("base64");
|
|
41
|
+
return {
|
|
42
|
+
magic: ENVELOPE_MAGIC,
|
|
43
|
+
envelopeVersion: 1,
|
|
44
|
+
payloadEncoding: "base64-json",
|
|
45
|
+
payload,
|
|
46
|
+
payloadSha256: sha256Hex(payload),
|
|
47
|
+
viewer: {
|
|
48
|
+
version: bundle.metadata.viewerVersion,
|
|
49
|
+
molstarVersion: bundle.metadata.molstarVersion,
|
|
50
|
+
bundleSha256: bundle.metadata.bundleSha256,
|
|
51
|
+
templateVersion: TEMPLATE_VERSION,
|
|
52
|
+
},
|
|
53
|
+
...(lastUpdatedAt === undefined ? {} : { lastUpdatedAt }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function escapeHtml(text) {
|
|
57
|
+
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
58
|
+
}
|
|
59
|
+
/** JSON that cannot terminate a script element or be misread as HTML. */
|
|
60
|
+
function inertJson(value) {
|
|
61
|
+
return JSON.stringify(value).replace(/[<>&]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
62
|
+
}
|
|
63
|
+
function cspHash(text) {
|
|
64
|
+
return `'sha256-${Buffer.from(sha256Hex(text), "hex").toString("base64")}'`;
|
|
65
|
+
}
|
|
66
|
+
/** Restrictive policy: no network of any kind; only the two hashed inline blocks may run/style. */
|
|
67
|
+
export function contentSecurityPolicy(js, css) {
|
|
68
|
+
return [
|
|
69
|
+
"default-src 'none'",
|
|
70
|
+
`script-src ${cspHash(js)}`,
|
|
71
|
+
`style-src-elem ${cspHash(css)}`,
|
|
72
|
+
"img-src data: blob:",
|
|
73
|
+
"worker-src blob:",
|
|
74
|
+
"connect-src 'none'",
|
|
75
|
+
"font-src 'none'",
|
|
76
|
+
"object-src 'none'",
|
|
77
|
+
"base-uri 'none'",
|
|
78
|
+
"form-action 'none'",
|
|
79
|
+
].join("; ");
|
|
80
|
+
}
|
|
81
|
+
/** Deterministic: same envelope + bundle always yields identical bytes. */
|
|
82
|
+
export function renderHtml(envelope, bundle, title) {
|
|
83
|
+
const csp = contentSecurityPolicy(bundle.js, bundle.css);
|
|
84
|
+
return [
|
|
85
|
+
"<!doctype html>",
|
|
86
|
+
'<html lang="en">',
|
|
87
|
+
"<head>",
|
|
88
|
+
'<meta charset="utf-8">',
|
|
89
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
90
|
+
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">`,
|
|
91
|
+
`<title>${escapeHtml(title)}</title>`,
|
|
92
|
+
`<style>${bundle.css}</style>`,
|
|
93
|
+
"</head>",
|
|
94
|
+
"<body>",
|
|
95
|
+
'<div id="root"></div>',
|
|
96
|
+
`${ENVELOPE_OPEN}${inertJson(envelope)}${SCRIPT_CLOSE}`,
|
|
97
|
+
`<script>${bundle.js}${SCRIPT_CLOSE}`,
|
|
98
|
+
"</body>",
|
|
99
|
+
"</html>",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
/** Narrow exact extractor: exactly one envelope element, no HTML parsing, no script execution. */
|
|
104
|
+
export function extractEnvelope(html) {
|
|
105
|
+
if (html.length > REPORT_LIMITS.maxReportFileBytes)
|
|
106
|
+
throw new ReportFormatError("Report file is too large");
|
|
107
|
+
const first = html.indexOf(ENVELOPE_OPEN);
|
|
108
|
+
if (first === -1)
|
|
109
|
+
throw new ReportFormatError("This file is not a MoleculeDesk molecular report");
|
|
110
|
+
if (html.indexOf(ENVELOPE_OPEN, first + 1) !== -1 || html.split(`id="${ENVELOPE_ELEMENT_ID}"`).length !== 2) {
|
|
111
|
+
throw new ReportFormatError("Report contains more than one report envelope");
|
|
112
|
+
}
|
|
113
|
+
const start = first + ENVELOPE_OPEN.length;
|
|
114
|
+
const end = html.indexOf(SCRIPT_CLOSE, start);
|
|
115
|
+
if (end === -1)
|
|
116
|
+
throw new ReportFormatError("Report envelope is not terminated");
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(html.slice(start, end));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new ReportFormatError("Report envelope is not valid JSON");
|
|
123
|
+
}
|
|
124
|
+
return validateEnvelope(parsed);
|
|
125
|
+
}
|
|
126
|
+
const ENVELOPE_KEYS = ["magic", "envelopeVersion", "payloadEncoding", "payload", "payloadSha256", "viewer", "lastUpdatedAt"];
|
|
127
|
+
const VIEWER_KEYS = ["version", "molstarVersion", "bundleSha256", "templateVersion"];
|
|
128
|
+
function validateEnvelope(value) {
|
|
129
|
+
const bad = (m) => {
|
|
130
|
+
throw new ReportFormatError(`Invalid report envelope: ${m}`);
|
|
131
|
+
};
|
|
132
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
133
|
+
bad("not an object");
|
|
134
|
+
const o = value;
|
|
135
|
+
if (Object.keys(o).some((k) => !ENVELOPE_KEYS.includes(k)))
|
|
136
|
+
bad("unknown field");
|
|
137
|
+
if (o.magic !== ENVELOPE_MAGIC)
|
|
138
|
+
bad("wrong magic");
|
|
139
|
+
if (o.envelopeVersion !== 1)
|
|
140
|
+
bad("unsupported envelope version");
|
|
141
|
+
if (o.payloadEncoding !== "base64-json")
|
|
142
|
+
bad("unsupported payload encoding");
|
|
143
|
+
if (typeof o.payload !== "string" || typeof o.payloadSha256 !== "string")
|
|
144
|
+
bad("missing payload");
|
|
145
|
+
const v = o.viewer;
|
|
146
|
+
if (typeof v !== "object" || v === null || Object.keys(v).some((k) => !VIEWER_KEYS.includes(k)))
|
|
147
|
+
bad("viewer metadata");
|
|
148
|
+
if (typeof v.version !== "string" ||
|
|
149
|
+
typeof v.molstarVersion !== "string" ||
|
|
150
|
+
typeof v.bundleSha256 !== "string" ||
|
|
151
|
+
typeof v.templateVersion !== "number") {
|
|
152
|
+
bad("viewer metadata");
|
|
153
|
+
}
|
|
154
|
+
if (o.lastUpdatedAt !== undefined && typeof o.lastUpdatedAt !== "string")
|
|
155
|
+
bad("lastUpdatedAt");
|
|
156
|
+
return o;
|
|
157
|
+
}
|
|
158
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
159
|
+
/** Verifies payload and every resource against its recorded digest, then validates the schema. */
|
|
160
|
+
export function decodeEnvelope(envelope) {
|
|
161
|
+
const maxPayload = Math.ceil((REPORT_LIMITS.maxTotalResourceBytes * 4) / 3) * 2;
|
|
162
|
+
if (envelope.payload.length > maxPayload)
|
|
163
|
+
throw new ReportFormatError("Report payload is too large");
|
|
164
|
+
if (!BASE64.test(envelope.payload) || envelope.payload.length % 4 !== 0) {
|
|
165
|
+
throw new ReportFormatError("Report payload is not valid base64");
|
|
166
|
+
}
|
|
167
|
+
if (sha256Hex(envelope.payload) !== envelope.payloadSha256) {
|
|
168
|
+
throw new ReportFormatError("Report payload does not match its recorded digest");
|
|
169
|
+
}
|
|
170
|
+
let json;
|
|
171
|
+
try {
|
|
172
|
+
json = JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8"));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
throw new ReportFormatError("Report payload is not valid JSON");
|
|
176
|
+
}
|
|
177
|
+
let report;
|
|
178
|
+
try {
|
|
179
|
+
report = validateReport(json);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error instanceof ContractError)
|
|
183
|
+
throw new ReportFormatError(error.message);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
for (const r of report.resources) {
|
|
187
|
+
if (!BASE64.test(r.data))
|
|
188
|
+
throw new ReportFormatError(`Resource "${r.id}" is not valid base64`);
|
|
189
|
+
const bytes = Buffer.from(r.data, "base64");
|
|
190
|
+
if (bytes.byteLength !== r.sizeBytes || sha256Hex(bytes) !== r.sha256) {
|
|
191
|
+
throw new ReportFormatError(`Resource "${r.id}" does not match its recorded digest`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return report;
|
|
195
|
+
}
|
package/dist/update.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ViewerBundle } from "./bundle.js";
|
|
2
|
+
export type UpdateStatus = "noop" | "replace" | "unsupported";
|
|
3
|
+
export interface UpdatePlan {
|
|
4
|
+
status: UpdateStatus;
|
|
5
|
+
/** Why an update is unsupported. */
|
|
6
|
+
reason?: string;
|
|
7
|
+
targetPath: string;
|
|
8
|
+
outputPath?: string;
|
|
9
|
+
originalSha256: string;
|
|
10
|
+
originalSizeBytes: number;
|
|
11
|
+
payloadSha256?: string;
|
|
12
|
+
fromViewerVersion?: string;
|
|
13
|
+
toViewerVersion: string;
|
|
14
|
+
toBundleSha256: string;
|
|
15
|
+
/** Internal: state needed to commit safely. */
|
|
16
|
+
original: Buffer;
|
|
17
|
+
identity: {
|
|
18
|
+
ino: number;
|
|
19
|
+
dev: number;
|
|
20
|
+
size: number;
|
|
21
|
+
mtimeMs: number;
|
|
22
|
+
mode: number;
|
|
23
|
+
};
|
|
24
|
+
newHtml?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface UpdateResult {
|
|
27
|
+
status: "updated" | "copied";
|
|
28
|
+
path: string;
|
|
29
|
+
backupPath?: string;
|
|
30
|
+
sha256: string;
|
|
31
|
+
sizeBytes: number;
|
|
32
|
+
payloadSha256: string;
|
|
33
|
+
viewerVersion: string;
|
|
34
|
+
bundleSha256: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function planUpdate(target: string, options?: {
|
|
37
|
+
output?: string;
|
|
38
|
+
bundle?: ViewerBundle;
|
|
39
|
+
now?: () => Date;
|
|
40
|
+
}): Promise<UpdatePlan>;
|
|
41
|
+
export declare function applyUpdate(plan: UpdatePlan): Promise<UpdateResult>;
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Local viewer replacement. Inspects the target as data only; never executes it.
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { loadBundle, sha256Hex } from "./bundle.js";
|
|
6
|
+
import { PublishError, writeFileExclusive } from "./export.js";
|
|
7
|
+
import { REPORT_LIMITS } from "./limits.js";
|
|
8
|
+
import { buildEnvelope, decodeEnvelope, extractEnvelope, ReportFormatError, renderHtml } from "./report.js";
|
|
9
|
+
function identityOf(s) {
|
|
10
|
+
return { ino: s.ino, dev: s.dev, size: s.size, mtimeMs: s.mtimeMs, mode: s.mode & 0o777 };
|
|
11
|
+
}
|
|
12
|
+
async function readRegularFile(target) {
|
|
13
|
+
let stat;
|
|
14
|
+
try {
|
|
15
|
+
stat = await fsp.lstat(target);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new ReportFormatError(`Report not found: ${target}`);
|
|
19
|
+
}
|
|
20
|
+
if (stat.isSymbolicLink())
|
|
21
|
+
throw new ReportFormatError("Report path is a symbolic link");
|
|
22
|
+
if (!stat.isFile())
|
|
23
|
+
throw new ReportFormatError("Report path is not a regular file");
|
|
24
|
+
if (stat.size > REPORT_LIMITS.maxReportFileBytes)
|
|
25
|
+
throw new ReportFormatError("Report file is too large");
|
|
26
|
+
return { bytes: await fsp.readFile(target), stat };
|
|
27
|
+
}
|
|
28
|
+
export async function planUpdate(target, options = {}) {
|
|
29
|
+
const bundle = options.bundle ?? loadBundle();
|
|
30
|
+
const { bytes, stat } = await readRegularFile(target);
|
|
31
|
+
const html = bytes.toString("utf8");
|
|
32
|
+
const base = {
|
|
33
|
+
targetPath: target,
|
|
34
|
+
...(options.output === undefined ? {} : { outputPath: options.output }),
|
|
35
|
+
originalSha256: sha256Hex(bytes),
|
|
36
|
+
originalSizeBytes: bytes.byteLength,
|
|
37
|
+
toViewerVersion: bundle.metadata.viewerVersion,
|
|
38
|
+
toBundleSha256: bundle.metadata.bundleSha256,
|
|
39
|
+
original: bytes,
|
|
40
|
+
identity: identityOf(stat),
|
|
41
|
+
};
|
|
42
|
+
const envelope = extractEnvelope(html);
|
|
43
|
+
const compat = bundle.metadata.compatible;
|
|
44
|
+
const unsupported = (reason) => ({ ...base, status: "unsupported", reason });
|
|
45
|
+
if (!compat.envelopeVersions.includes(envelope.envelopeVersion)) {
|
|
46
|
+
return unsupported("unsupported envelope version");
|
|
47
|
+
}
|
|
48
|
+
const report = decodeEnvelope(envelope); // throws on malformed/inconsistent payloads
|
|
49
|
+
if (!compat.schemaVersions.includes(report.schemaVersion))
|
|
50
|
+
return unsupported("unsupported report schema");
|
|
51
|
+
const common = {
|
|
52
|
+
...base,
|
|
53
|
+
payloadSha256: envelope.payloadSha256,
|
|
54
|
+
fromViewerVersion: envelope.viewer.version,
|
|
55
|
+
};
|
|
56
|
+
// Same-bundle no-op only if the actual file equals a trusted reconstruction.
|
|
57
|
+
const sameBundle = envelope.viewer.bundleSha256 === bundle.metadata.bundleSha256 &&
|
|
58
|
+
envelope.viewer.templateVersion === bundle.metadata.templateVersion;
|
|
59
|
+
if (sameBundle && renderHtml(envelope, bundle, report.title) === html) {
|
|
60
|
+
return { ...common, status: "noop" };
|
|
61
|
+
}
|
|
62
|
+
const updated = {
|
|
63
|
+
...buildEnvelope(report, bundle, (options.now?.() ?? new Date()).toISOString()),
|
|
64
|
+
// Payload kept verbatim, not re-serialized.
|
|
65
|
+
payload: envelope.payload,
|
|
66
|
+
payloadSha256: envelope.payloadSha256,
|
|
67
|
+
};
|
|
68
|
+
return { ...common, status: "replace", newHtml: renderHtml(updated, bundle, report.title) };
|
|
69
|
+
}
|
|
70
|
+
async function assertUnchanged(plan) {
|
|
71
|
+
let current;
|
|
72
|
+
try {
|
|
73
|
+
current = await readRegularFile(plan.targetPath);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new PublishError("REPORT_CONFLICT", "The report changed while the update was being prepared. Nothing was changed.");
|
|
77
|
+
}
|
|
78
|
+
const a = identityOf(current.stat);
|
|
79
|
+
const b = plan.identity;
|
|
80
|
+
if (a.ino !== b.ino ||
|
|
81
|
+
a.dev !== b.dev ||
|
|
82
|
+
a.size !== b.size ||
|
|
83
|
+
a.mtimeMs !== b.mtimeMs ||
|
|
84
|
+
sha256Hex(current.bytes) !== plan.originalSha256) {
|
|
85
|
+
throw new PublishError("REPORT_CONFLICT", "The report changed while the update was being prepared. Nothing was changed.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function writeUniqueBackup(target, bytes, mode) {
|
|
89
|
+
const { dir, name, ext } = path.parse(target);
|
|
90
|
+
for (let n = 0; n < 1000; n++) {
|
|
91
|
+
const candidate = path.join(dir, `${name}.backup${n === 0 ? "" : `-${n}`}${ext}`);
|
|
92
|
+
try {
|
|
93
|
+
await fsp.writeFile(candidate, bytes, { flag: "wx", mode });
|
|
94
|
+
return candidate;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== "EEXIST")
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
throw new PublishError("OUTPUT_EXISTS", "Could not find an unused backup file name");
|
|
102
|
+
}
|
|
103
|
+
function digestsOf(html) {
|
|
104
|
+
return { sha256: sha256Hex(html), sizeBytes: Buffer.byteLength(html) };
|
|
105
|
+
}
|
|
106
|
+
export async function applyUpdate(plan) {
|
|
107
|
+
if (plan.status === "unsupported") {
|
|
108
|
+
throw new PublishError("UNSUPPORTED_REPORT", plan.reason ?? "Report cannot be updated");
|
|
109
|
+
}
|
|
110
|
+
await assertUnchanged(plan);
|
|
111
|
+
const payloadSha256 = plan.payloadSha256 ?? "";
|
|
112
|
+
if (plan.outputPath !== undefined) {
|
|
113
|
+
const content = plan.status === "replace" ? plan.newHtml : plan.original;
|
|
114
|
+
await writeFileExclusive(plan.outputPath, content, plan.identity.mode);
|
|
115
|
+
return {
|
|
116
|
+
status: plan.status === "replace" ? "updated" : "copied",
|
|
117
|
+
path: plan.outputPath,
|
|
118
|
+
payloadSha256,
|
|
119
|
+
viewerVersion: plan.toViewerVersion,
|
|
120
|
+
bundleSha256: plan.toBundleSha256,
|
|
121
|
+
...digestsOf(content),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (plan.status === "noop" || plan.newHtml === undefined) {
|
|
125
|
+
throw new PublishError("REPORT_CONFLICT", "Nothing to update");
|
|
126
|
+
}
|
|
127
|
+
const backupPath = await writeUniqueBackup(plan.targetPath, plan.original, plan.identity.mode);
|
|
128
|
+
const tmp = path.join(path.dirname(plan.targetPath), `.${path.basename(plan.targetPath)}.tmp-${process.pid}-${randomUUID()}`);
|
|
129
|
+
try {
|
|
130
|
+
await fsp.writeFile(tmp, plan.newHtml, { flag: "wx", mode: plan.identity.mode });
|
|
131
|
+
await fsp.chmod(tmp, plan.identity.mode);
|
|
132
|
+
await fsp.rename(tmp, plan.targetPath);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
await fsp.rm(tmp, { force: true });
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: "updated",
|
|
140
|
+
path: plan.targetPath,
|
|
141
|
+
backupPath,
|
|
142
|
+
payloadSha256,
|
|
143
|
+
viewerVersion: plan.toViewerVersion,
|
|
144
|
+
bundleSha256: plan.toBundleSha256,
|
|
145
|
+
...digestsOf(plan.newHtml),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, chmodSync, statSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { computeBundleSha256, sha256Hex, TEMPLATE_VERSION } from "./bundle.js";
|
|
6
|
+
import { exportReport, writeFileExclusive } from "./export.js";
|
|
7
|
+
import { decodeEnvelope, extractEnvelope, ReportFormatError } from "./report.js";
|
|
8
|
+
import { applyUpdate, planUpdate } from "./update.js";
|
|
9
|
+
function fakeBundle(tag, version = "1.0.0") {
|
|
10
|
+
const js = `/* viewer ${tag} */ window.__v=${JSON.stringify(tag)};`;
|
|
11
|
+
const css = `/* css ${tag} */ body{margin:0}`;
|
|
12
|
+
const assets = { "viewer.js": sha256Hex(js), "viewer.css": sha256Hex(css) };
|
|
13
|
+
return {
|
|
14
|
+
js,
|
|
15
|
+
css,
|
|
16
|
+
metadata: {
|
|
17
|
+
viewerVersion: version,
|
|
18
|
+
molstarVersion: "5.12.0",
|
|
19
|
+
templateVersion: TEMPLATE_VERSION,
|
|
20
|
+
compatible: { envelopeVersions: [1], schemaVersions: [1] },
|
|
21
|
+
assets,
|
|
22
|
+
bundleSha256: computeBundleSha256(assets),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const PDB = new TextEncoder().encode("ATOM 1 N ALA A 1 11.104 13.207 2.100 1.00 20.00 N\n");
|
|
27
|
+
function input(title = "My protein") {
|
|
28
|
+
return {
|
|
29
|
+
title,
|
|
30
|
+
resources: [{ id: "r1", label: "p.pdb", format: "pdb", bytes: PDB }],
|
|
31
|
+
scenes: [{ id: "s1", title: "Structure", structures: [{ id: "a", resourceId: "r1" }] }],
|
|
32
|
+
panels: [],
|
|
33
|
+
provenance: {},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
let dir;
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
dir = mkdtempSync(path.join(os.tmpdir(), "moldesk-viz-"));
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
41
|
+
describe("envelope", () => {
|
|
42
|
+
it("round-trips and survives hostile titles and molecular comments", () => {
|
|
43
|
+
const evil = '</script><script>alert(1)</script><img src=x onerror="y">';
|
|
44
|
+
const bytes = new TextEncoder().encode(`REMARK ${evil}\nATOM\n`);
|
|
45
|
+
const out = exportReport({ ...input(evil), resources: [{ id: "r1", label: evil, format: "pdb", bytes }] }, fakeBundle("a"));
|
|
46
|
+
// The only script elements are the inert envelope and the trusted viewer.
|
|
47
|
+
expect(out.html.match(/<script/g)).toHaveLength(2);
|
|
48
|
+
expect(out.html).not.toContain(evil);
|
|
49
|
+
const report = decodeEnvelope(extractEnvelope(out.html));
|
|
50
|
+
expect(report.title).toBe(evil);
|
|
51
|
+
expect(Buffer.from(report.resources[0].data, "base64").equals(Buffer.from(bytes))).toBe(true);
|
|
52
|
+
expect(report.resources[0].sha256).toBe(sha256Hex(bytes));
|
|
53
|
+
});
|
|
54
|
+
it("emits a CSP with matching hashes and no network allowances", () => {
|
|
55
|
+
const b = fakeBundle("a");
|
|
56
|
+
const { html } = exportReport(input(), b);
|
|
57
|
+
const csp = /Content-Security-Policy" content="([^"]+)"/.exec(html)[1].replace(/'/g, "'");
|
|
58
|
+
expect(csp).toContain("connect-src 'none'");
|
|
59
|
+
expect(csp).toContain("default-src 'none'");
|
|
60
|
+
expect(csp).not.toContain("unsafe-eval");
|
|
61
|
+
expect(csp).toContain(`'sha256-${Buffer.from(sha256Hex(b.js), "hex").toString("base64")}'`);
|
|
62
|
+
});
|
|
63
|
+
it("rejects duplicate envelopes, non-reports, and tampered payloads", () => {
|
|
64
|
+
const { html } = exportReport(input(), fakeBundle("a"));
|
|
65
|
+
const block = /<script type="application\/json" id="moldesk-report">[\s\S]*?<\/script>/.exec(html)[0];
|
|
66
|
+
expect(() => extractEnvelope(html.replace("</body>", `${block}</body>`))).toThrow(/more than one/);
|
|
67
|
+
expect(() => extractEnvelope("<html><body>hi</body></html>")).toThrow(ReportFormatError);
|
|
68
|
+
const env = extractEnvelope(html);
|
|
69
|
+
expect(() => decodeEnvelope({ ...env, payloadSha256: "0".repeat(64) })).toThrow(/digest/);
|
|
70
|
+
expect(() => decodeEnvelope({ ...env, payload: "!!!" })).toThrow(/base64/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("update", () => {
|
|
74
|
+
async function writeReport(bundle, name = "report.html") {
|
|
75
|
+
const file = path.join(dir, name);
|
|
76
|
+
writeFileSync(file, exportReport(input(), bundle).html);
|
|
77
|
+
return file;
|
|
78
|
+
}
|
|
79
|
+
it("is a no-op for the same bundle", async () => {
|
|
80
|
+
const b = fakeBundle("a");
|
|
81
|
+
const file = await writeReport(b);
|
|
82
|
+
expect((await planUpdate(file, { bundle: b })).status).toBe("noop");
|
|
83
|
+
});
|
|
84
|
+
it("does not trust same-version metadata when the scripts differ", async () => {
|
|
85
|
+
const b = fakeBundle("a");
|
|
86
|
+
const file = await writeReport(b);
|
|
87
|
+
writeFileSync(file, readFileSync(file, "utf8").replace("window.__v", "window.__evil"));
|
|
88
|
+
expect((await planUpdate(file, { bundle: b })).status).toBe("replace");
|
|
89
|
+
});
|
|
90
|
+
it("replaces with an older or newer local bundle, keeps payload, backs up the original", async () => {
|
|
91
|
+
const oldFile = await writeReport(fakeBundle("new", "2.0.0"));
|
|
92
|
+
const before = readFileSync(oldFile);
|
|
93
|
+
const payloadBefore = extractEnvelope(before.toString("utf8")).payload;
|
|
94
|
+
const plan = await planUpdate(oldFile, { bundle: fakeBundle("old", "1.0.0") });
|
|
95
|
+
expect(plan.status).toBe("replace");
|
|
96
|
+
const result = await applyUpdate(plan);
|
|
97
|
+
const after = readFileSync(oldFile, "utf8");
|
|
98
|
+
expect(after).toContain("viewer old");
|
|
99
|
+
expect(extractEnvelope(after).payload).toBe(payloadBefore);
|
|
100
|
+
expect(extractEnvelope(after).viewer.version).toBe("1.0.0");
|
|
101
|
+
expect(readFileSync(result.backupPath).equals(before)).toBe(true);
|
|
102
|
+
expect((await planUpdate(oldFile, { bundle: fakeBundle("old", "1.0.0") })).status).toBe("noop");
|
|
103
|
+
});
|
|
104
|
+
it("never overwrites an existing backup", async () => {
|
|
105
|
+
const file = await writeReport(fakeBundle("a"));
|
|
106
|
+
writeFileSync(path.join(dir, "report.backup.html"), "precious");
|
|
107
|
+
const result = await applyUpdate(await planUpdate(file, { bundle: fakeBundle("b") }));
|
|
108
|
+
expect(readFileSync(path.join(dir, "report.backup.html"), "utf8")).toBe("precious");
|
|
109
|
+
expect(path.basename(result.backupPath)).toBe("report.backup-1.html");
|
|
110
|
+
});
|
|
111
|
+
it("writes --output separately, leaves the original, refuses to overwrite", async () => {
|
|
112
|
+
const file = await writeReport(fakeBundle("a"));
|
|
113
|
+
const before = readFileSync(file);
|
|
114
|
+
const out = path.join(dir, "out.html");
|
|
115
|
+
const result = await applyUpdate(await planUpdate(file, { bundle: fakeBundle("b"), output: out }));
|
|
116
|
+
expect(result.backupPath).toBeUndefined();
|
|
117
|
+
expect(readFileSync(file).equals(before)).toBe(true);
|
|
118
|
+
expect(readFileSync(out, "utf8")).toContain("viewer b");
|
|
119
|
+
await expect(applyUpdate(await planUpdate(file, { bundle: fakeBundle("b"), output: out }))).rejects.toMatchObject({
|
|
120
|
+
code: "OUTPUT_EXISTS",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
it("copies on no-op with --output", async () => {
|
|
124
|
+
const b = fakeBundle("a");
|
|
125
|
+
const file = await writeReport(b);
|
|
126
|
+
const out = path.join(dir, "copy.html");
|
|
127
|
+
const result = await applyUpdate(await planUpdate(file, { bundle: b, output: out }));
|
|
128
|
+
expect(result.status).toBe("copied");
|
|
129
|
+
expect(readFileSync(out).equals(readFileSync(file))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
it("rejects when the file changed after planning, leaving it untouched", async () => {
|
|
132
|
+
const file = await writeReport(fakeBundle("a"));
|
|
133
|
+
const plan = await planUpdate(file, { bundle: fakeBundle("b") });
|
|
134
|
+
writeFileSync(file, readFileSync(file, "utf8") + "<!-- edited -->");
|
|
135
|
+
const edited = readFileSync(file);
|
|
136
|
+
await expect(applyUpdate(plan)).rejects.toMatchObject({ code: "REPORT_CONFLICT" });
|
|
137
|
+
expect(readFileSync(file).equals(edited)).toBe(true);
|
|
138
|
+
expect(readdirSync(dir).filter((n) => n.includes("backup") || n.includes(".tmp-"))).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
it("preserves file mode", async () => {
|
|
141
|
+
const file = await writeReport(fakeBundle("a"));
|
|
142
|
+
chmodSync(file, 0o600);
|
|
143
|
+
await applyUpdate(await planUpdate(file, { bundle: fakeBundle("b") }));
|
|
144
|
+
expect(statSync(file).mode & 0o777).toBe(0o600);
|
|
145
|
+
});
|
|
146
|
+
it("rejects symlinks, ordinary HTML, and unsupported schemas without touching them", async () => {
|
|
147
|
+
const real = await writeReport(fakeBundle("a"));
|
|
148
|
+
const link = path.join(dir, "link.html");
|
|
149
|
+
symlinkSync(real, link);
|
|
150
|
+
await expect(planUpdate(link, { bundle: fakeBundle("b") })).rejects.toThrow(/symbolic link/);
|
|
151
|
+
const plain = path.join(dir, "plain.html");
|
|
152
|
+
writeFileSync(plain, "<html><script>alert(1)</script></html>");
|
|
153
|
+
await expect(planUpdate(plain, { bundle: fakeBundle("b") })).rejects.toThrow(/not a MoleculeDesk/);
|
|
154
|
+
const b = fakeBundle("b");
|
|
155
|
+
b.metadata.compatible.envelopeVersions = [99];
|
|
156
|
+
expect((await planUpdate(real, { bundle: b })).status).toBe("unsupported");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe("writeFileExclusive", () => {
|
|
160
|
+
it("refuses an existing destination and cleans temp files", async () => {
|
|
161
|
+
const dest = path.join(dir, "x.html");
|
|
162
|
+
await writeFileExclusive(dest, "one");
|
|
163
|
+
await expect(writeFileExclusive(dest, "two")).rejects.toMatchObject({ code: "OUTPUT_EXISTS" });
|
|
164
|
+
expect(readFileSync(dest, "utf8")).toBe("one");
|
|
165
|
+
expect(readdirSync(dir)).toEqual(["x.html"]);
|
|
166
|
+
});
|
|
167
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moldesk/visualizer",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"description": "Offline, self-contained Mol* molecular reports for MoleculeDesk",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/pooriyapfn/moldesk.git",
|
|
10
|
+
"directory": "packages/visualizer"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./contract": {
|
|
24
|
+
"types": "./dist/contract.d.ts",
|
|
25
|
+
"default": "./dist/contract.js"
|
|
26
|
+
},
|
|
27
|
+
"./csv": {
|
|
28
|
+
"types": "./dist/csv.d.ts",
|
|
29
|
+
"default": "./dist/csv.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.16.11",
|
|
37
|
+
"@types/react": "18.3.31",
|
|
38
|
+
"@types/react-dom": "18.3.7",
|
|
39
|
+
"esbuild": "0.28.2",
|
|
40
|
+
"molstar": "5.12.0",
|
|
41
|
+
"playwright-core": "1.63.0",
|
|
42
|
+
"react": "18.3.1",
|
|
43
|
+
"react-dom": "18.3.1",
|
|
44
|
+
"typescript": "^5.6.3",
|
|
45
|
+
"vitest": "^3.0.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.json && node scripts/build-viewer.mjs",
|
|
49
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"smoke": "node scripts/browser-smoke.mjs",
|
|
52
|
+
"measure": "node scripts/measure.mjs"
|
|
53
|
+
}
|
|
6
54
|
}
|
package/README.md
DELETED