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
100 changes: 100 additions & 0 deletions apps/backend/src/app/api/branding/upload/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,104 @@ describe('POST /api/branding/upload', () => {
expect(res.status).toBe(422);
expect((await res.json()).code).toBe('UNSAFE_SVG');
});

it('returns 413 when content-length exceeds size limit', async () => {
const { POST } = await import('./route');
const req = new NextRequest('http://localhost/api/branding/upload', {
method: 'POST',
headers: {
'content-length': String(6 * 1024 * 1024), // 6MB, exceeds 5MB limit
},
});
const res = await POST(req, { params: {} });
expect(res.status).toBe(413);
expect((await res.json()).error).toContain('exceeds maximum');
});

it('returns 413 when content-length is missing', async () => {
const { POST } = await import('./route');
const req = new NextRequest('http://localhost/api/branding/upload', {
method: 'POST',
headers: {},
});
const res = await POST(req, { params: {} });
expect(res.status).toBe(413);
});

it('accepts valid metadata in JSON format', async () => {
const { POST } = await import('./route');
const form = new FormData();
const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' });
form.append('file', file);
form.append('metadata', JSON.stringify({
filename: 'logo.png',
description: 'Main logo',
tags: ['logo', 'brand'],
category: 'branding',
}));

const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' });
(req as any).formData = async () => form;

const res = await POST(req, { params: {} });
expect(res.status).toBe(200);
});

it('returns 400 for invalid metadata JSON', async () => {
const { POST } = await import('./route');
const form = new FormData();
const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' });
form.append('file', file);
form.append('metadata', '{invalid json}');

const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' });
(req as any).formData = async () => form;

const res = await POST(req, { params: {} });
expect(res.status).toBe(400);
expect((await res.json()).error).toContain('JSON');
});

it('returns 400 for invalid metadata schema', async () => {
const { POST } = await import('./route');
const form = new FormData();
const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' });
form.append('file', file);
form.append('metadata', JSON.stringify({
filename: 123, // Invalid: should be string
tags: 'not-array', // Invalid: should be array
}));

const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' });
(req as any).formData = async () => form;

const res = await POST(req, { params: {} });
expect(res.status).toBe(400);
expect((await res.json()).error).toContain('Validation');
});

it('returns 400 when metadata is not a JSON string', async () => {
const { POST } = await import('./route');
const form = new FormData();
const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' });
form.append('file', file);
form.append('metadata', 123); // Not a string

const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' });
(req as any).formData = async () => form;

const res = await POST(req, { params: {} });
expect(res.status).toBe(400);
expect((await res.json()).error).toContain('JSON string');
});

it('returns 413 when file size exceeds limit in body validation', async () => {
const { POST } = await import('./route');
const big = new Uint8Array(6 * 1024 * 1024); // 6MB
big.set(PNG_MAGIC);
const file = new File([big], 'logo.png', { type: 'image/png' });
const res = await POST(makeMultipartRequest(file), { params: {} });
expect(res.status).toBe(413);
expect((await res.json()).error).toContain('exceeds maximum');
});
});
144 changes: 124 additions & 20 deletions apps/backend/src/app/api/branding/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/with-auth';
import { validateBrandingFile } from '@/lib/customization/validate-branding-file';
import {
validateContentLength,
DEFAULT_MAX_BRANDING_FILE_SIZE,
formatBytes,
} from '@/lib/api/validate-request-size';
import {
validateUploadMetadata,
formatValidationErrors,
} from '@/lib/validation/schema-validator';

/**
* POST /api/branding/upload
* Accepts a multipart/form-data upload with a single "file" field.
* Validates type, extension, size, and content safety before accepting.
*
* On success returns { url } — currently a placeholder; wire to your storage
* provider (e.g. Supabase Storage) in a follow-up.
* Upload and validate branding files with strict size and schema validation.
* Validates file size, type, format, and optional metadata before processing.
*
* Request body: multipart/form-data
* file (required) The file to upload
* metadata (optional) JSON metadata with optional fields:
* - filename: string (max 255 chars)
* - description: string (max 1000 chars)
* - tags: string[] (max 20 tags, each 1-50 chars)
* - category: one of "branding", "content", "config", "other"
*
* Responses:
* 200 — File validated successfully
* 400 — Missing required fields or invalid format
* 401 — Not authenticated
* 413 — Request body exceeds size limit
* 422 — File validation failed (invalid type, size, or safety)
* 500 — Unexpected server error
*
* Issue: #607
* Branch: feat/issue-071-upload-size-schema-validation
*/
export const POST = withAuth(async (req: NextRequest) => {
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 });
}
// Validate request size before buffering entire body
const contentLength = req.headers.get('content-length');
const sizeValidation = validateContentLength(
contentLength,
DEFAULT_MAX_BRANDING_FILE_SIZE,
);

const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Missing "file" field' }, { status: 400 });
}
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: sizeValidation.message },
{ status: 413 },
);
}

const buffer = new Uint8Array(await file.arrayBuffer());
const result = validateBrandingFile(file.name, file.type, file.size, buffer);
let formData: FormData;
try {
formData = await req.formData();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to parse form data';
console.error('[branding-upload] form parsing error:', err);
return NextResponse.json(
{ error: 'Expected multipart/form-data' },
{ status: 400 },
);
}

