Skip to content

Commit f5a9e2c

Browse files
designcodeclaude
andauthored
fix: auto-detect Content-Type on upload to match aws s3 cp behaviour (#97)
Uploads via `t3 cp`, `t3 mv`, and `t3 objects put` were always landing with `Content-Type: application/octet-stream` because we never set the header. Browsers serving an HTML object then offered it as a download instead of rendering. The AWS CLI uses Python's `mimetypes.guess_type` to derive the Content-Type from the file extension; we lacked the equivalent. - Add `src/utils/mime.ts` with an inline table covering ~50 common web/dev extensions (markup, scripts, JSON, fonts, images, audio, video, archives). `getContentType()` returns `undefined` for unknown extensions so the SDK omits the header and the server default applies — same posture as `aws s3 cp` (the AWS CLI never emits a fallback `application/octet-stream`). - `cp.ts uploadFile` (local→remote): infer Content-Type from the local path. - `cp.ts copyObject` (remote→remote): always `head()` the source and propagate `headData.contentType` to the destination put. The head call was previously gated on `showProgress`. - `mv.ts moveObject` (remote→remote): propagate `headData.contentType` the same way. - `objects/put.ts`: `--content-type` still wins; otherwise infer from the file path when one is provided. Stdin uploads leave it unset. Folder markers (zero-byte puts in cp/mv/mk/touch) are unchanged. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e59543d commit f5a9e2c

5 files changed

Lines changed: 169 additions & 12 deletions

File tree

src/lib/cp.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { get, head, list, put } from '@tigrisdata/storage';
33
import { executeWithConcurrency } from '@utils/concurrency.js';
44
import { exitWithError } from '@utils/exit.js';
55
import { formatSize } from '@utils/format.js';
6+
import { getContentType } from '@utils/mime.js';
67
import { getFormat, getOption } from '@utils/options.js';
78
import {
89
globToRegex,
@@ -113,8 +114,11 @@ async function uploadFile(
113114
const fileStream = createReadStream(localPath);
114115
const body = Readable.toWeb(fileStream) as ReadableStream;
115116

117+
const contentType = getContentType(localPath);
118+
116119
const { error: putError } = await put(key, body, {
117120
...calculateUploadParams(fileSize),
121+
...(contentType ? { contentType } : {}),
118122
onUploadProgress: showProgress
119123
? ({ loaded }) => {
120124
if (fileSize !== undefined && fileSize > 0) {
@@ -224,16 +228,17 @@ async function copyObject(
224228
return {};
225229
}
226230

227-
let fileSize: number | undefined;
228-
if (showProgress) {
229-
const { data: headData } = await head(srcKey, {
230-
config: {
231-
...config,
232-
bucket: srcBucket,
233-
},
234-
});
235-
fileSize = headData?.size;
236-
}
231+
// head() is unconditional now: we need the source's Content-Type
232+
// to propagate it to the destination so a remote→remote copy
233+
// doesn't strip the header.
234+
const { data: headData } = await head(srcKey, {
235+
config: {
236+
...config,
237+
bucket: srcBucket,
238+
},
239+
});
240+
const fileSize = headData?.size;
241+
const sourceContentType = headData?.contentType;
237242

238243
const { data, error: getError } = await get(srcKey, 'stream', {
239244
config: {
@@ -248,6 +253,7 @@ async function copyObject(
248253

249254
const { error: putError } = await put(destKey, data, {
250255
...calculateUploadParams(fileSize),
256+
...(sourceContentType ? { contentType: sourceContentType } : {}),
251257
onUploadProgress: showProgress
252258
? ({ loaded }) => {
253259
if (fileSize !== undefined && fileSize > 0) {

src/lib/mv.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,14 +342,17 @@ async function moveObject(
342342
return {};
343343
}
344344

345-
// Get source object size for upload params and progress
345+
// Get source object size and content-type for upload params and
346+
// header propagation. Without this, a remote→remote move would
347+
// strip the source's Content-Type.
346348
const { data: headData } = await head(srcKey, {
347349
config: {
348350
...config,
349351
bucket: srcBucket,
350352
},
351353
});
352354
const fileSize = headData?.size;
355+
const sourceContentType = headData?.contentType;
353356

354357
// Get source object
355358
const { data, error: getError } = await get(srcKey, 'stream', {
@@ -366,6 +369,7 @@ async function moveObject(
366369
// Put to destination
367370
const { error: putError } = await put(destKey, data, {
368371
...calculateUploadParams(fileSize),
372+
...(sourceContentType ? { contentType: sourceContentType } : {}),
369373
onUploadProgress: showProgress
370374
? ({ loaded }) => {
371375
if (fileSize !== undefined && fileSize > 0) {

src/lib/objects/put.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { put } from '@tigrisdata/storage';
33
import { failWithError, printNextActions } from '@utils/exit.js';
44
import { formatOutput, formatSize } from '@utils/format.js';
55
import { msg, printStart, printSuccess } from '@utils/messages.js';
6+
import { getContentType } from '@utils/mime.js';
67
import { getFormat, getOption } from '@utils/options.js';
78
import { resolveObjectArgs } from '@utils/path.js';
89
import { calculateUploadParams } from '@utils/upload.js';
@@ -71,9 +72,15 @@ export default async function putObject(options: Record<string, unknown>) {
7172
? calculateUploadParams(fileSize)
7273
: { multipart: true, partSize: 5 * 1024 * 1024, queueSize: 8 };
7374

75+
// --content-type wins; otherwise infer from the file extension when
76+
// we have a path. Stdin uploads have no extension to infer from, so
77+
// we leave it unset and let the server default apply.
78+
const resolvedContentType =
79+
contentType ?? (file ? getContentType(file) : undefined);
80+
7481
const { data, error } = await put(key, body, {
7582
access: access === 'public' ? 'public' : 'private',
76-
contentType,
83+
contentType: resolvedContentType,
7784
...uploadParams,
7885
onUploadProgress: ({ loaded, percentage }) => {
7986
if (fileSize !== undefined && fileSize > 0) {

src/utils/mime.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { extname } from 'path';
2+
3+
/**
4+
* Inline MIME table covering the file types commonly served from
5+
* Tigris buckets. Mirrors the AWS CLI behaviour of `mimetypes.guess_type`
6+
* by extension — extension-only, no content sniffing. Returns
7+
* `undefined` for unknown extensions so callers omit the
8+
* `Content-Type` header and let the server default apply (matches
9+
* `aws s3 cp`'s behaviour, which never emits a fallback
10+
* `application/octet-stream`).
11+
*/
12+
const MIME_TABLE: Record<string, string> = {
13+
// Markup / scripts
14+
html: 'text/html',
15+
htm: 'text/html',
16+
css: 'text/css',
17+
js: 'text/javascript',
18+
mjs: 'text/javascript',
19+
cjs: 'text/javascript',
20+
json: 'application/json',
21+
map: 'application/json',
22+
xml: 'application/xml',
23+
svg: 'image/svg+xml',
24+
webmanifest: 'application/manifest+json',
25+
wasm: 'application/wasm',
26+
27+
// Plain text
28+
txt: 'text/plain',
29+
log: 'text/plain',
30+
md: 'text/markdown',
31+
csv: 'text/csv',
32+
yaml: 'application/yaml',
33+
yml: 'application/yaml',
34+
35+
// Documents
36+
pdf: 'application/pdf',
37+
rtf: 'application/rtf',
38+
39+
// Images
40+
png: 'image/png',
41+
jpg: 'image/jpeg',
42+
jpeg: 'image/jpeg',
43+
gif: 'image/gif',
44+
webp: 'image/webp',
45+
avif: 'image/avif',
46+
ico: 'image/x-icon',
47+
bmp: 'image/bmp',
48+
tif: 'image/tiff',
49+
tiff: 'image/tiff',
50+
51+
// Fonts
52+
woff: 'font/woff',
53+
woff2: 'font/woff2',
54+
ttf: 'font/ttf',
55+
otf: 'font/otf',
56+
eot: 'application/vnd.ms-fontobject',
57+
58+
// Video
59+
mp4: 'video/mp4',
60+
m4v: 'video/x-m4v',
61+
webm: 'video/webm',
62+
mov: 'video/quicktime',
63+
avi: 'video/x-msvideo',
64+
mkv: 'video/x-matroska',
65+
66+
// Audio
67+
mp3: 'audio/mpeg',
68+
m4a: 'audio/mp4',
69+
wav: 'audio/wav',
70+
ogg: 'audio/ogg',
71+
flac: 'audio/flac',
72+
aac: 'audio/aac',
73+
opus: 'audio/opus',
74+
75+
// Archives
76+
zip: 'application/zip',
77+
tar: 'application/x-tar',
78+
gz: 'application/gzip',
79+
tgz: 'application/gzip',
80+
bz2: 'application/x-bzip2',
81+
'7z': 'application/x-7z-compressed',
82+
rar: 'application/vnd.rar',
83+
};
84+
85+
/**
86+
* Look up a Content-Type from a file path's extension. Returns
87+
* `undefined` when the extension is unknown — callers should omit the
88+
* Content-Type rather than fall back to `application/octet-stream`.
89+
*/
90+
export function getContentType(filePath: string): string | undefined {
91+
const ext = extname(filePath).slice(1).toLowerCase();
92+
if (!ext) return undefined;
93+
return MIME_TABLE[ext];
94+
}

test/utils/mime.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getContentType } from '../../src/utils/mime.js';
4+
5+
describe('getContentType', () => {
6+
it('returns text/html for .html', () => {
7+
expect(getContentType('foo.html')).toBe('text/html');
8+
expect(getContentType('a/b/index.html')).toBe('text/html');
9+
});
10+
11+
it('handles uppercase extensions (lowercases internally)', () => {
12+
expect(getContentType('IMAGE.PNG')).toBe('image/png');
13+
expect(getContentType('Foo.JPG')).toBe('image/jpeg');
14+
});
15+
16+
it('matches the final extension only (.tar.gz → gzip)', () => {
17+
expect(getContentType('archive.tar.gz')).toBe('application/gzip');
18+
});
19+
20+
it('returns text/javascript for .js / .mjs / .cjs', () => {
21+
expect(getContentType('app.js')).toBe('text/javascript');
22+
expect(getContentType('app.mjs')).toBe('text/javascript');
23+
expect(getContentType('app.cjs')).toBe('text/javascript');
24+
});
25+
26+
it('returns image/svg+xml for .svg', () => {
27+
expect(getContentType('logo.svg')).toBe('image/svg+xml');
28+
});
29+
30+
it('returns undefined when the extension is unknown', () => {
31+
// AWS-CLI behavior parity: callers omit the header and let the
32+
// server default apply rather than emitting application/octet-stream.
33+
expect(getContentType('mystery.xyz')).toBeUndefined();
34+
});
35+
36+
it('returns undefined when there is no extension', () => {
37+
expect(getContentType('Makefile')).toBeUndefined();
38+
expect(getContentType('binary')).toBeUndefined();
39+
});
40+
41+
it('returns undefined for dotfiles (no extension after the dot)', () => {
42+
// extname('.gitignore') === '' — these are treated as no-extension.
43+
expect(getContentType('.gitignore')).toBeUndefined();
44+
expect(getContentType('.env')).toBeUndefined();
45+
});
46+
});

0 commit comments

Comments
 (0)