@buildinternet/uploads 0.51.0 → 0.52.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/dist/commands.js CHANGED
@@ -4,7 +4,7 @@ import { mapBounded } from "./async.js";
4
4
  import { createUploadsClient, } from "./client.js";
5
5
  import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
6
6
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
7
- import { buildUploadMarkdown } from "./embed.js";
7
+ import { buildUploadMarkdown, fileKindFromName } from "./embed.js";
8
8
  import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
9
9
  import { urlForGithubEmbed } from "./public-urls.js";
10
10
  import { UploadsError } from "./errors.js";
@@ -110,6 +110,10 @@ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
110
110
  high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
111
111
  when they are already smaller, animated, or not an image. Use --no-optimize to
112
112
  upload as-is, or --keep-exif when image metadata matters for the discussion.
113
+ Non-media files (PDF, zip, gzip, logs, JSON, CSV, markdown) upload as-is and
114
+ show up in the managed comment as links. HTML and SVG are rejected. Uploads are
115
+ public, so scrub secrets and tokens out of logs, JSON, and other text before
116
+ uploading.
113
117
 
114
118
  Optional --frame wraps the image in a device/browser chrome before optimize
115
119
  (default off). See: uploads put --help frames
@@ -421,13 +425,23 @@ export async function prepareImageForUpload(bytes, filename, opts) {
421
425
  return { ...optimized, frame: frameMeta };
422
426
  }
423
427
  /**
424
- * Merge an image's own EXIF-derived facts under any explicit metadata.
428
+ * Merge an image's own EXIF-derived facts under any explicit metadata, when
429
+ * the caller asked for them and the filename could plausibly be an image —
430
+ * only an `image/*` extension, or none at all (an extension-less
431
+ * screenshot), is worth an `imageFactsFromBytes` probe
432
+ * (`sharp(bytes).metadata()` under the hood); a known non-image extension (a
433
+ * 25 MB zip, a `.log`, a PDF…) skips it rather than paying a sharp call that
434
+ * can only come back empty.
435
+ *
425
436
  * Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
426
437
  * budget drops the derived pairs rather than failing the upload. Returns the
427
438
  * input untouched (including `undefined`) when there is nothing to add, so a
428
439
  * metadata-free upload stays metadata-free.
429
440
  */
