caracms 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
package/bin/media.mjs
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
// `caracms media import <files…>` (M8 Decision 18): local images into the site's media
|
|
2
|
+
// bucket, by the editor's upload route's own rules, for a port to point its content at.
|
|
3
|
+
// It's `scripts/media-setup.ts` made a command.
|
|
4
|
+
//
|
|
5
|
+
// Plain JavaScript with Node built-ins only: it runs on Node 22.12 with no flags, so
|
|
6
|
+
// nothing here may import the package's TypeScript. That's why the upload route's rules
|
|
7
|
+
// are written out again below, and why `test/media-import.test.ts` runs both on the same
|
|
8
|
+
// inputs, so the two can't drift:
|
|
9
|
+
//
|
|
10
|
+
// - **The type and size are the bytes'** (src/editor/server/media/sniff.ts, `sniffImage`):
|
|
11
|
+
// JPEG, PNG, WebP, AVIF, HEIC/HEIF, GIF and SVG; the size as shown (EXIF orientation
|
|
12
|
+
// 5–8, or HEIF's `irot`, swaps it; an SVG's from its viewBox, else width and height).
|
|
13
|
+
// At most 40 MB and 50 megapixels (an SVG has no pixels), as the route.
|
|
14
|
+
// - **The key is `uploads/<first 12 hex of SHA-256>/<the name, slugified>.<ext>`**
|
|
15
|
+
// (src/editor/server/media/upload.ts, `keyName`; src/slug.ts, `slugify`), `<ext>` from
|
|
16
|
+
// the bytes.
|
|
17
|
+
// - **If anything exists under `uploads/<hash>/`, nothing is written**, and that key is
|
|
18
|
+
// the answer (`"existed": true`), whatever it was called. Nothing here ever deletes.
|
|
19
|
+
// - Metadata: `w`, `h`, `name` (the file's name, URI-encoded) and `by=import`.
|
|
20
|
+
// - Credentials: `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID` and
|
|
21
|
+
// `S3_SECRET_ACCESS_KEY`, from the environment, else the site's `.env` in the cwd.
|
|
22
|
+
// - S3's SigV4 by hand, path-style requests (Railway's endpoint takes both styles).
|
|
23
|
+
//
|
|
24
|
+
// Output: one JSON array on stdout, `[{ "file", "key", "w", "h", "existed" }]`, `file`
|
|
25
|
+
// as given. Messages go to stderr. Exit codes: 0 fine, 1 failed, 2 bad usage.
|
|
26
|
+
|
|
27
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { readFile } from 'node:fs/promises';
|
|
30
|
+
import { basename, join, resolve } from 'node:path';
|
|
31
|
+
|
|
32
|
+
const USAGE = 'Usage: caracms media import <files…>';
|
|
33
|
+
|
|
34
|
+
/** What the upload route takes (src/editor/types.ts: MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS). */
|
|
35
|
+
export const MAX_BYTES = 40_000_000;
|
|
36
|
+
export const MAX_PIXELS = 50_000_000;
|
|
37
|
+
|
|
38
|
+
export const ENV_NAMES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'];
|
|
39
|
+
|
|
40
|
+
// ── The key rule (upload.ts keyName, slug.ts slugify) ───────────────────────────
|
|
41
|
+
|
|
42
|
+
const SLUG_MAX = 60;
|
|
43
|
+
const FOLD = { ß: 'ss', æ: 'ae', œ: 'oe', ø: 'o', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' };
|
|
44
|
+
|
|
45
|
+
/** src/slug.ts `slugify`, written out again. */
|
|
46
|
+
export function slugify(name) {
|
|
47
|
+
let s = String(name).normalize('NFKD').replace(/\p{M}+/gu, '').toLowerCase();
|
|
48
|
+
s = s.replace(/[ßæœøłđðþı]/g, (c) => FOLD[c]);
|
|
49
|
+
s = s.replace(/&/g, ' and ');
|
|
50
|
+
s = s.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
|
|
51
|
+
s = s.normalize('NFC');
|
|
52
|
+
const chars = [...s];
|
|
53
|
+
if (chars.length > SLUG_MAX) s = chars.slice(0, SLUG_MAX).join('').replace(/-+$/, '');
|
|
54
|
+
return s;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** src/editor/server/media/upload.ts `keyName`: the name without its extension, slugified. */
|
|
58
|
+
export function keyName(name, fallback = 'image') {
|
|
59
|
+
const dot = name.lastIndexOf('.');
|
|
60
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
61
|
+
return slugify(stem) || fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The name as the route keeps it (`uploadName`): no path, no control characters, 255 characters, 1 KB encoded. */
|
|
65
|
+
export function cleanName(file) {
|
|
66
|
+
const name = (String(file).split(/[\\/]/).pop() ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
67
|
+
const chars = [...name].slice(0, 255);
|
|
68
|
+
while (chars.length > 0 && encodeURIComponent(chars.join('')).length > 1024) chars.pop();
|
|
69
|
+
return chars.join('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sha256hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
73
|
+
|
|
74
|
+
/** The key an image's bytes and name get, or the sniff's refusal. */
|
|
75
|
+
export function keyFor(bytes, name) {
|
|
76
|
+
const sniffed = sniffImage(bytes);
|
|
77
|
+
if (!sniffed.ok) return sniffed;
|
|
78
|
+
const hash = sha256hex(bytes).slice(0, 12);
|
|
79
|
+
return { ok: true, key: `uploads/${hash}/${keyName(cleanName(name))}.${sniffed.ext}`, hash, sniffed };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── The type and size from the bytes (sniff.ts sniffImage, images only) ─────────
|
|
83
|
+
|
|
84
|
+
const FORMATS = {
|
|
85
|
+
jpeg: { ext: 'jpg', mime: 'image/jpeg' },
|
|
86
|
+
png: { ext: 'png', mime: 'image/png' },
|
|
87
|
+
webp: { ext: 'webp', mime: 'image/webp' },
|
|
88
|
+
avif: { ext: 'avif', mime: 'image/avif' },
|
|
89
|
+
heic: { ext: 'heic', mime: 'image/heic' },
|
|
90
|
+
heif: { ext: 'heif', mime: 'image/heif' },
|
|
91
|
+
gif: { ext: 'gif', mime: 'image/gif' },
|
|
92
|
+
svg: { ext: 'svg', mime: 'image/svg+xml' },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const TAKES = 'The library takes JPEG, PNG, WebP, AVIF, HEIC, GIF and SVG images.';
|
|
96
|
+
const unknown = () => ({ ok: false, code: 'unknown-type', message: `This isn't an image the library can take. ${TAKES}` });
|
|
97
|
+
const named = (what) => ({ ok: false, code: 'not-an-image', message: `This is ${what}. ${TAKES}` });
|
|
98
|
+
const SVG_NO_SIZE_MESSAGE = 'This SVG has no size (no viewBox, and no width and height). Add a viewBox and import it again.';
|
|
99
|
+
|
|
100
|
+
function image(format, w, h, swap = false) {
|
|
101
|
+
if (!(w > 0 && h > 0 && Number.isFinite(w) && Number.isFinite(h))) return unknown();
|
|
102
|
+
return { ok: true, format, ...FORMATS[format], w: swap ? h : w, h: swap ? w : h };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Bounds-checked reads: out of range is NaN or '', which every caller treats as "not this". */
|
|
106
|
+
class Bytes {
|
|
107
|
+
constructor(b) {
|
|
108
|
+
this.b = b;
|
|
109
|
+
}
|
|
110
|
+
get length() {
|
|
111
|
+
return this.b.length;
|
|
112
|
+
}
|
|
113
|
+
u8(i) {
|
|
114
|
+
return i >= 0 && i < this.b.length ? this.b[i] : NaN;
|
|
115
|
+
}
|
|
116
|
+
u16be(i) {
|
|
117
|
+
return this.u8(i) * 256 + this.u8(i + 1);
|
|
118
|
+
}
|
|
119
|
+
u16le(i) {
|
|
120
|
+
return this.u8(i) + this.u8(i + 1) * 256;
|
|
121
|
+
}
|
|
122
|
+
u24le(i) {
|
|
123
|
+
return this.u8(i) + this.u8(i + 1) * 256 + this.u8(i + 2) * 65536;
|
|
124
|
+
}
|
|
125
|
+
u32be(i) {
|
|
126
|
+
return this.u16be(i) * 65536 + this.u16be(i + 2);
|
|
127
|
+
}
|
|
128
|
+
u32le(i) {
|
|
129
|
+
return this.u16le(i) + this.u16le(i + 2) * 65536;
|
|
130
|
+
}
|
|
131
|
+
ascii(i, n) {
|
|
132
|
+
if (i < 0 || i + n > this.b.length) return '';
|
|
133
|
+
let s = '';
|
|
134
|
+
for (let k = 0; k < n; k++) s += String.fromCharCode(this.b[i + k]);
|
|
135
|
+
return s;
|
|
136
|
+
}
|
|
137
|
+
starts(...bytes) {
|
|
138
|
+
return bytes.every((v, k) => this.b[k] === v);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** An image's type and size as shown, from its bytes. Never throws. */
|
|
143
|
+
export function sniffImage(data) {
|
|
144
|
+
const b = new Bytes(data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
145
|
+
if (b.length === 0) return unknown();
|
|
146
|
+
try {
|
|
147
|
+
if (b.starts(0xff, 0xd8, 0xff)) return jpeg(b);
|
|
148
|
+
if (b.starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return png(b);
|
|
149
|
+
const head6 = b.ascii(0, 6);
|
|
150
|
+
if (head6 === 'GIF87a' || head6 === 'GIF89a') return image('gif', b.u16le(6), b.u16le(8));
|
|
151
|
+
if (b.ascii(0, 4) === 'RIFF') {
|
|
152
|
+
const kind = b.ascii(8, 4);
|
|
153
|
+
if (kind === 'WEBP') return webp(b);
|
|
154
|
+
if (kind === 'AVI ') return named('a video');
|
|
155
|
+
if (kind === 'WAVE') return named('an audio file');
|
|
156
|
+
return unknown();
|
|
157
|
+
}
|
|
158
|
+
if (b.ascii(4, 4) === 'ftyp') return isoMedia(b);
|
|
159
|
+
const other = otherType(b);
|
|
160
|
+
if (other) return named(other);
|
|
161
|
+
return svg(b.b);
|
|
162
|
+
} catch {
|
|
163
|
+
return unknown();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function jpeg(b) {
|
|
168
|
+
let orientation = 1;
|
|
169
|
+
let i = 2;
|
|
170
|
+
while (i + 3 < b.length) {
|
|
171
|
+
if (b.u8(i) !== 0xff) return unknown();
|
|
172
|
+
const marker = b.u8(i + 1);
|
|
173
|
+
if (marker === 0xff) {
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
|
|
178
|
+
i += 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (marker === 0xd9 || marker === 0xda) break;
|
|
182
|
+
const len = b.u16be(i + 2);
|
|
183
|
+
if (!(len >= 2)) break;
|
|
184
|
+
if (marker === 0xe1 && b.ascii(i + 4, 6) === 'Exif\0\0') orientation = exifOrientation(b, i + 10, i + 2 + len) ?? orientation;
|
|
185
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
186
|
+
return image('jpeg', b.u16be(i + 7), b.u16be(i + 5), swaps(orientation));
|
|
187
|
+
}
|
|
188
|
+
i += 2 + len;
|
|
189
|
+
}
|
|
190
|
+
return unknown();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const swaps = (orientation) => orientation >= 5 && orientation <= 8;
|
|
194
|
+
|
|
195
|
+
function exifOrientation(b, tiff, end) {
|
|
196
|
+
const order = b.ascii(tiff, 2);
|
|
197
|
+
if (order !== 'II' && order !== 'MM') return null;
|
|
198
|
+
const le = order === 'II';
|
|
199
|
+
const u16 = (o) => (o + 2 <= end ? (le ? b.u16le(o) : b.u16be(o)) : NaN);
|
|
200
|
+
const u32 = (o) => (o + 4 <= end ? (le ? b.u32le(o) : b.u32be(o)) : NaN);
|
|
201
|
+
if (u16(tiff + 2) !== 42) return null;
|
|
202
|
+
const ifd = tiff + u32(tiff + 4);
|
|
203
|
+
const count = u16(ifd);
|
|
204
|
+
if (!(count >= 0) || count > 1000) return null;
|
|
205
|
+
for (let n = 0, e = ifd + 2; n < count; n++, e += 12) {
|
|
206
|
+
if (u16(e) === 0x0112) {
|
|
207
|
+
const value = u16(e + 8);
|
|
208
|
+
return value >= 1 && value <= 8 ? value : null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function png(b) {
|
|
215
|
+
if (b.ascii(12, 4) !== 'IHDR') return unknown();
|
|
216
|
+
const w = b.u32be(16);
|
|
217
|
+
const h = b.u32be(20);
|
|
218
|
+
let orientation = 1;
|
|
219
|
+
for (let i = 8; i + 8 <= b.length; ) {
|
|
220
|
+
const len = b.u32be(i);
|
|
221
|
+
const type = b.ascii(i + 4, 4);
|
|
222
|
+
if (!(len >= 0) || type === 'IDAT' || type === 'IEND' || type === '') break;
|
|
223
|
+
if (type === 'eXIf') orientation = exifOrientation(b, i + 8, i + 8 + len) ?? orientation;
|
|
224
|
+
i += 12 + len;
|
|
225
|
+
}
|
|
226
|
+
return image('png', w, h, swaps(orientation));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function webp(b) {
|
|
230
|
+
const chunk = b.ascii(12, 4);
|
|
231
|
+
let w = NaN;
|
|
232
|
+
let h = NaN;
|
|
233
|
+
if (chunk === 'VP8X') {
|
|
234
|
+
w = b.u24le(24) + 1;
|
|
235
|
+
h = b.u24le(27) + 1;
|
|
236
|
+
} else if (chunk === 'VP8L') {
|
|
237
|
+
if (b.u8(20) !== 0x2f) return unknown();
|
|
238
|
+
const bits = b.u32le(21);
|
|
239
|
+
w = (bits & 0x3fff) + 1;
|
|
240
|
+
h = ((bits >>> 14) & 0x3fff) + 1;
|
|
241
|
+
} else if (chunk === 'VP8 ') {
|
|
242
|
+
if (!(b.u8(23) === 0x9d && b.u8(24) === 0x01 && b.u8(25) === 0x2a)) return unknown();
|
|
243
|
+
w = b.u16le(26) & 0x3fff;
|
|
244
|
+
h = b.u16le(28) & 0x3fff;
|
|
245
|
+
} else return unknown();
|
|
246
|
+
let orientation = 1;
|
|
247
|
+
if (chunk === 'VP8X') {
|
|
248
|
+
for (let i = 12; i + 8 <= b.length; ) {
|
|
249
|
+
const type = b.ascii(i, 4);
|
|
250
|
+
const len = b.u32le(i + 4);
|
|
251
|
+
if (!(len >= 0) || type === '') break;
|
|
252
|
+
if (type === 'EXIF') {
|
|
253
|
+
const start = b.ascii(i + 8, 6) === 'Exif\0\0' ? i + 14 : i + 8;
|
|
254
|
+
orientation = exifOrientation(b, start, i + 8 + len) ?? orientation;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
i += 8 + len + (len % 2);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return image('webp', w, h, swaps(orientation));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const AVIF_BRANDS = new Set(['avif', 'avis']);
|
|
264
|
+
const HEIC_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs']);
|
|
265
|
+
const HEIF_BRANDS = new Set(['mif1', 'msf1']);
|
|
266
|
+
const VIDEO_BRANDS = new Set(['isom', 'iso2', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'qt ', 'M4V ', 'M4VP', '3gp4', '3gp5', '3gp6', '3g2a', 'avc1', 'dash', 'MSNV', 'f4v ', 'crx ']);
|
|
267
|
+
const AUDIO_BRANDS = new Set(['M4A ', 'M4B ', 'M4P ', 'F4A ']);
|
|
268
|
+
|
|
269
|
+
function boxes(b, from, to) {
|
|
270
|
+
const out = [];
|
|
271
|
+
for (let i = from; i + 8 <= to && out.length < 4096; ) {
|
|
272
|
+
let size = b.u32be(i);
|
|
273
|
+
const type = b.ascii(i + 4, 4);
|
|
274
|
+
let header = 8;
|
|
275
|
+
if (size === 1) {
|
|
276
|
+
const high = b.u32be(i + 8);
|
|
277
|
+
const low = b.u32be(i + 12);
|
|
278
|
+
if (high !== 0) break;
|
|
279
|
+
size = low;
|
|
280
|
+
header = 16;
|
|
281
|
+
} else if (size === 0) size = to - i;
|
|
282
|
+
if (!(size >= header) || i + size > to || type === '') break;
|
|
283
|
+
out.push({ type, start: i + header, end: i + size });
|
|
284
|
+
i += size;
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isoMedia(b) {
|
|
290
|
+
const top = boxes(b, 0, b.length);
|
|
291
|
+
const ftyp = top[0];
|
|
292
|
+
if (!ftyp || ftyp.type !== 'ftyp') return unknown();
|
|
293
|
+
const brands = new Set([b.ascii(ftyp.start, 4)]);
|
|
294
|
+
for (let i = ftyp.start + 8; i + 4 <= ftyp.end; i += 4) brands.add(b.ascii(i, 4));
|
|
295
|
+
const has = (set) => [...brands].some((x) => set.has(x));
|
|
296
|
+
const format = has(AVIF_BRANDS) ? 'avif' : has(HEIC_BRANDS) ? 'heic' : has(HEIF_BRANDS) ? 'heif' : null;
|
|
297
|
+
if (!format) {
|
|
298
|
+
if (has(VIDEO_BRANDS)) return named('a video');
|
|
299
|
+
if (has(AUDIO_BRANDS)) return named('an audio file');
|
|
300
|
+
return unknown();
|
|
301
|
+
}
|
|
302
|
+
const meta = top.find((x) => x.type === 'meta');
|
|
303
|
+
if (!meta) return unknown();
|
|
304
|
+
const size = primarySize(b, meta.start + 4, meta.end);
|
|
305
|
+
return size ? image(format, size.w, size.h, size.quarter) : unknown();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function primarySize(b, from, to) {
|
|
309
|
+
const children = boxes(b, from, to);
|
|
310
|
+
const pitm = children.find((x) => x.type === 'pitm');
|
|
311
|
+
const primary = pitm ? (b.u8(pitm.start) === 0 ? b.u16be(pitm.start + 4) : b.u32be(pitm.start + 4)) : NaN;
|
|
312
|
+
const iprp = children.find((x) => x.type === 'iprp');
|
|
313
|
+
if (!iprp) return null;
|
|
314
|
+
const inner = boxes(b, iprp.start, iprp.end);
|
|
315
|
+
const ipco = inner.find((x) => x.type === 'ipco');
|
|
316
|
+
if (!ipco) return null;
|
|
317
|
+
const props = boxes(b, ipco.start, ipco.end);
|
|
318
|
+
const mine = new Set();
|
|
319
|
+
for (const ipma of inner.filter((x) => x.type === 'ipma')) {
|
|
320
|
+
const version = b.u8(ipma.start);
|
|
321
|
+
const wide = (b.u8(ipma.start + 3) & 1) === 1;
|
|
322
|
+
let i = ipma.start + 4;
|
|
323
|
+
const count = b.u32be(i);
|
|
324
|
+
i += 4;
|
|
325
|
+
for (let n = 0; n < count && i < ipma.end; n++) {
|
|
326
|
+
const item = version < 1 ? b.u16be(i) : b.u32be(i);
|
|
327
|
+
i += version < 1 ? 2 : 4;
|
|
328
|
+
const associations = b.u8(i);
|
|
329
|
+
i += 1;
|
|
330
|
+
for (let k = 0; k < associations; k++) {
|
|
331
|
+
const index = wide ? b.u16be(i) & 0x7fff : b.u8(i) & 0x7f;
|
|
332
|
+
i += wide ? 2 : 1;
|
|
333
|
+
if (item === primary) mine.add(index);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const ispeOf = (p) => ({ w: b.u32be(p.start + 4), h: b.u32be(p.start + 8) });
|
|
338
|
+
let size = null;
|
|
339
|
+
let quarter = false;
|
|
340
|
+
props.forEach((p, k) => {
|
|
341
|
+
if (!mine.has(k + 1)) return;
|
|
342
|
+
if (p.type === 'ispe') size = ispeOf(p);
|
|
343
|
+
if (p.type === 'irot') quarter = (b.u8(p.start) & 3) % 2 === 1;
|
|
344
|
+
});
|
|
345
|
+
if (!size) {
|
|
346
|
+
for (const p of props) {
|
|
347
|
+
if (p.type !== 'ispe') continue;
|
|
348
|
+
const s = ispeOf(p);
|
|
349
|
+
if (!size || s.w * s.h > size.w * size.h) size = s;
|
|
350
|
+
}
|
|
351
|
+
if (!size) return null;
|
|
352
|
+
quarter = props.some((p) => p.type === 'irot' && (b.u8(p.start) & 3) % 2 === 1);
|
|
353
|
+
}
|
|
354
|
+
return { w: size.w, h: size.h, quarter };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function otherType(b) {
|
|
358
|
+
if (b.ascii(0, 5) === '%PDF-') return 'a PDF';
|
|
359
|
+
if (b.starts(0x50, 0x4b, 0x03, 0x04) || b.starts(0x50, 0x4b, 0x05, 0x06)) return 'a ZIP file';
|
|
360
|
+
if (b.ascii(0, 2) === 'MZ' || b.starts(0x7f, 0x45, 0x4c, 0x46) || isMachO(b) || b.ascii(0, 2) === '#!') return 'a program';
|
|
361
|
+
if (b.starts(0x1a, 0x45, 0xdf, 0xa3)) return 'a video';
|
|
362
|
+
if (b.ascii(0, 4) === 'OggS' || b.ascii(0, 4) === 'fLaC' || b.ascii(0, 3) === 'ID3') return 'an audio file';
|
|
363
|
+
if (b.starts(0x49, 0x49, 0x2a, 0x00) || b.starts(0x4d, 0x4d, 0x00, 0x2a)) return 'a TIFF or camera raw image';
|
|
364
|
+
if (b.ascii(0, 4) === '8BPS') return 'a Photoshop file';
|
|
365
|
+
if (b.starts(0xff, 0x0a) || (b.u32be(0) === 12 && b.ascii(4, 4) === 'JXL ')) return 'a JPEG XL image';
|
|
366
|
+
if (b.ascii(0, 2) === 'BM' && b.u32le(2) > 26 && b.u16le(26) === 1) return 'a BMP image';
|
|
367
|
+
if (b.starts(0x00, 0x00, 0x01, 0x00) && b.u16le(4) > 0) return 'an icon file';
|
|
368
|
+
if (b.ascii(0, 4) === 'Rar!' || b.starts(0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c) || b.starts(0x1f, 0x8b)) return 'a compressed file';
|
|
369
|
+
if (b.starts(0xd0, 0xcf, 0x11, 0xe0)) return 'an Office document';
|
|
370
|
+
if (b.ascii(0, 5) === '{\\rtf') return 'a document';
|
|
371
|
+
if (b.u8(0) === 0xff && (b.u8(1) & 0xe0) === 0xe0) return 'an audio file';
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isMachO(b) {
|
|
376
|
+
const m = b.u32be(0);
|
|
377
|
+
if (m === 0xfeedface || m === 0xfeedfacf || m === 0xcefaedfe || m === 0xcffaedfe) return true;
|
|
378
|
+
return m === 0xcafebabe && b.u32be(4) > 0 && b.u32be(4) < 20;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** An SVG's size, its root element's attributes read by hand: never parsed as XML. */
|
|
382
|
+
function svg(data) {
|
|
383
|
+
const head = data.subarray(0, Math.min(data.length, 1_000_000));
|
|
384
|
+
if (head.includes(0)) return unknown();
|
|
385
|
+
let s = new TextDecoder('utf-8', { fatal: false }).decode(head);
|
|
386
|
+
if (s.charCodeAt(0) === 0xfeff) s = s.slice(1);
|
|
387
|
+
let i = 0;
|
|
388
|
+
const skipSpace = () => {
|
|
389
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
390
|
+
};
|
|
391
|
+
for (let guard = 0; guard < 10_000; guard++) {
|
|
392
|
+
skipSpace();
|
|
393
|
+
if (s.startsWith('<?', i)) {
|
|
394
|
+
const end = s.indexOf('?>', i + 2);
|
|
395
|
+
if (end < 0) return unknown();
|
|
396
|
+
i = end + 2;
|
|
397
|
+
} else if (s.startsWith('<!--', i)) {
|
|
398
|
+
const end = s.indexOf('-->', i + 4);
|
|
399
|
+
if (end < 0) return unknown();
|
|
400
|
+
i = end + 3;
|
|
401
|
+
} else if (s.slice(i, i + 9).toUpperCase() === '<!DOCTYPE') {
|
|
402
|
+
let depth = 0;
|
|
403
|
+
let j = i + 9;
|
|
404
|
+
for (; j < s.length; j++) {
|
|
405
|
+
const c = s[j];
|
|
406
|
+
if (c === '[') depth++;
|
|
407
|
+
else if (c === ']') depth--;
|
|
408
|
+
else if (c === '>' && depth <= 0) break;
|
|
409
|
+
}
|
|
410
|
+
if (j >= s.length) return unknown();
|
|
411
|
+
if (/^<!DOCTYPE\s+html/i.test(s.slice(i, i + 20))) return named('a web page');
|
|
412
|
+
i = j + 1;
|
|
413
|
+
} else break;
|
|
414
|
+
}
|
|
415
|
+
const root = /^<((?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*))(?=[\s/>])/.exec(s.slice(i, i + 256));
|
|
416
|
+
if (!root) return unknown();
|
|
417
|
+
if (root[2].toLowerCase() === 'html') return named('a web page');
|
|
418
|
+
if (root[2] !== 'svg') return unknown();
|
|
419
|
+
const attrs = {};
|
|
420
|
+
const re = /\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
|
|
421
|
+
let j = i + root[0].length;
|
|
422
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
423
|
+
re.lastIndex = j;
|
|
424
|
+
const m = re.exec(s);
|
|
425
|
+
if (!m) break;
|
|
426
|
+
attrs[m[1]] = m[2] ?? m[3] ?? '';
|
|
427
|
+
j = re.lastIndex;
|
|
428
|
+
}
|
|
429
|
+
if (!/^\s*\/?>/.test(s.slice(j, j + 64))) return unknown();
|
|
430
|
+
const viewBox = attrs.viewBox?.trim().split(/[\s,]+/).map(Number);
|
|
431
|
+
if (viewBox && viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
|
|
432
|
+
return image('svg', Math.max(1, Math.round(viewBox[2])), Math.max(1, Math.round(viewBox[3])));
|
|
433
|
+
}
|
|
434
|
+
const px = (v) => {
|
|
435
|
+
const m = v?.trim().match(/^(\d+(?:\.\d+)?)(?:px)?$/);
|
|
436
|
+
return m ? Math.max(1, Math.round(Number(m[1]))) : NaN;
|
|
437
|
+
};
|
|
438
|
+
const w = px(attrs.width);
|
|
439
|
+
const h = px(attrs.height);
|
|
440
|
+
if (w > 0 && h > 0) return image('svg', w, h);
|
|
441
|
+
return { ok: false, code: 'no-size', message: SVG_NO_SIZE_MESSAGE };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── The credentials ────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
/** `KEY=value` lines of a `.env` (an `export ` prefix, quotes and `#` comments allowed). */
|
|
447
|
+
export function parseDotenv(text) {
|
|
448
|
+
const out = {};
|
|
449
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
450
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
451
|
+
if (!m) continue;
|
|
452
|
+
let v = m[2].trim();
|
|
453
|
+
const q = v[0];
|
|
454
|
+
if ((q === '"' || q === "'") && v.indexOf(q, 1) > 0) v = v.slice(1, v.indexOf(q, 1));
|
|
455
|
+
else v = v.replace(/\s+#.*$/, '');
|
|
456
|
+
out[m[1]] = v;
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The five `S3_*` values: the environment's, else `.env`'s in `cwd`. Names what's missing. */
|
|
462
|
+
export function readS3Env(env, cwd) {
|
|
463
|
+
let file = null;
|
|
464
|
+
const fromFile = () => {
|
|
465
|
+
if (file) return file;
|
|
466
|
+
try {
|
|
467
|
+
file = parseDotenv(readFileSync(join(cwd, '.env'), 'utf8'));
|
|
468
|
+
} catch {
|
|
469
|
+
file = {};
|
|
470
|
+
}
|
|
471
|
+
return file;
|
|
472
|
+
};
|
|
473
|
+
const values = {};
|
|
474
|
+
const missing = [];
|
|
475
|
+
for (const name of ENV_NAMES) {
|
|
476
|
+
const v = env[name] || fromFile()[name];
|
|
477
|
+
if (v) values[name] = v;
|
|
478
|
+
else missing.push(name);
|
|
479
|
+
}
|
|
480
|
+
return { values, missing };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── S3 (SigV4 by hand, path style) ─────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
|
|
486
|
+
const encodePath = (key) => key.split('/').map((p) => encodeURIComponent(p)).join('/');
|
|
487
|
+
const rfc3986 = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Sign one request: the URL to fetch and its headers, `authorization` included.
|
|
491
|
+
* `now` is for the test that checks it against another signer.
|
|
492
|
+
*
|
|
493
|
+
* @param {Record<string, string>} creds the five `S3_*` values
|
|
494
|
+
* @param {{ method: string, key?: string, query?: Record<string, string>, body?: Uint8Array, headers?: Record<string, string>, now?: Date }} request
|
|
495
|
+
* @returns {{ url: string, headers: Record<string, string> }}
|
|
496
|
+
*/
|
|
497
|
+
export function signS3(creds, { method, key = '', query = {}, body, headers = {}, now = new Date() }) {
|
|
498
|
+
const endpoint = creds.S3_ENDPOINT.replace(/\/+$/, '');
|
|
499
|
+
const url = new URL(`${endpoint}/${encodeURIComponent(creds.S3_BUCKET)}${key ? `/${encodePath(key)}` : ''}`);
|
|
500
|
+
const qs = Object.keys(query).sort().map((k) => `${rfc3986(k)}=${rfc3986(query[k])}`).join('&');
|
|
501
|
+
const stamp = now.toISOString().replace(/[-:]|\.\d{3}/g, '');
|
|
502
|
+
const day = stamp.slice(0, 8);
|
|
503
|
+
const payloadHash = sha256hex(body ?? '');
|
|
504
|
+
const all = { ...headers, host: url.host, 'x-amz-content-sha256': payloadHash, 'x-amz-date': stamp };
|
|
505
|
+
const lower = Object.fromEntries(Object.entries(all).map(([k, v]) => [k.toLowerCase(), String(v)]));
|
|
506
|
+
const names = Object.keys(lower).sort();
|
|
507
|
+
const canonical = [method, url.pathname, qs, names.map((n) => `${n}:${lower[n].trim()}\n`).join(''), names.join(';'), payloadHash].join('\n');
|
|
508
|
+
const scope = `${day}/${creds.S3_REGION}/s3/aws4_request`;
|
|
509
|
+
const toSign = ['AWS4-HMAC-SHA256', stamp, scope, sha256hex(canonical)].join('\n');
|
|
510
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${creds.S3_SECRET_ACCESS_KEY}`, day), creds.S3_REGION), 's3'), 'aws4_request');
|
|
511
|
+
const signature = createHmac('sha256', signingKey).update(toSign).digest('hex');
|
|
512
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${creds.S3_ACCESS_KEY_ID}/${scope}, SignedHeaders=${names.join(';')}, Signature=${signature}`;
|
|
513
|
+
const { host: _host, ...sent } = lower;
|
|
514
|
+
return { url: `${url.origin}${url.pathname}${qs ? `?${qs}` : ''}`, headers: { ...sent, authorization } };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** One signed request. Only GET (a listing), HEAD and PUT: nothing here deletes. */
|
|
518
|
+
async function s3(creds, method, key, { query, body, headers } = {}) {
|
|
519
|
+
if (method !== 'GET' && method !== 'HEAD' && method !== 'PUT') throw new Error(`No ${method} here.`);
|
|
520
|
+
const signed = signS3(creds, { method, key, query, body, headers });
|
|
521
|
+
const res = await fetch(signed.url, { method, headers: signed.headers, body: body ? new Uint8Array(body) : undefined, signal: AbortSignal.timeout(60_000) });
|
|
522
|
+
const text = method === 'HEAD' ? '' : await res.text();
|
|
523
|
+
return { res, text };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const unxml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
527
|
+
|
|
528
|
+
/** The first key under a prefix, as the route's `bucket.first`. */
|
|
529
|
+
async function first(creds, prefix) {
|
|
530
|
+
const { res, text } = await s3(creds, 'GET', '', { query: { 'list-type': '2', prefix, 'max-keys': '1' } });
|
|
531
|
+
if (!res.ok) throw new Error(`listing ${prefix} failed: ${res.status} ${text.slice(0, 300)}`);
|
|
532
|
+
const m = /<Contents>[\s\S]*?<Key>([\s\S]*?)<\/Key>/.exec(text);
|
|
533
|
+
return m ? unxml(m[1]) : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** An existing object's stored `w` and `h`, when it has them. */
|
|
537
|
+
async function storedSize(creds, key) {
|
|
538
|
+
const { res } = await s3(creds, 'HEAD', key);
|
|
539
|
+
if (!res.ok) return null;
|
|
540
|
+
const num = (v) => (v != null && /^\d+$/.test(v) ? Number(v) : null);
|
|
541
|
+
const w = num(res.headers.get('x-amz-meta-w'));
|
|
542
|
+
const h = num(res.headers.get('x-amz-meta-h'));
|
|
543
|
+
return w && h ? { w, h } : null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── The command ────────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* `caracms media …`. `argv` is what follows `media`; returns the exit code.
|
|
550
|
+
* `io` is for tests: the environment, the working directory and the two streams.
|
|
551
|
+
*/
|
|
552
|
+
export async function run(argv, io = {}) {
|
|
553
|
+
const env = io.env ?? process.env;
|
|
554
|
+
const cwd = io.cwd ?? process.cwd();
|
|
555
|
+
const out = io.stdout ?? process.stdout;
|
|
556
|
+
const err = io.stderr ?? process.stderr;
|
|
557
|
+
const say = (line) => err.write(`${line}\n`);
|
|
558
|
+
|
|
559
|
+
const [sub, ...rest] = argv;
|
|
560
|
+
if (sub === 'help' || sub === '--help' || sub === '-h') {
|
|
561
|
+
out.write(`${USAGE}\n`);
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
if (sub !== 'import') {
|
|
565
|
+
say(sub ? `Unknown media command: ${sub}` : 'Which media command?');
|
|
566
|
+
say(USAGE);
|
|
567
|
+
return 2;
|
|
568
|
+
}
|
|
569
|
+
const files = rest.filter((a) => a !== '--');
|
|
570
|
+
const flag = files.find((a) => a.startsWith('-'));
|
|
571
|
+
if (flag) {
|
|
572
|
+
say(`Unknown option: ${flag}`);
|
|
573
|
+
say(USAGE);
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
if (files.length === 0) {
|
|
577
|
+
say('No files given.');
|
|
578
|
+
say(USAGE);
|
|
579
|
+
return 2;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const { values: creds, missing } = readS3Env(env, cwd);
|
|
583
|
+
if (missing.length > 0) {
|
|
584
|
+
say(`Missing ${missing.join(', ')}: set ${missing.length === 1 ? 'it' : 'them'} in the environment or in this site's .env.`);
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Every file read and checked before anything is written.
|
|
589
|
+
const items = [];
|
|
590
|
+
let bad = 0;
|
|
591
|
+
for (const file of files) {
|
|
592
|
+
let bytes;
|
|
593
|
+
try {
|
|
594
|
+
bytes = await readFile(resolve(cwd, file));
|
|
595
|
+
} catch (error) {
|
|
596
|
+
say(`${file}: can't read it (${error?.code ?? error?.message ?? error}).`);
|
|
597
|
+
bad++;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (bytes.byteLength === 0) {
|
|
601
|
+
say(`${file}: it's empty.`);
|
|
602
|
+
bad++;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (bytes.byteLength > MAX_BYTES) {
|
|
606
|
+
say(`${file}: it's ${(bytes.byteLength / 1e6).toFixed(1)} MB. The library takes up to ${MAX_BYTES / 1e6} MB.`);
|
|
607
|
+
bad++;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const keyed = keyFor(bytes, basename(file));
|
|
611
|
+
if (!keyed.ok) {
|
|
612
|
+
say(`${file}: ${keyed.message}`);
|
|
613
|
+
bad++;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const { sniffed } = keyed;
|
|
617
|
+
if (sniffed.format !== 'svg' && sniffed.w * sniffed.h > MAX_PIXELS) {
|
|
618
|
+
say(`${file}: it's ${(sniffed.w * sniffed.h / 1e6).toFixed(1)} megapixels. The library takes up to ${MAX_PIXELS / 1e6}.`);
|
|
619
|
+
bad++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
items.push({ file, bytes, ...keyed });
|
|
623
|
+
}
|
|
624
|
+
if (bad > 0) {
|
|
625
|
+
say(`Nothing imported: ${bad} of ${files.length} file${files.length === 1 ? '' : 's'} can't go in.`);
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const results = [];
|
|
630
|
+
try {
|
|
631
|
+
for (const item of items) {
|
|
632
|
+
const { file, bytes, key, hash, sniffed } = item;
|
|
633
|
+
const existing = await first(creds, `uploads/${hash}/`);
|
|
634
|
+
if (existing) {
|
|
635
|
+
const size = (await storedSize(creds, existing)) ?? { w: sniffed.w, h: sniffed.h };
|
|
636
|
+
results.push({ file, key: existing, w: size.w, h: size.h, existed: true });
|
|
637
|
+
say(`${file}: already there, ${existing}`);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const name = cleanName(basename(file)) || key.slice(key.lastIndexOf('/') + 1);
|
|
641
|
+
const { res, text } = await s3(creds, 'PUT', key, {
|
|
642
|
+
body: bytes,
|
|
643
|
+
headers: {
|
|
644
|
+
'content-type': sniffed.mime,
|
|
645
|
+
'x-amz-meta-w': String(sniffed.w),
|
|
646
|
+
'x-amz-meta-h': String(sniffed.h),
|
|
647
|
+
'x-amz-meta-name': encodeURIComponent(name),
|
|
648
|
+
'x-amz-meta-by': 'import',
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
if (!res.ok) throw new Error(`writing ${key} failed: ${res.status} ${text.slice(0, 300)}`);
|
|
652
|
+
results.push({ file, key, w: sniffed.w, h: sniffed.h, existed: false });
|
|
653
|
+
say(`${file}: ${key} (${sniffed.w}×${sniffed.h})`);
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
say(`The bucket refused or didn't answer: ${error?.message ?? error}`);
|
|
657
|
+
if (results.length > 0) say(`These went in before it stopped: ${JSON.stringify(results)}`);
|
|
658
|
+
return 1;
|
|
659
|
+
}
|
|
660
|
+
out.write(`${JSON.stringify(results, null, 2)}\n`);
|
|
661
|
+
return 0;
|
|
662
|
+
}
|