Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
23 changes: 23 additions & 0 deletions src/commands/storage/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { resolveUploadContentType } from './upload.js';

describe('resolveUploadContentType', () => {
it('uses an explicit --content-type over inference', () => {
expect(resolveUploadContentType('photo.png', 'image/webp')).toBe('image/webp');
});

it('infers from the file extension when no flag is given', () => {
expect(resolveUploadContentType('photo.png')).toBe('image/png');
expect(resolveUploadContentType('/tmp/report.pdf')).toBe('application/pdf');
});

it('treats an empty or whitespace flag as not provided', () => {
expect(resolveUploadContentType('photo.png', '')).toBe('image/png');
expect(resolveUploadContentType('photo.png', ' ')).toBe('image/png');
});

it('falls back to octet-stream for an unknown extension', () => {
expect(resolveUploadContentType('archive.unknownext')).toBe('application/octet-stream');
expect(resolveUploadContentType('noext')).toBe('application/octet-stream');
});
});
21 changes: 19 additions & 2 deletions src/commands/storage/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ import { getProjectConfig } from '../../lib/config.js';
import { requireAuth } from '../../lib/credentials.js';
import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js';
import { outputJson, outputSuccess } from '../../lib/output.js';
import { mimeTypeFromName } from '../../lib/mime.js';

/**
* Resolve the content type for an upload: an explicit (non-empty) `--content-type`
* wins, otherwise infer from the file extension, otherwise fall back to the
* storage default. Exported for testing.
*/
export function resolveUploadContentType(filePath: string, explicit?: string): string {
return explicit?.trim() || mimeTypeFromName(filePath) || 'application/octet-stream';
}

export function registerStorageUploadCommand(storageCmd: Command): void {
storageCmd
.command('upload <file>')
.description('Upload a file to a storage bucket')
.requiredOption('--bucket <name>', 'Target bucket name')
.option('--key <objectKey>', 'Object key (defaults to filename)')
.option('--content-type <type>', 'MIME type to store (defaults to one inferred from the file extension)')
.action(async (file: string, opts, cmd) => {
const { json } = getRootOpts(cmd);
try {
Expand All @@ -28,9 +39,15 @@ export function registerStorageUploadCommand(storageCmd: Command): void {
const objectKey = opts.key ?? basename(file);
const bucketName = opts.bucket;

// Build multipart form data
// Resolve the content type: explicit flag wins, then infer from the
// file's extension. Without this a typeless Blob is stored as
// application/octet-stream regardless of the actual file type.
const contentType = resolveUploadContentType(file, opts.contentType);

// Build multipart form data. The Blob's type becomes the multipart
// part's Content-Type, which the backend stores as the object's MIME type.
const formData = new FormData();
const blob = new Blob([fileContent]);
const blob = new Blob([fileContent], { type: contentType });
formData.append('file', blob, objectKey);

// PUT /api/storage/buckets/{bucket}/objects/{key} for named upload
Expand Down
31 changes: 31 additions & 0 deletions src/lib/mime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { mimeTypeFromName } from './mime.js';

describe('mimeTypeFromName', () => {
it('infers common types from the extension', () => {
expect(mimeTypeFromName('photo.png')).toBe('image/png');
expect(mimeTypeFromName('a.jpeg')).toBe('image/jpeg');
expect(mimeTypeFromName('report.pdf')).toBe('application/pdf');
expect(mimeTypeFromName('data.json')).toBe('application/json');
expect(mimeTypeFromName('clip.mp4')).toBe('video/mp4');
});

it('is case-insensitive and handles full paths', () => {
expect(mimeTypeFromName('IMG.PNG')).toBe('image/png');
expect(mimeTypeFromName('/tmp/sub dir/cover.JPG')).toBe('image/jpeg');
expect(mimeTypeFromName('archive.tar.gz')).toBe('application/gzip');
});

it('ignores dots in parent directories (basename only)', () => {
expect(mimeTypeFromName('/home/user.name/photo.png')).toBe('image/png');
expect(mimeTypeFromName('/home/user.name/datafile')).toBeUndefined();
expect(mimeTypeFromName('C:\\Users\\a.b\\report.pdf')).toBe('application/pdf');
});

it('returns undefined for unknown or missing extensions', () => {
expect(mimeTypeFromName('file.unknownext')).toBeUndefined();
expect(mimeTypeFromName('noext')).toBeUndefined();
expect(mimeTypeFromName('trailingdot.')).toBeUndefined();
expect(mimeTypeFromName('.gitignore')).toBeUndefined();
});
});
85 changes: 85 additions & 0 deletions src/lib/mime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Minimal extension → MIME type lookup for storage uploads.
*
* The CLI reads raw bytes off disk, so unlike a browser `File` there is no
* `type` to read — without inference every upload would be stored as
* `application/octet-stream`. We infer from the filename extension (the same
* approach `aws s3 cp` uses). Unknown extensions fall back to octet-stream,
* which is the server's default anyway, so this is strictly an improvement.
*/

const EXTENSION_MIME_TYPES: Record<string, string> = {
// Images
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
bmp: 'image/bmp',
ico: 'image/x-icon',
avif: 'image/avif',
heic: 'image/heic',
heif: 'image/heif',
tif: 'image/tiff',
tiff: 'image/tiff',
// Documents
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text / data
txt: 'text/plain',
csv: 'text/csv',
md: 'text/markdown',
html: 'text/html',
htm: 'text/html',
css: 'text/css',
js: 'text/javascript',
mjs: 'text/javascript',
json: 'application/json',
xml: 'application/xml',
yaml: 'application/yaml',
yml: 'application/yaml',
// Archives
zip: 'application/zip',
gz: 'application/gzip',
tar: 'application/x-tar',
rar: 'application/vnd.rar',
'7z': 'application/x-7z-compressed',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
flac: 'audio/flac',
aac: 'audio/aac',
// Video
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
// Fonts
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
};

/**
* Infer a MIME type from a filename/path extension. Returns `undefined` when the
* extension is missing or unrecognized.
*/
export function mimeTypeFromName(name: string): string | undefined {
// Strip any directory portion first so a dot in a parent directory
// (e.g. "/home/user.name/datafile") can't be mistaken for an extension.
const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1);
const lastDot = base.lastIndexOf('.');
if (lastDot < 0 || lastDot === base.length - 1) return undefined;
const ext = base.slice(lastDot + 1).toLowerCase();
return EXTENSION_MIME_TYPES[ext];
}
Loading