430
- async function mergeImageFacts(bytes, metadata) {
441
+ async function maybeDeriveImageFacts(enabled, sourceName, bytes, metadata) {
442
+ const kind = fileKindFromName(sourceName);
443
+ if (!enabled || (kind !== "image" && kind !== "unknown"))
444
+ return metadata;
431
445
  const facts = await imageFactsFromBytes(bytes);
432
446
  if (Object.keys(facts).length === 0)
433
447
  return metadata;
@@ -444,9 +458,7 @@ async function mergeImageFacts(bytes, metadata) {
444
458
  */
445
459
  export async function uploadPreparedImage(client, bytes, sourceName, opts) {
446
460
  // Read EXIF from the original bytes before the optimizer strips it.
447
- const metadata = opts.deriveImageFacts
448
- ? await mergeImageFacts(bytes, opts.metadata)
449
- : opts.metadata;
461
+ const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, opts.metadata);
450
462
  const prepared = await prepareImageForUpload(bytes, sourceName, {
451
463
  frameId: opts.frame.frameId,
452
464
  frameUrl: opts.frame.frameUrl,
@@ -955,9 +967,7 @@ async function uploadAttachmentBatch(opts) {
955
967
  const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
956
968
  // Same EXIF promotion uploadPreparedImage does; attach keeps its own
957
969
  // per-file tail (it builds keys differently), so it opts in here too.
958
- const metadata = opts.deriveImageFacts
959
- ? await mergeImageFacts(bytes, baseMetadata)
960
- : baseMetadata;
970
+ const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, baseMetadata);
961
971
  const prepared = await prepareImageForUpload(bytes, sourceName, {
962
972
  ...opts.frame,
963
973
  optimize: opts.optimize,
@@ -47,6 +47,14 @@ export declare function parseGhPrivateKey(key: string): {
47
47
  kind: GhTargetKind;
48
48
  num: number;
49
49
  } | undefined;
50
+ export declare function inferContentType(filename: string): string;
51
+ /**
52
+ * Coarse family a filename belongs to, for the callers that only branch on
53
+ * "is this an image / a video / something else" — `unknown` when the
54
+ * extension maps to nothing (an extension-less screenshot, say), which those
55
+ * callers treat as "might be an image, probe it".
56
+ */
57
+ export declare function fileKindFromName(filename: string): "image" | "video" | "file" | "unknown";
50
58
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
51
59
  export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
52
60
  /**
@@ -69,27 +69,55 @@ export function parseGhPrivateKey(key) {
69
69
  return { prefixId, kind: kind, num: Number(num) };
70
70
  }
71
71
  /** GitHub-embed helper (content type). Copied from packages/uploads/src/embed.ts. */
72
- function inferContentType(filename) {
72
+ const CONTENT_TYPE_BY_EXTENSION = {
73
+ png: "image/png",
74
+ jpg: "image/jpeg",
75
+ jpeg: "image/jpeg",
76
+ gif: "image/gif",
77
+ webp: "image/webp",
78
+ avif: "image/avif",
79
+ svg: "image/svg+xml",
80
+ mp4: "video/mp4",
81
+ webm: "video/webm",
82
+ mov: "video/quicktime",
83
+ pdf: "application/pdf",
84
+ zip: "application/zip",
85
+ gz: "application/gzip",
86
+ tgz: "application/gzip",
87
+ txt: "text/plain",
88
+ text: "text/plain",
89
+ log: "text/plain",
90
+ jsonl: "text/plain",
91
+ ndjson: "text/plain",
92
+ yaml: "text/plain",
93
+ yml: "text/plain",
94
+ md: "text/markdown",
95
+ markdown: "text/markdown",
96
+ csv: "text/csv",
97
+ json: "application/json",
98
+ xml: "application/xml",
99
+ };
100
+ export function inferContentType(filename) {
73
101
  const ext = filename.includes(".")
74
102
  ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
75
103
  : "";
76
- switch (ext) {
77
- case "png":
78
- return "image/png";
79
- case "jpg":
80
- case "jpeg":
81
- return "image/jpeg";
82
- case "gif":
83
- return "image/gif";
84
- case "webp":
85
- return "image/webp";
86
- case "svg":
87
- return "image/svg+xml";
88
- case "mp4":
89
- return "video/mp4";
90
- default:
91
- return "application/octet-stream";
92
- }
104
+ return CONTENT_TYPE_BY_EXTENSION[ext] ?? "application/octet-stream";
105
+ }
106
+ /**
107
+ * Coarse family a filename belongs to, for the callers that only branch on
108
+ * "is this an image / a video / something else" — `unknown` when the
109
+ * extension maps to nothing (an extension-less screenshot, say), which those
110
+ * callers treat as "might be an image, probe it".
111
+ */
112
+ export function fileKindFromName(filename) {
113
+ const type = inferContentType(filename);
114
+ if (type === "application/octet-stream")
115
+ return "unknown";
116
+ if (type.startsWith("image/"))
117
+ return "image";
118
+ if (type.startsWith("video/"))
119
+ return "video";
120
+ return "file";
93
121
  }
94
122
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
95
123
  export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
package/dist/embed.d.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  /** GitHub-embed helpers (content type + markdown). */
2
- export declare function inferContentType(filename: string): string;
2
+ /**
3
+ * The filename→type map and its two readers live in
4
+ * packages/comment-render/src/index.ts (inlined here as
5
+ * `comment-render.generated.ts`), so the CLI and the managed-comment renderer
6
+ * cannot drift apart. Re-exported from this module because every existing
7
+ * caller imports them from `./embed.js`. That map mirrors the upload table in
8
+ * apps/api/src/guards.ts; keep the two in step. `svg` maps to
9
+ * `image/svg+xml` here same as any other type (issue #929): the server no
10
+ * longer rejects it outright — it accepts SVG only on a storage lane
11
+ * verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
12
+ * so whether a given upload actually lands depends on that lane's state,
13
+ * not on anything this map decides.
14
+ */
15
+ export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
3
16
  export declare function buildMarkdown(url: string, opts: {
4
17
  alt: string;
5
18
  width?: number;
package/dist/embed.js CHANGED
@@ -1,26 +1,18 @@
1
1
  /** GitHub-embed helpers (content type + markdown). */
2
- export function inferContentType(filename) {
3
- const ext = filename.includes(".")
4
- ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
5
- : "";
6
- switch (ext) {
7
- case "png":
8
- return "image/png";
9
- case "jpg":
10
- case "jpeg":
11
- return "image/jpeg";
12
- case "gif":
13
- return "image/gif";
14
- case "webp":
15
- return "image/webp";
16
- case "svg":
17
- return "image/svg+xml";
18
- case "mp4":
19
- return "video/mp4";
20
- default:
21
- return "application/octet-stream";
22
- }
23
- }
2
+ /**
3
+ * The filename→type map and its two readers live in
4
+ * packages/comment-render/src/index.ts (inlined here as
5
+ * `comment-render.generated.ts`), so the CLI and the managed-comment renderer
6
+ * cannot drift apart. Re-exported from this module because every existing
7
+ * caller imports them from `./embed.js`. That map mirrors the upload table in
8
+ * apps/api/src/guards.ts; keep the two in step. `svg` maps to
9
+ * `image/svg+xml` here same as any other type (issue #929): the server no
10
+ * longer rejects it outright — it accepts SVG only on a storage lane
11
+ * verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
12
+ * so whether a given upload actually lands depends on that lane's state,
13
+ * not on anything this map decides.
14
+ */
15
+ export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
24
16
  export function buildMarkdown(url, opts) {
25
17
  if (opts.width) {
26
18
  const alt = opts.alt.replace(/"/g, "&quot;");
package/dist/mcp/tools.js CHANGED
@@ -292,7 +292,7 @@ export function createUploadsMcpTools(opts) {
292
292
  title: "Upload file",
293
293
  annotations: mcpDestroyPublic,
294
294
  securitySchemes: mcpOAuthWrite,
295
- description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
295
+ description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public. Accepts images (PNG, JPEG, GIF, WebP, AVIF), video (MP4, WebM, MOV), PDF, zip, gzip, and text (plain, markdown, CSV, JSON). SVG and XML are accepted only on storage lanes verified to serve them sandboxed (see the workspace's storage settings); HTML is rejected.",
296
296
  inputSchema: {
297
297
  type: "object",
298
298
  properties: {
package/dist/optimize.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * SVG, video, and non-images are left unchanged.
7
7
  */
8
8
  import sharp from "sharp";
9
+ import { fileKindFromName, inferContentType } from "./embed.js";
9
10
  /** Longest edge in pixels (screenshots beyond this rarely help PR review). */
10
11
  export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
11
12
  /** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
@@ -68,11 +69,25 @@ function passthrough(bytes, filename, contentType, skippedReason) {
68
69
  */
69
70
  export async function optimizeImageForUpload(bytes, filename, opts = {}) {
70
71
  const originalBytes = bytes.byteLength;
72
+ // The filename's own claim, used by every passthrough exit below.
73
+ const guessed = inferContentType(filename);
71
74
  if (opts.enabled === false) {
72
- return passthrough(bytes, filename, guessContentType(filename), "disabled");
75
+ return passthrough(bytes, filename, guessed, "disabled");
73
76
  }
74
77
  if (originalBytes === 0) {
75
- return passthrough(bytes, filename, guessContentType(filename), "empty");
78
+ return passthrough(bytes, filename, guessed, "empty");
79
+ }
80
+ // A known non-image extension (log, pdf, zip, video…) never needs sharp —
81
+ // and must be checked before `looksLikeSvg` below, since that check sniffs
82
+ // the leading bytes for an XML/SVG prologue with no regard for the actual
83
+ // extension: a `.log` or `.json` file that happens to start with `<?xml`
84
+ // (a JUnit report, say) would otherwise misreport as `skippedReason: "svg"`
85
+ // / `image/svg+xml`. An unknown extension is still "might be an image": it
86
+ // falls through to the SVG sniff and the sharp probe below, so an
87
+ // extension-less screenshot is optimized as before.
88
+ const kind = fileKindFromName(filename);
89
+ if (kind === "file" || kind === "video") {
90
+ return passthrough(bytes, filename, guessed, "not_image");
76
91
  }
77
92
  if (looksLikeSvg(bytes, filename)) {
78
93
  return passthrough(bytes, filename, "image/svg+xml", "svg");
@@ -92,14 +107,14 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
92
107
  meta = await image.metadata();
93
108
  }
94
109
  catch {
95
- return passthrough(bytes, filename, guessContentType(filename), "not_image");
110
+ return passthrough(bytes, filename, guessed, "not_image");
96
111
  }
97
112
  if (!meta.format) {
98
- return passthrough(bytes, filename, guessContentType(filename), "not_image");
113
+ return passthrough(bytes, filename, guessed, "not_image");
99
114
  }
100
115
  // Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
101
116
  if ((meta.pages ?? 1) > 1) {
102
- return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessContentType(filename), "animated");
117
+ return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessed, "animated");
103
118
  }
104
119
  if (meta.format === "gif") {
105
120
  // Single-frame GIF can convert; multi-page already returned above.
@@ -134,10 +149,10 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
134
149
  }
135
150
  }
136
151
  catch {
137
- return passthrough(bytes, filename, guessContentType(filename), "encode_failed");
152
+ return passthrough(bytes, filename, guessed, "encode_failed");
138
153
  }
139
154
  if (encoded.byteLength >= originalBytes) {
140
- return passthrough(bytes, filename, guessContentType(filename), "not_smaller");
155
+ return passthrough(bytes, filename, guessed, "not_smaller");
141
156
  }
142
157
  const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
143
158
  return {
@@ -149,25 +164,6 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
149
164
  outputBytes: encoded.byteLength,
150
165
  };
151
166
  }
152
- function guessContentType(filename) {
153
- switch (extensionOf(filename)) {
154
- case "png":
155
- return "image/png";
156
- case "jpg":
157
- case "jpeg":
158
- return "image/jpeg";
159
- case "gif":
160
- return "image/gif";
161
- case "webp":
162
- return "image/webp";
163
- case "avif":
164
- return "image/avif";
165
- case "svg":
166
- return "image/svg+xml";
167
- default:
168
- return "application/octet-stream";
169
- }
170
- }
171
167
  /** Rewrite an object key's trailing image extension to match optimized output. */
172
168
  export function rewriteKeyExtension(key, filename) {
173
169
  const ext = extensionOf(filename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "mcpName": "sh.uploads/mcp",
5
5
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
6
6
  "type": "module",