if (!result.valid) {
return NextResponse.json({ error: result.error, code: result.code }, { status: 422 });
}
// Validate file field
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json(
{ error: 'Missing or invalid "file" field' },
{ status: 400 },
);
}

// Additional file size check (safety measure)
if (file.size > DEFAULT_MAX_BRANDING_FILE_SIZE) {
return NextResponse.json(
{
error: `File exceeds maximum size of ${formatBytes(DEFAULT_MAX_BRANDING_FILE_SIZE)}. Received: ${formatBytes(file.size)}.`,
},
{ status: 413 },
);
}

// TODO: upload buffer to Supabase Storage / S3 and return the real URL
return NextResponse.json({ url: null, message: 'File validated successfully. Storage not yet wired.' }, { status: 200 });
// Validate optional metadata
let metadata;
const metadataField = formData.get('metadata');
if (metadataField) {
if (typeof metadataField !== 'string') {
return NextResponse.json(
{ error: 'Metadata field must be a JSON string' },
{ status: 400 },
);
}

try {
metadata = JSON.parse(metadataField);
} catch (err: unknown) {
return NextResponse.json(
{ error: 'Invalid metadata: must be valid JSON' },
{ status: 400 },
);
}

const metadataValidation = validateUploadMetadata(metadata);
if (!metadataValidation.valid) {
return NextResponse.json(
{ error: formatValidationErrors(metadataValidation.errors) },
{ status: 400 },
);
}
}

// Validate file (type, extension, content safety)
const buffer = new Uint8Array(await file.arrayBuffer());
const fileValidation = validateBrandingFile(file.name, file.type, file.size, buffer);

if (!fileValidation.valid) {
return NextResponse.json(
{ error: fileValidation.error, code: fileValidation.code },
{ status: 422 },
);
}

// TODO: upload buffer to Supabase Storage / S3 and return the real URL
return NextResponse.json(
{
url: null,
message: 'File validated successfully. Storage not yet wired.',
},
{ status: 200 },
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Upload failed';
console.error('[branding-upload] unexpected error:', err);
return NextResponse.json({ error: msg }, { status: 500 });
}
});
105 changes: 105 additions & 0 deletions apps/backend/src/lib/api/validate-request-size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import {
checkRequestSize,
formatBytes,
validateContentLength,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MAX_BRANDING_FILE_SIZE,
} from './validate-request-size';

describe('validate-request-size', () => {
describe('checkRequestSize', () => {
it('should return size for valid content-length', () => {
const size = checkRequestSize('1024', DEFAULT_MAX_REQUEST_SIZE);
expect(size).toBe(1024);
});

it('should return null for missing content-length', () => {
const size = checkRequestSize(null, DEFAULT_MAX_REQUEST_SIZE);
expect(size).toBeNull();
});

it('should return null when size exceeds limit', () => {
const size = checkRequestSize(
String(DEFAULT_MAX_REQUEST_SIZE + 1),
DEFAULT_MAX_REQUEST_SIZE,
);
expect(size).toBeNull();
});

it('should return null for invalid content-length', () => {
const size = checkRequestSize('invalid', DEFAULT_MAX_REQUEST_SIZE);
expect(size).toBeNull();
});

it('should accept size equal to limit', () => {
const size = checkRequestSize(String(DEFAULT_MAX_REQUEST_SIZE), DEFAULT_MAX_REQUEST_SIZE);
expect(size).toBe(DEFAULT_MAX_REQUEST_SIZE);
});
});

describe('formatBytes', () => {
it('should format bytes correctly', () => {
expect(formatBytes(0)).toBe('0 bytes');
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1024 * 1024)).toBe('1 MB');
expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
});

it('should round to 2 decimal places', () => {
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB');
});

it('should handle large files', () => {
const result = formatBytes(5 * 1024 * 1024);
expect(result).toContain('MB');
expect(parseFloat(result)).toBe(5);
});
});

describe('validateContentLength', () => {
it('should validate correct content-length', () => {
const result = validateContentLength('1024', DEFAULT_MAX_REQUEST_SIZE);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.size).toBe(1024);
}
});

it('should reject missing content-length', () => {
const result = validateContentLength(null, DEFAULT_MAX_REQUEST_SIZE);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.message).toContain('Content-Length');
}
});

it('should reject invalid content-length', () => {
const result = validateContentLength('invalid', DEFAULT_MAX_REQUEST_SIZE);
expect(result.valid).toBe(false);
});

it('should reject oversized content-length', () => {
const result = validateContentLength(
String(DEFAULT_MAX_BRANDING_FILE_SIZE + 1),
DEFAULT_MAX_BRANDING_FILE_SIZE,
);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.message).toContain('exceeds maximum');
}
});

it('should include formatted sizes in error message', () => {
const result = validateContentLength(
String(10 * 1024 * 1024),
DEFAULT_MAX_BRANDING_FILE_SIZE,
);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.message).toContain('MB');
}
});
});
});
Loading