Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions src/lib/cp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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: {
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/mv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,14 +342,17 @@ 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,
bucket: srcBucket,
},
});
const fileSize = headData?.size;
const sourceContentType = headData?.contentType;

// Get source object
const { data, error: getError } = await get(srcKey, 'stream', {
Expand All @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion src/lib/objects/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,9 +72,15 @@ export default async function putObject(options: Record<string, unknown>) {
? 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) {
Expand Down
94 changes: 94 additions & 0 deletions src/utils/mime.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
// 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];
}
46 changes: 46 additions & 0 deletions test/utils/mime.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});