diff --git a/src/lib/cp.ts b/src/lib/cp.ts index 879bc80..ff6f414 100644 --- a/src/lib/cp.ts +++ b/src/lib/cp.ts @@ -3,6 +3,7 @@ import { get, head, list, put } from '@tigrisdata/storage'; import { executeWithConcurrency } from '@utils/concurrency.js'; import { exitWithError } from '@utils/exit.js'; import { formatSize } from '@utils/format.js'; +import { getContentType } from '@utils/mime.js'; import { getFormat, getOption } from '@utils/options.js'; import { globToRegex, @@ -113,8 +114,11 @@ async function uploadFile( const fileStream = createReadStream(localPath); const body = Readable.toWeb(fileStream) as ReadableStream; + const contentType = getContentType(localPath); + const { error: putError } = await put(key, body, { ...calculateUploadParams(fileSize), + ...(contentType ? { contentType } : {}), onUploadProgress: showProgress ? ({ loaded }) => { if (fileSize !== undefined && fileSize > 0) { @@ -224,16 +228,17 @@ async function copyObject( return {}; } - let fileSize: number | undefined; - if (showProgress) { - const { data: headData } = await head(srcKey, { - config: { - ...config, - bucket: srcBucket, - }, - }); - fileSize = headData?.size; - } + // head() is unconditional now: we need the source's Content-Type + // to propagate it to the destination so a remote→remote copy + // doesn't strip the header. + const { data: headData } = await head(srcKey, { + config: { + ...config, + bucket: srcBucket, + }, + }); + const fileSize = headData?.size; + const sourceContentType = headData?.contentType; const { data, error: getError } = await get(srcKey, 'stream', { config: { @@ -248,6 +253,7 @@ async function copyObject( const { error: putError } = await put(destKey, data, { ...calculateUploadParams(fileSize), + ...(sourceContentType ? { contentType: sourceContentType } : {}), onUploadProgress: showProgress ? ({ loaded }) => { if (fileSize !== undefined && fileSize > 0) { diff --git a/src/lib/mv.ts b/src/lib/mv.ts index b597bac..9616480 100644 --- a/src/lib/mv.ts +++ b/src/lib/mv.ts @@ -342,7 +342,9 @@ async function moveObject( return {}; } - // Get source object size for upload params and progress + // Get source object size and content-type for upload params and + // header propagation. Without this, a remote→remote move would + // strip the source's Content-Type. const { data: headData } = await head(srcKey, { config: { ...config, @@ -350,6 +352,7 @@ async function moveObject( }, }); const fileSize = headData?.size; + const sourceContentType = headData?.contentType; // Get source object const { data, error: getError } = await get(srcKey, 'stream', { @@ -366,6 +369,7 @@ async function moveObject( // Put to destination const { error: putError } = await put(destKey, data, { ...calculateUploadParams(fileSize), + ...(sourceContentType ? { contentType: sourceContentType } : {}), onUploadProgress: showProgress ? ({ loaded }) => { if (fileSize !== undefined && fileSize > 0) { diff --git a/src/lib/objects/put.ts b/src/lib/objects/put.ts index 8ed5cdf..e5616d0 100644 --- a/src/lib/objects/put.ts +++ b/src/lib/objects/put.ts @@ -3,6 +3,7 @@ import { put } from '@tigrisdata/storage'; import { failWithError, printNextActions } from '@utils/exit.js'; import { formatOutput, formatSize } from '@utils/format.js'; import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getContentType } from '@utils/mime.js'; import { getFormat, getOption } from '@utils/options.js'; import { resolveObjectArgs } from '@utils/path.js'; import { calculateUploadParams } from '@utils/upload.js'; @@ -71,9 +72,15 @@ export default async function putObject(options: Record) { ? calculateUploadParams(fileSize) : { multipart: true, partSize: 5 * 1024 * 1024, queueSize: 8 }; + // --content-type wins; otherwise infer from the file extension when + // we have a path. Stdin uploads have no extension to infer from, so + // we leave it unset and let the server default apply. + const resolvedContentType = + contentType ?? (file ? getContentType(file) : undefined); + const { data, error } = await put(key, body, { access: access === 'public' ? 'public' : 'private', - contentType, + contentType: resolvedContentType, ...uploadParams, onUploadProgress: ({ loaded, percentage }) => { if (fileSize !== undefined && fileSize > 0) { diff --git a/src/utils/mime.ts b/src/utils/mime.ts new file mode 100644 index 0000000..1ee3402 --- /dev/null +++ b/src/utils/mime.ts @@ -0,0 +1,94 @@ +import { extname } from 'path'; + +/** + * Inline MIME table covering the file types commonly served from + * Tigris buckets. Mirrors the AWS CLI behaviour of `mimetypes.guess_type` + * by extension — extension-only, no content sniffing. Returns + * `undefined` for unknown extensions so callers omit the + * `Content-Type` header and let the server default apply (matches + * `aws s3 cp`'s behaviour, which never emits a fallback + * `application/octet-stream`). + */ +const MIME_TABLE: Record = { + // Markup / scripts + html: 'text/html', + htm: 'text/html', + css: 'text/css', + js: 'text/javascript', + mjs: 'text/javascript', + cjs: 'text/javascript', + json: 'application/json', + map: 'application/json', + xml: 'application/xml', + svg: 'image/svg+xml', + webmanifest: 'application/manifest+json', + wasm: 'application/wasm', + + // Plain text + txt: 'text/plain', + log: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + yaml: 'application/yaml', + yml: 'application/yaml', + + // Documents + pdf: 'application/pdf', + rtf: 'application/rtf', + + // Images + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + ico: 'image/x-icon', + bmp: 'image/bmp', + tif: 'image/tiff', + tiff: 'image/tiff', + + // Fonts + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + otf: 'font/otf', + eot: 'application/vnd.ms-fontobject', + + // Video + mp4: 'video/mp4', + m4v: 'video/x-m4v', + webm: 'video/webm', + mov: 'video/quicktime', + avi: 'video/x-msvideo', + mkv: 'video/x-matroska', + + // Audio + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + wav: 'audio/wav', + ogg: 'audio/ogg', + flac: 'audio/flac', + aac: 'audio/aac', + opus: 'audio/opus', + + // Archives + zip: 'application/zip', + tar: 'application/x-tar', + gz: 'application/gzip', + tgz: 'application/gzip', + bz2: 'application/x-bzip2', + '7z': 'application/x-7z-compressed', + rar: 'application/vnd.rar', +}; + +/** + * Look up a Content-Type from a file path's extension. Returns + * `undefined` when the extension is unknown — callers should omit the + * Content-Type rather than fall back to `application/octet-stream`. + */ +export function getContentType(filePath: string): string | undefined { + const ext = extname(filePath).slice(1).toLowerCase(); + if (!ext) return undefined; + return MIME_TABLE[ext]; +} diff --git a/test/utils/mime.test.ts b/test/utils/mime.test.ts new file mode 100644 index 0000000..7b06494 --- /dev/null +++ b/test/utils/mime.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { getContentType } from '../../src/utils/mime.js'; + +describe('getContentType', () => { + it('returns text/html for .html', () => { + expect(getContentType('foo.html')).toBe('text/html'); + expect(getContentType('a/b/index.html')).toBe('text/html'); + }); + + it('handles uppercase extensions (lowercases internally)', () => { + expect(getContentType('IMAGE.PNG')).toBe('image/png'); + expect(getContentType('Foo.JPG')).toBe('image/jpeg'); + }); + + it('matches the final extension only (.tar.gz → gzip)', () => { + expect(getContentType('archive.tar.gz')).toBe('application/gzip'); + }); + + it('returns text/javascript for .js / .mjs / .cjs', () => { + expect(getContentType('app.js')).toBe('text/javascript'); + expect(getContentType('app.mjs')).toBe('text/javascript'); + expect(getContentType('app.cjs')).toBe('text/javascript'); + }); + + it('returns image/svg+xml for .svg', () => { + expect(getContentType('logo.svg')).toBe('image/svg+xml'); + }); + + it('returns undefined when the extension is unknown', () => { + // AWS-CLI behavior parity: callers omit the header and let the + // server default apply rather than emitting application/octet-stream. + expect(getContentType('mystery.xyz')).toBeUndefined(); + }); + + it('returns undefined when there is no extension', () => { + expect(getContentType('Makefile')).toBeUndefined(); + expect(getContentType('binary')).toBeUndefined(); + }); + + it('returns undefined for dotfiles (no extension after the dot)', () => { + // extname('.gitignore') === '' — these are treated as no-extension. + expect(getContentType('.gitignore')).toBeUndefined(); + expect(getContentType('.env')).toBeUndefined(); + }); +});