@hachej/boring-files 0.4.51 → 0.4.52

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.
@@ -12,7 +12,8 @@ export declare const mediaTypeOf: (path: string) => string;
12
12
  /** How many leading bytes `sniffMediaType` looks at: a caller reads this many and passes them in. */
13
13
  export declare const SNIFF_BYTES = 8192;
14
14
  /**
15
- * The media type a file's leading bytes name, or undefined. Pass up to `SNIFF_BYTES` of them.
15
+ * The media type a file's leading bytes name, or undefined. Pass at least `SNIFF_BYTES` of them when there are that many; only a zip
16
+ * looks further, so more bytes (the whole file) let it read its central directory.
16
17
  * - Signatures: PDF, PNG, JPEG, GIF, WebP, HEIC/HEIF and MP4/MOV/M4V (`ftyp` brands), WebM (EBML), and zip-based documents.
17
18
  * - Otherwise `text/plain` when the bytes are valid UTF-8 (a character cut at the end is allowed) with no NUL byte: a weak answer, a
18
19
  * name says better which text it is (`delimitedTextType` for CSV and TSV).
@@ -54,44 +54,99 @@ const OOXML_MAIN = [
54
54
  ['presentationml.presentation.main', 'ppt/', MEDIA_TYPES.pptx],
55
55
  ];
56
56
  const ZIP_MIMETYPES = new Set([MEDIA_TYPES.odt, MEDIA_TYPES.ods, MEDIA_TYPES.odp, MEDIA_TYPES.epub]);
57
+ /** The office type a zip's entry names say: a `[Content_Types].xml` and a part in `word/`, `xl/` or `ppt/`, in any order. */
58
+ const officeTypeOf = (names) => names.includes('[Content_Types].xml')
59
+ ? OOXML_MAIN.find(([, prefix]) => names.some(name => name.startsWith(prefix)))?.[2]
60
+ : undefined;
61
+ /** How much central directory a zip gets read: a real office file's fits well within this, and the walk is synchronous. */
62
+ const ZIP_DIRECTORY_BYTES = 0x100000;
57
63
  /**
58
- * Zip-based documents from the head alone, without inflating anything: it walks the local file headers that fit in `head`.
59
- * - OpenDocument and EPUB: a first entry `mimetype`, stored uncompressed, holds the media type itself.
60
- * - Office Open XML (docx, xlsx, pptx): a `[Content_Types].xml` entry, then the main part's content type when that entry is stored
61
- * uncompressed, or else the folder of a later entry (`word/`, `xl/`, `ppt/`). Its content is almost always deflated, and inflating
62
- * is asynchronous in a browser, so the folder rule is the usual one.
63
- * A zip whose entries have unknown sizes (a data descriptor) cannot be walked past its first entry; it is then not claimed.
64
+ * The entry names of a zip's central directory when `bytes` reach its end record; undefined otherwise (a head, a zip64 file, a
65
+ * directory past the budget). Bytes inside an entry can look like an end record, so only one whose directory adds up counts.
64
66
  */
