@jokerized/decksmith 0.1.0 → 0.1.2

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.
@@ -1,232 +0,0 @@
1
- import { posix, sep } from "node:path";
2
- import { unzipSync } from "fflate";
3
- const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
4
- const ZIP_LIMITS = {
5
- maxEntries: 500,
6
- maxTotalBytes: 200 * 1024 * 1024,
7
- maxEntryBytes: 64 * 1024 * 1024
8
- };
9
- const MARKDOWN_EXTS = [".md", ".markdown", ".txt"];
10
- class UploadError extends Error {
11
- hint;
12
- status;
13
- constructor(message, hint, status = 400) {
14
- super(message);
15
- this.name = "UploadError";
16
- this.hint = hint;
17
- this.status = status;
18
- }
19
- }
20
- const GRACE = 4;
21
- function readBody(req, limit = MAX_UPLOAD_BYTES) {
22
- return new Promise((resolve, reject) => {
23
- const chunks = [];
24
- let size = 0;
25
- let refused = false;
26
- req.on("data", (chunk) => {
27
- size += chunk.length;
28
- if (refused) {
29
- if (size > limit * GRACE) req.destroy();
30
- return;
31
- }
32
- if (size > limit) {
33
- refused = true;
34
- chunks.length = 0;
35
- reject(
36
- new UploadError(
37
- `Upload is larger than ${Math.round(limit / 1024 / 1024)} MB.`,
38
- "Send the markdown on its own, or a zip holding only the document and the figures it cites.",
39
- 413
40
- )
41
- );
42
- return;
43
- }
44
- chunks.push(chunk);
45
- });
46
- req.on("end", () => {
47
- if (!refused) resolve(Buffer.concat(chunks));
48
- });
49
- req.on("error", (err) => {
50
- if (!refused) reject(err);
51
- });
52
- });
53
- }
54
- async function parseMultipart(body, contentType) {
55
- if (!/^multipart\/form-data\s*;/i.test(contentType)) {
56
- throw new UploadError(
57
- "This endpoint takes a multipart/form-data upload.",
58
- 'Post a form with a "file" part holding the document, e.g. `curl -F file=@paper.md`.',
59
- 415
60
- );
61
- }
62
- let form;
63
- try {
64
- const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
65
- form = await new Response(view, { headers: { "content-type": contentType } }).formData();
66
- } catch {
67
- throw new UploadError(
68
- "The multipart body could not be parsed.",
69
- "Let your HTTP client set the Content-Type and boundary rather than writing them by hand."
70
- );
71
- }
72
- const fields = {};
73
- let file;
74
- form.forEach((value, key) => {
75
- if (typeof value === "string") {
76
- fields[key] = value;
77
- return;
78
- }
79
- if (key === "file") file = value;
80
- });
81
- if (!file) {
82
- throw new UploadError(
83
- 'The upload has no "file" part.',
84
- 'Name the document part "file" \u2014 .md, .markdown, .txt, or a .zip containing one.'
85
- );
86
- }
87
- if (file.size === 0) {
88
- throw new UploadError(
89
- `"${file.name || "the uploaded file"}" is empty.`,
90
- "Check the path you gave your client; an empty part usually means the file was not found."
91
- );
92
- }
93
- return {
94
- filename: file.name || "upload",
95
- bytes: new Uint8Array(await file.arrayBuffer()),
96
- fields
97
- };
98
- }
99
- function looksLikeZip(bytes) {
100
- return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75;
101
- }
102
- function safeEntryPath(name) {
103
- if (name.includes("\0")) return null;
104
- const segments = name.replace(/\\/g, "/").split("/");
105
- if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
106
- const kept = [];
107
- for (const segment of segments) {
108
- if (segment === "" || segment === ".") continue;
109
- if (segment === "..") return null;
110
- kept.push(segment);
111
- }
112
- if (kept.length === 0) return null;
113
- const path = kept.join("/");
114
- if (path.length > 512 || kept.some((s) => s.length > 200)) return null;
115
- return path;
116
- }
117
- function insideRoot(root, joined) {
118
- return joined === root || joined.startsWith(root.endsWith(sep) ? root : root + sep);
119
- }
120
- function readZip(bytes, limits = ZIP_LIMITS) {
121
- const seen = [];
122
- try {
123
- unzipSync(bytes, {
124
- filter: (f) => {
125
- seen.push({ name: f.name, originalSize: f.originalSize });
126
- return false;
127
- }
128
- });
129
- } catch {
130
- throw new UploadError(
131
- "That file starts like a zip but could not be read as one.",
132
- "Re-create the archive \u2014 a truncated download and a renamed .rar both look like this."
133
- );
134
- }
135
- const warnings = [];
136
- const approved = /* @__PURE__ */ new Map();
137
- let total = 0;
138
- for (const entry of seen) {
139
- if (entry.name.endsWith("/")) continue;
140
- if (isNoise(entry.name)) continue;
141
- const safe = safeEntryPath(entry.name);
142
- if (safe === null) {
143
- throw new UploadError(
144
- `The archive contains an entry that tries to escape its directory: "${entry.name}".`,
145
- "Re-zip from inside the folder so every path is relative and none of them start with / or contain .."
146
- );
147
- }
148
- if (entry.originalSize > limits.maxEntryBytes) {
149
- throw new UploadError(
150
- `"${entry.name}" unpacks to ${mb(entry.originalSize)}, over the ${mb(limits.maxEntryBytes)} per-file limit.`,
151
- "Leave the large file out; a deck reads the document and its figures, not the dataset."
152
- );
153
- }
154
- total += entry.originalSize;
155
- if (total > limits.maxTotalBytes) {
156
- throw new UploadError(
157
- `The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
158
- "Send only the document and the figures it cites."
159
- );
160
- }
161
- if (approved.size >= limits.maxEntries) {
162
- throw new UploadError(
163
- `The archive holds more than ${limits.maxEntries} files.`,
164
- "Send only the document and the figures it cites."
165
- );
166
- }
167
- approved.set(entry.name, safe);
168
- }
169
- if (approved.size === 0) {
170
- throw new UploadError(
171
- "The archive is empty.",
172
- "Zip the folder that holds your markdown, not an empty one."
173
- );
174
- }
175
- const raw = unzipSync(bytes, { filter: (f) => approved.has(f.name) });
176
- const files = {};
177
- let actual = 0;
178
- for (const [name, safe] of approved) {
179
- const data = raw[name];
180
- if (!data) continue;
181
- actual += data.length;
182
- if (actual > limits.maxTotalBytes) {
183
- throw new UploadError(
184
- `The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
185
- "Send only the document and the figures it cites."
186
- );
187
- }
188
- if (files[safe]) warnings.push(`two entries both unpack to ${safe}; kept the last`);
189
- files[safe] = data;
190
- }
191
- return { files, warnings };
192
- }
193
- function pickMarkdown(files) {
194
- const candidates = Object.keys(files).filter(
195
- (p) => MARKDOWN_EXTS.includes(posix.extname(p).toLowerCase())
196
- );
197
- if (candidates.length === 0) {
198
- throw new UploadError(
199
- `The archive has no ${MARKDOWN_EXTS.join(", ")} file in it.`,
200
- "DeckSmith reads a markdown document. Add the .md next to your figures and zip the folder again."
201
- );
202
- }
203
- const rank = (p) => {
204
- const base = posix.basename(p).toLowerCase();
205
- const named = /^(readme|index|main|paper|analysis)\./.test(base) ? 0 : 1;
206
- return [p.split("/").length, named, p];
207
- };
208
- return candidates.sort((a, b) => {
209
- const [da, na, pa] = rank(a);
210
- const [db, nb, pb] = rank(b);
211
- return da - db || na - nb || pa.localeCompare(pb);
212
- })[0];
213
- }
214
- function isNoise(name) {
215
- return name.startsWith("__MACOSX/") || name.includes("/__MACOSX/") || posix.basename(name) === ".DS_Store" || posix.basename(name) === "Thumbs.db";
216
- }
217
- function mb(bytes) {
218
- return `${Math.round(bytes / 1024 / 1024)} MB`;
219
- }
220
- export {
221
- MARKDOWN_EXTS,
222
- MAX_UPLOAD_BYTES,
223
- UploadError,
224
- ZIP_LIMITS,
225
- insideRoot,
226
- looksLikeZip,
227
- parseMultipart,
228
- pickMarkdown,
229
- readBody,
230
- readZip,
231
- safeEntryPath
232
- };