feat: upload course materials to bunny storage
This commit is contained in:
@@ -35,6 +35,18 @@ export function buildApp() {
|
||||
app.addContentTypeParser('application/octet-stream', rawUploadParser);
|
||||
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
|
||||
app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
|
||||
const assetContentTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
'application/zip',
|
||||
'text/csv',
|
||||
];
|
||||
for (const contentType of assetContentTypes) {
|
||||
app.addContentTypeParser(contentType, { parseAs: 'buffer', bodyLimit: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
|
||||
}
|
||||
|
||||
app.register(cors, {
|
||||
origin: config.FRONTEND_ORIGIN,
|
||||
|
||||
@@ -35,6 +35,7 @@ const environmentSchema = z.object({
|
||||
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
|
||||
BUNNY_STORAGE_CDN_HOST: optionalEnvironmentValue(z.string().url()),
|
||||
BUNNY_COVER_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(50).default(10),
|
||||
BUNNY_ASSET_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(512).default(100),
|
||||
});
|
||||
|
||||
export const config = environmentSchema.parse(process.env);
|
||||
|
||||
@@ -5,6 +5,7 @@ export class BunnyStorageConfigurationError extends Error {}
|
||||
export class BunnyStorageRequestError extends Error {}
|
||||
|
||||
type CoverImage = { body: Buffer; contentType: string };
|
||||
type StoredAsset = { body: Buffer; contentType: string; extension: string };
|
||||
|
||||
function bunnyStorageConfiguration() {
|
||||
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
||||
@@ -53,6 +54,25 @@ export async function uploadBunnyCover(image: CoverImage) {
|
||||
return { key, coverImageUrl: `${cdnHost}/${key}` };
|
||||
}
|
||||
|
||||
export async function uploadBunnyAsset(asset: StoredAsset) {
|
||||
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
|
||||
const key = `materials/${randomUUID()}.${asset.extension}`;
|
||||
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
AccessKey: password,
|
||||
'Content-Type': asset.contentType,
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
},
|
||||
body: asset.body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new BunnyStorageRequestError(`Bunny Storage upload failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
|
||||
}
|
||||
return { key, assetUrl: `${cdnHost}/${key}` };
|
||||
}
|
||||
|
||||
export async function downloadBunnyCover(filename: string) {
|
||||
const { zone, password, endpoint } = bunnyStorageConfiguration();
|
||||
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
BunnyStorageConfigurationError,
|
||||
BunnyStorageRequestError,
|
||||
isBunnyStorageConfigured,
|
||||
uploadBunnyAsset,
|
||||
uploadBunnyCover,
|
||||
} from '../providers/bunny-storage.js';
|
||||
import { AssetUploadValidationError, validateAssetUpload } from '../uploads/asset.js';
|
||||
|
||||
const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
|
||||
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
||||
@@ -31,6 +33,7 @@ function providerError(reply: { code: (status: number) => { send: (payload: obje
|
||||
}
|
||||
|
||||
function storageError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
||||
if (error instanceof AssetUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
|
||||
if (error instanceof BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
|
||||
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
|
||||
throw error;
|
||||
@@ -88,6 +91,13 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
}));
|
||||
|
||||
app.get('/assets/config', manageAccess, async () => ({
|
||||
data: {
|
||||
configured: isBunnyStorageConfigured(),
|
||||
maxUploadBytes: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024,
|
||||
},
|
||||
}));
|
||||
|
||||
app.post('/covers', coverAccess, async (request, reply) => {
|
||||
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
||||
const body = request.body;
|
||||
@@ -105,6 +115,20 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/assets', manageAccess, async (request, reply) => {
|
||||
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
||||
const body = request.body;
|
||||
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de material.' });
|
||||
try {
|
||||
const asset = validateAssetUpload(body, contentType, config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024);
|
||||
const stored = await uploadBunnyAsset({ body, contentType: asset.contentType, extension: asset.extension });
|
||||
await recordAudit({ actorId: request.user.id, action: 'media.asset.uploaded', subjectType: 'asset', metadata: { key: stored.key, kind: asset.kind, sizeBytes: body.length }, ipAddress: request.ip });
|
||||
return reply.code(201).send({ data: { ...stored, kind: asset.kind, sizeBytes: body.length } });
|
||||
} catch (error) {
|
||||
return storageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/bunny/videos', manageAccess, async (request, reply) => {
|
||||
const input = createVideoSchema.parse(request.body);
|
||||
try {
|
||||
|
||||
52
server/src/uploads/asset.ts
Normal file
52
server/src/uploads/asset.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type AssetKind = 'document' | 'spreadsheet' | 'archive';
|
||||
|
||||
type AssetDefinition = {
|
||||
kind: AssetKind;
|
||||
extension: string;
|
||||
needsZipSignature?: boolean;
|
||||
needsOleSignature?: boolean;
|
||||
needsPdfSignature?: boolean;
|
||||
needsTextContent?: boolean;
|
||||
};
|
||||
|
||||
const allowedAssetTypes = new Map<string, AssetDefinition>([
|
||||
['application/pdf', { kind: 'document', extension: 'pdf', needsPdfSignature: true }],
|
||||
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', { kind: 'document', extension: 'docx', needsZipSignature: true }],
|
||||
['application/msword', { kind: 'document', extension: 'doc', needsOleSignature: true }],
|
||||
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', { kind: 'spreadsheet', extension: 'xlsx', needsZipSignature: true }],
|
||||
['application/vnd.ms-excel', { kind: 'spreadsheet', extension: 'xls', needsOleSignature: true }],
|
||||
['text/csv', { kind: 'spreadsheet', extension: 'csv', needsTextContent: true }],
|
||||
['application/zip', { kind: 'archive', extension: 'zip', needsZipSignature: true }],
|
||||
]);
|
||||
|
||||
export class AssetUploadValidationError extends Error {
|
||||
constructor(message: string, public readonly statusCode: 400 | 413 | 415) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const zipSignature = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
const oleSignature = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||
|
||||
export function validateAssetUpload(body: Buffer, contentType: string | undefined, maxBytes: number) {
|
||||
const normalizedType = contentType?.split(';')[0]?.trim().toLowerCase();
|
||||
const definition = normalizedType ? allowedAssetTypes.get(normalizedType) : undefined;
|
||||
if (!definition) {
|
||||
throw new AssetUploadValidationError('Envie apenas arquivos PDF, DOCX, XLSX, CSV ou ZIP.', 415);
|
||||
}
|
||||
if (body.length === 0) throw new AssetUploadValidationError('Escolha um arquivo para enviar.', 400);
|
||||
if (body.length > maxBytes) throw new AssetUploadValidationError(`O arquivo excede o limite de ${Math.floor(maxBytes / 1024 / 1024)} MB.`, 413);
|
||||
if (definition.needsPdfSignature && !body.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
|
||||
throw new AssetUploadValidationError('O arquivo enviado não é um PDF válido.', 415);
|
||||
}
|
||||
if (definition.needsZipSignature && !body.subarray(0, 4).equals(zipSignature)) {
|
||||
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
|
||||
}
|
||||
if (definition.needsOleSignature && !body.subarray(0, 8).equals(oleSignature)) {
|
||||
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
|
||||
}
|
||||
if (definition.needsTextContent && body.subarray(0, Math.min(body.length, 4096)).includes(0)) {
|
||||
throw new AssetUploadValidationError('O arquivo CSV deve conter texto.', 415);
|
||||
}
|
||||
return { kind: definition.kind, extension: definition.extension, contentType: normalizedType as string };
|
||||
}
|
||||
Reference in New Issue
Block a user