65
- function sniffZip(head) {
66
- let contentTypes = false;
67
- for (let offset = 0, index = 0; index < 64 && offset + 30 <= head.length && u32(head, offset) === 0x04034b50; index++) {
68
- const flags = u16(head, offset + 6), method = u16(head, offset + 8), size = u32(head, offset + 18);
69
- const nameStart = offset + 30, nameEnd = nameStart + u16(head, offset + 26), dataStart = nameEnd + u16(head, offset + 28);
70
- if (nameEnd > head.length)
67
+ function zipDirectoryNames(bytes) {
68
+ let budget = ZIP_DIRECTORY_BYTES;
69
+ for (let end = bytes.length - 22; end >= 0 && end >= bytes.length - 22 - 0xffff; end--) {
70
+ if (u32(bytes, end) !== 0x06054b50 || end + 22 + u16(bytes, end + 20) !== bytes.length)
71
+ continue;
72
+ const size = u32(bytes, end + 12), start = end - size;
73
+ if (start < 0 || u32(bytes, end + 16) !== start)
74
+ continue;
75
+ budget -= size;
76
+ if (budget < 0)
71
77
  return undefined;
72
- const name = ascii(head.subarray(nameStart, nameEnd));
73
- const stored = method === 0 && (flags & 8) === 0 && dataStart + size <= head.length ? ascii(head.subarray(dataStart, dataStart + size)) : undefined;
78
+ const names = [];
79
+ let offset = start;
80
+ while (offset + 46 <= end && u32(bytes, offset) === 0x02014b50) {
81
+ const nameEnd = offset + 46 + u16(bytes, offset + 28);
82
+ if (nameEnd > end)
83
+ break;
84
+ names.push(ascii(bytes.subarray(offset + 46, nameEnd)));
85
+ offset = nameEnd + u16(bytes, offset + 30) + u16(bytes, offset + 32);
86
+ }
87
+ if (offset === end && names.length === u16(bytes, end + 10))
88
+ return names;
89
+ }
90
+ return undefined;
91
+ }
92
+ /**
93
+ * Zip-based documents from the bytes given, without inflating anything.
94
+ * - OpenDocument and EPUB: a first entry `mimetype`, stored uncompressed, holds the media type itself.
95
+ * - Office Open XML (docx, xlsx, pptx), whatever the order of its entries (OOXML sets none; openpyxl writes `[Content_Types].xml`
96
+ * last): that entry and a part in `word/`, `xl/` or `ppt/`, or the main part's content type when that entry is stored uncompressed.
97
+ * The answer comes from the local headers while they suffice, then from the central directory when the bytes reach it (the only
98
+ * place that names entries with unknown sizes). A head of at least `SNIFF_BYTES` that runs out before either: a `.xml` part in an
99
+ * office folder decides, `[Content_Types].xml` being out of sight. It cannot tell a truncated file from a big one; a host that wants
100
+ * that refused checks the file's end once it has it. Anything shorter than `SNIFF_BYTES` is the whole file, so it is held to the rule.
101
+ */
102
+ function sniffZip(bytes) {
103
+ const names = [];
104
+ let cut = false;
105
+ for (let offset = 0, index = 0; offset < bytes.length; index++) {
106
+ if (offset + 30 > bytes.length || index === 64) {
107
+ cut = true;
108
+ break;
109
+ }
110
+ if (u32(bytes, offset) !== 0x04034b50)
111
+ break;
112
+ const flags = u16(bytes, offset + 6), method = u16(bytes, offset + 8), size = u32(bytes, offset + 18);
113
+ const nameStart = offset + 30, nameEnd = nameStart + u16(bytes, offset + 26), dataStart = nameEnd + u16(bytes, offset + 28);
114
+ if (nameEnd > bytes.length) {
115
+ cut = true;
116
+ break;
117
+ }
118
+ const name = ascii(bytes.subarray(nameStart, nameEnd));
119
+ // Only these two are ever read, and never a big one: `ascii` spreads its bytes, and a whole file can hold a large stored entry.
120
+ const stored = (name === 'mimetype' || name === '[Content_Types].xml') && method === 0 && (flags & 8) === 0 && size <= SNIFF_BYTES && dataStart + size <= bytes.length ? ascii(bytes.subarray(dataStart, dataStart + size)) : undefined;
74
121
  if (index === 0 && name === 'mimetype' && stored !== undefined)
75
122
  return ZIP_MIMETYPES.has(stored.trim()) ? stored.trim() : undefined;
76
- if (name === '[Content_Types].xml') {
77
- contentTypes = true;
78
- const named = stored === undefined ? undefined : OOXML_MAIN.find(([main]) => stored.includes(`${main}+xml`));
123
+ if (name === '[Content_Types].xml' && stored !== undefined) {
124
+ const named = OOXML_MAIN.find(([main]) => stored.includes(`${main}+xml`));
79
125
  if (named)
80
126
  return named[2];
81
127
  }
82
- else if (contentTypes) {
83
- const folder = OOXML_MAIN.find(([, prefix]) => name.startsWith(prefix));
84
- if (folder)
85
- return folder[2];
128
+ names.push(name);
129
+ const office = officeTypeOf(names);
130
+ if (office)
131
+ return office;
132
+ if (flags & 8) {
133
+ cut = true;
134
+ break;
86
135
  }
87
- if (flags & 8)
88
- return undefined;
89
136
  offset = dataStart + size;
137
+ if (offset >= bytes.length)
138
+ cut = true;
90
139
  }
91
- return undefined;
140
+ const directory = zipDirectoryNames(bytes);
141
+ if (directory)
142
+ return officeTypeOf(directory);
143
+ if (!cut || bytes.length < SNIFF_BYTES)
144
+ return undefined;
145
+ return OOXML_MAIN.find(([, prefix]) => names.some(name => name.startsWith(prefix) && name.endsWith('.xml')))?.[2];
92
146
  }
93
147
  /**
94
- * The media type a file's leading bytes name, or undefined. Pass up to `SNIFF_BYTES` of them.
148
+ * The media type a file's leading bytes name, or undefined. Pass at least `SNIFF_BYTES` of them when there are that many; only a zip
149
+ * looks further, so more bytes (the whole file) let it read its central directory.
95
150
  * - Signatures: PDF, PNG, JPEG, GIF, WebP, HEIC/HEIF and MP4/MOV/M4V (`ftyp` brands), WebM (EBML), and zip-based documents.
96
151
  * - Otherwise `text/plain` when the bytes are valid UTF-8 (a character cut at the end is allowed) with no NUL byte: a weak answer, a
97
152
  * name says better which text it is (`delimitedTextType` for CSV and TSV).
@@ -123,7 +178,7 @@ export function sniffMediaType(head) {
123
178
  if (at([0x1a, 0x45, 0xdf, 0xa3]))
124
179
  return 'video/webm';
125
180
  if (at([0x50, 0x4b, 0x03, 0x04]))
126
- return sniffZip(head.subarray(0, SNIFF_BYTES));
181
+ return sniffZip(head);
127
182
  const sample = head.subarray(0, SNIFF_BYTES);
128
183
  if (sample.length === 0 || sample.includes(0))
129
184
  return undefined;
@@ -25,6 +25,11 @@ export interface FileEntry {
25
25
  export interface FilePage {
26
26
  readonly entries: readonly FileEntry[];
27
27
  readonly cursor?: string;
28
+ /**
29
+ * Whether the caller may write in the listed folder (add, upload, rename, move or delete there), as the host decides it. Absent: the
30
+ * host says nothing, and the views offer what the client and its capabilities offer. A view never infers it from roles or config.
31
+ */
32
+ readonly writable?: boolean;
28
33
  }
29
34
  export interface FileListRequest {
30
35
  readonly directory?: string;
@@ -81,8 +86,13 @@ export interface WorkspaceCatalog {
81
86
  readonly history: (path: string, access: ResourceAccess) => Promise<readonly SavedRevision[]>;
82
87
  }
83
88
  export declare class RevisionError extends Error {
84
- readonly kind: 'denied' | 'unavailable' | 'invalid' | 'aborted';
85
- constructor(kind: 'denied' | 'unavailable' | 'invalid' | 'aborted', message: string);
89
+ readonly kind: 'denied' | 'unavailable' | 'invalid' | 'aborted' | 'missing';
90
+ readonly status?: number | undefined;
91
+ /**
92
+ * `missing`: the addressed folder or file is not there (or not for this caller): HTTP 404. `status`: the HTTP status the host answered,
93
+ * when it answered one (`connectRevisionProvider` sets it), so a 403 can be told from a 409 binding mismatch, both `denied`.
94
+ */
95
+ constructor(kind: 'denied' | 'unavailable' | 'invalid' | 'aborted' | 'missing', message: string, status?: number | undefined);
86
96
  }
87
97
  /** One authenticated binding for viewers, file browsing and publication. */
88
98
  export interface RevisionProvider extends ResourceClient {
@@ -1,8 +1,14 @@
1
1
  export class RevisionError extends Error {
2
2
  kind;
3
- constructor(kind, message) {
3
+ status;
4
+ /**
5
+ * `missing`: the addressed folder or file is not there (or not for this caller): HTTP 404. `status`: the HTTP status the host answered,
6
+ * when it answered one (`connectRevisionProvider` sets it), so a 403 can be told from a 409 binding mismatch, both `denied`.
7
+ */
8
+ constructor(kind, message, status) {
4
9
  super(message);
5
10
  this.kind = kind;
11
+ this.status = status;
6
12
  this.name = 'RevisionError';
7
13
  }
8
14
  }
@@ -213,7 +213,7 @@ export function createRevisionHandler(options) {
213
213
  return new Response(body, { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
214
214
  }
215
215
  catch (error) {
216
- return refuse(error instanceof RevisionError ? error.kind === 'invalid' ? 400 : error.kind === 'denied' || error.kind === 'aborted' ? 403 : 503 : error instanceof RequestGuardError || error instanceof TypeError ? guardStatus(error) : 503);
216
+ return refuse(error instanceof RevisionError ? error.kind === 'invalid' ? 400 : error.kind === 'denied' || error.kind === 'aborted' ? 403 : error.kind === 'missing' ? 404 : 503 : error instanceof RequestGuardError || error instanceof TypeError ? guardStatus(error) : 503);
217
217
  }
218
218
  finally {
219
219
  await Promise.allSettled([...operations]);
@@ -44,7 +44,9 @@ export function filePage(value) {
44
44
  });
45
45
  if (new Set(entries.map(entry => entry.path)).size !== entries.length)
46
46
  throw new RevisionError('invalid', 'Duplicate file entries');
47
- return { entries, ...(typeof data.cursor === 'string' ? { cursor: data.cursor } : {}) };
47
+ if (data.writable !== undefined && typeof data.writable !== 'boolean')
48
+ throw new RevisionError('invalid', 'Invalid file page');
49
+ return { entries, ...(typeof data.cursor === 'string' ? { cursor: data.cursor } : {}), ...(typeof data.writable === 'boolean' ? { writable: data.writable } : {}) };
48
50
  }
49
51
  export function savedRevisions(value) {
50
52
  if (!Array.isArray(value) || value.length > 100_000)
package/dist/revision.js CHANGED
@@ -22,7 +22,7 @@ export async function connectRevisionProvider(options) {
22
22
  }
23
23
  if (response.redirected || !response.ok) {
24
24
  void response.body?.cancel().catch(() => { });
25
- throw new RevisionError(response.status === 403 || response.status === 409 ? 'denied' : response.status === 400 ? 'invalid' : 'unavailable', 'The selected workspace is unavailable or no longer authorized');
25
+ throw new RevisionError(response.status === 403 || response.status === 409 ? 'denied' : response.status === 400 ? 'invalid' : response.status === 404 ? 'missing' : 'unavailable', response.status === 404 ? 'Not found' : 'The selected workspace is unavailable or no longer authorized', response.status);
26
26
  }
27
27
  return await readJsonBody(response, 8_388_608, request.signal);
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.4.51",
3
+ "version": "0.4.52",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",