feat: upload course materials to bunny storage
This commit is contained in:
@@ -48,6 +48,7 @@ BUNNY_STORAGE_PASSWORD=
|
|||||||
BUNNY_STORAGE_ENDPOINT=
|
BUNNY_STORAGE_ENDPOINT=
|
||||||
BUNNY_STORAGE_CDN_HOST=
|
BUNNY_STORAGE_CDN_HOST=
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB=10
|
BUNNY_COVER_MAX_UPLOAD_MB=10
|
||||||
|
BUNNY_ASSET_MAX_UPLOAD_MB=100
|
||||||
|
|
||||||
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
||||||
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ BUNNY_STORAGE_PASSWORD=the-storage-zone-password
|
|||||||
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
|
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
|
||||||
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
|
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB=10
|
BUNNY_COVER_MAX_UPLOAD_MB=10
|
||||||
|
BUNNY_ASSET_MAX_UPLOAD_MB=100
|
||||||
```
|
```
|
||||||
|
|
||||||
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload only JPG, PNG, or WebP cover images; the API verifies the image bytes and uploads them under a generated `covers/` filename. The CDN URL is then saved on the course.
|
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload JPG, PNG, or WebP cover images and PDF, DOCX, XLSX, CSV, or ZIP support materials; the API validates the file bytes and determines the material type and size automatically. Covers are stored under `covers/` and course materials under `materials/`.
|
||||||
|
|
||||||
### Bunny processing webhooks
|
### Bunny processing webhooks
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ services:
|
|||||||
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
|
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
|
||||||
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
|
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
|
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
|
||||||
|
BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ const CourseModal: React.FC<{
|
|||||||
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
||||||
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
||||||
const [isCoverUploading, setIsCoverUploading] = useState(false);
|
const [isCoverUploading, setIsCoverUploading] = useState(false);
|
||||||
|
const attachmentFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
|
||||||
|
const [isAssetUploading, setIsAssetUploading] = useState(false);
|
||||||
|
const [assetUploadMessage, setAssetUploadMessage] = useState('');
|
||||||
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
||||||
|
|
||||||
// Lesson Management
|
// Lesson Management
|
||||||
@@ -102,11 +106,7 @@ const CourseModal: React.FC<{
|
|||||||
const [newTip, setNewTip] = useState('');
|
const [newTip, setNewTip] = useState('');
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
|
||||||
// New Attachment State
|
// Optional context saved alongside each uploaded material.
|
||||||
const [newAttName, setNewAttName] = useState('');
|
|
||||||
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
|
|
||||||
const [newAttUrl, setNewAttUrl] = useState('');
|
|
||||||
const [newAttSize, setNewAttSize] = useState('');
|
|
||||||
const [newAttDesc, setNewAttDesc] = useState('');
|
const [newAttDesc, setNewAttDesc] = useState('');
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -151,12 +151,17 @@ const CourseModal: React.FC<{
|
|||||||
setBunnyUploadState('idle');
|
setBunnyUploadState('idle');
|
||||||
setBunnyUploadMessage('');
|
setBunnyUploadMessage('');
|
||||||
setCoverUploadMessage('');
|
setCoverUploadMessage('');
|
||||||
|
setAssetUploadMessage('');
|
||||||
|
setNewAttDesc('');
|
||||||
instructorApi.bunnyConfiguration()
|
instructorApi.bunnyConfiguration()
|
||||||
.then((response) => setBunnyConfigured(response.data.configured))
|
.then((response) => setBunnyConfigured(response.data.configured))
|
||||||
.catch(() => setBunnyConfigured(false));
|
.catch(() => setBunnyConfigured(false));
|
||||||
instructorApi.coverConfiguration()
|
instructorApi.coverConfiguration()
|
||||||
.then((response) => setCoverConfigured(response.data.configured))
|
.then((response) => setCoverConfigured(response.data.configured))
|
||||||
.catch(() => setCoverConfigured(false));
|
.catch(() => setCoverConfigured(false));
|
||||||
|
instructorApi.assetConfiguration()
|
||||||
|
.then((response) => setAssetConfigured(response.data.configured))
|
||||||
|
.catch(() => setAssetConfigured(false));
|
||||||
courseApi.categories()
|
courseApi.categories()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setCategories(response.data);
|
setCategories(response.data);
|
||||||
@@ -391,36 +396,41 @@ const CourseModal: React.FC<{
|
|||||||
setTips(tips.filter((_, i) => i !== idx));
|
setTips(tips.filter((_, i) => i !== idx));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddAttachment = () => {
|
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!newAttName.trim()) {
|
const file = event.target.files?.[0];
|
||||||
alert("Informe o nome do arquivo para download.");
|
event.target.value = '';
|
||||||
return;
|
if (!file) return;
|
||||||
}
|
if (!assetConfigured) {
|
||||||
if (!newAttUrl.trim()) {
|
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
|
||||||
alert('Informe a URL do material.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsAssetUploading(true);
|
||||||
|
setAssetUploadMessage(`Enviando ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
new URL(newAttUrl.trim());
|
const response = await instructorApi.uploadAsset(file);
|
||||||
} catch {
|
const type: AttachmentType = response.data.kind === 'spreadsheet'
|
||||||
alert('Informe uma URL válida para o material.');
|
? 'spreadsheet'
|
||||||
return;
|
: response.data.kind === 'archive'
|
||||||
|
? 'zip'
|
||||||
|
: file.name.toLowerCase().endsWith('.pdf')
|
||||||
|
? 'pdf'
|
||||||
|
: 'doc';
|
||||||
|
setAttachments((current) => [...current, {
|
||||||
|
id: `asset-${response.data.key}`,
|
||||||
|
name: file.name,
|
||||||
|
type,
|
||||||
|
url: response.data.assetUrl,
|
||||||
|
sizeBytes: response.data.sizeBytes,
|
||||||
|
description: newAttDesc.trim(),
|
||||||
|
}]);
|
||||||
|
setNewAttDesc('');
|
||||||
|
setAssetUploadMessage('Material enviado com sucesso.');
|
||||||
|
} catch (error) {
|
||||||
|
setAssetUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar o material.');
|
||||||
|
} finally {
|
||||||
|
setIsAssetUploading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const att: Attachment = {
|
|
||||||
id: `att-${Date.now()}`,
|
|
||||||
name: newAttName.trim(),
|
|
||||||
type: newAttType,
|
|
||||||
url: newAttUrl.trim(),
|
|
||||||
size: newAttSize.trim() || undefined,
|
|
||||||
description: newAttDesc.trim()
|
|
||||||
};
|
|
||||||
|
|
||||||
setAttachments([...attachments, att]);
|
|
||||||
setNewAttName('');
|
|
||||||
setNewAttSize('');
|
|
||||||
setNewAttUrl('');
|
|
||||||
setNewAttDesc('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
const removeAttachment = (id: string) => {
|
||||||
@@ -486,7 +496,7 @@ const CourseModal: React.FC<{
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
|
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
|
||||||
<h2 className="mt-1 text-2xl font-extrabold text-white">{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}</h2>
|
<h2 className="mt-1 text-2xl font-extrabold text-white">{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Inclua os arquivos e links de apoio.'}</p>
|
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Envie os arquivos de apoio para seus alunos.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} className="shrink-0 rounded-full bg-white/5 p-2.5 text-gray-300 hover:bg-white/10 hover:text-white" aria-label="Fechar editor"><X className="w-5 h-5" /></button>
|
<button type="button" onClick={onClose} className="shrink-0 rounded-full bg-white/5 p-2.5 text-gray-300 hover:bg-white/10 hover:text-white" aria-label="Fechar editor"><X className="w-5 h-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -667,61 +677,37 @@ const CourseModal: React.FC<{
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add attachment inputs */}
|
<input
|
||||||
<div className="space-y-2 pt-1">
|
ref={attachmentFileRef}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
type="file"
|
||||||
<input
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.zip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,application/zip"
|
||||||
type="text"
|
className="hidden"
|
||||||
value={newAttName}
|
onChange={(event) => void handleAttachmentUpload(event)}
|
||||||
onChange={(e) => setNewAttName(e.target.value)}
|
/>
|
||||||
placeholder="Ex.: Planilha de acompanhamento de campanhas"
|
<div className="rounded-xl border border-dashed border-white/15 bg-white/[0.02] p-4">
|
||||||
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
/>
|
<div>
|
||||||
<select
|
<p className="text-sm font-semibold text-white">Envie um arquivo de apoio</p>
|
||||||
value={newAttType}
|
<p className="mt-1 text-xs text-gray-500">PDF, DOCX, XLSX, CSV ou ZIP. O formato e o tamanho são identificados automaticamente.</p>
|
||||||
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
|
</div>
|
||||||
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!assetConfigured || isAssetUploading}
|
||||||
|
onClick={() => attachmentFileRef.current?.click()}
|
||||||
|
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<option value="pdf">PDF</option>
|
{isAssetUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
|
||||||
<option value="spreadsheet">Planilha</option>
|
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
|
||||||
<option value="zip">Pacote ZIP</option>
|
|
||||||
<option value="doc">Documento</option>
|
|
||||||
<option value="link">Link</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={newAttUrl}
|
|
||||||
onChange={(e) => setNewAttUrl(e.target.value)}
|
|
||||||
placeholder="URL do material (https://...)"
|
|
||||||
className="min-w-0 flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newAttSize}
|
|
||||||
onChange={(e) => setNewAttSize(e.target.value)}
|
|
||||||
placeholder="Tamanho (opcional)"
|
|
||||||
className="w-28 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newAttDesc}
|
|
||||||
onChange={(e) => setNewAttDesc(e.target.value)}
|
|
||||||
placeholder="Explique como este material apoia o aprendizado."
|
|
||||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddAttachment}
|
|
||||||
className="bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg text-white text-xs font-bold shrink-0 transition-colors"
|
|
||||||
>
|
|
||||||
Adicionar
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newAttDesc}
|
||||||
|
onChange={(event) => setNewAttDesc(event.target.value)}
|
||||||
|
placeholder="Descrição opcional: como este material apoia o aprendizado?"
|
||||||
|
className="mt-3 w-full rounded-lg border border-white/10 bg-black/20 px-3 py-2.5 text-xs text-white outline-none focus:border-orange-500/50"
|
||||||
|
/>
|
||||||
|
{assetUploadMessage && <p className={`mt-2 text-xs ${assetUploadMessage.includes('sucesso') ? 'text-emerald-400' : assetUploadMessage.includes('Enviando') ? 'text-gray-400' : 'text-amber-300'}`}>{assetUploadMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List of Attachments */}
|
{/* List of Attachments */}
|
||||||
@@ -731,7 +717,7 @@ const CourseModal: React.FC<{
|
|||||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||||
{getAttachmentIcon(att.type)}
|
{getAttachmentIcon(att.type)}
|
||||||
<span className="text-gray-200 font-medium truncate">{att.name}</span>
|
<span className="text-gray-200 font-medium truncate">{att.name}</span>
|
||||||
<span className="text-[10px] text-gray-500 font-mono">({att.size || '1 MB'})</span>
|
{att.size && <span className="text-[10px] text-gray-500 font-mono">({att.size})</span>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export function buildApp() {
|
|||||||
app.addContentTypeParser('application/octet-stream', rawUploadParser);
|
app.addContentTypeParser('application/octet-stream', rawUploadParser);
|
||||||
app.addContentTypeParser(/^video\/.+$/, 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));
|
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, {
|
app.register(cors, {
|
||||||
origin: config.FRONTEND_ORIGIN,
|
origin: config.FRONTEND_ORIGIN,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const environmentSchema = z.object({
|
|||||||
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
|
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
|
||||||
BUNNY_STORAGE_CDN_HOST: 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_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);
|
export const config = environmentSchema.parse(process.env);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export class BunnyStorageConfigurationError extends Error {}
|
|||||||
export class BunnyStorageRequestError extends Error {}
|
export class BunnyStorageRequestError extends Error {}
|
||||||
|
|
||||||
type CoverImage = { body: Buffer; contentType: string };
|
type CoverImage = { body: Buffer; contentType: string };
|
||||||
|
type StoredAsset = { body: Buffer; contentType: string; extension: string };
|
||||||
|
|
||||||
function bunnyStorageConfiguration() {
|
function bunnyStorageConfiguration() {
|
||||||
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
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}` };
|
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) {
|
export async function downloadBunnyCover(filename: string) {
|
||||||
const { zone, password, endpoint } = bunnyStorageConfiguration();
|
const { zone, password, endpoint } = bunnyStorageConfiguration();
|
||||||
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
|
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
BunnyStorageConfigurationError,
|
BunnyStorageConfigurationError,
|
||||||
BunnyStorageRequestError,
|
BunnyStorageRequestError,
|
||||||
isBunnyStorageConfigured,
|
isBunnyStorageConfigured,
|
||||||
|
uploadBunnyAsset,
|
||||||
uploadBunnyCover,
|
uploadBunnyCover,
|
||||||
} from '../providers/bunny-storage.js';
|
} 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 createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
|
||||||
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
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) {
|
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 BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
|
||||||
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
|
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
|
||||||
throw error;
|
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) => {
|
app.post('/covers', coverAccess, async (request, reply) => {
|
||||||
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
||||||
const body = request.body;
|
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) => {
|
app.post('/bunny/videos', manageAccess, async (request, reply) => {
|
||||||
const input = createVideoSchema.parse(request.body);
|
const input = createVideoSchema.parse(request.body);
|
||||||
try {
|
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 };
|
||||||
|
}
|
||||||
@@ -238,6 +238,8 @@ export const instructorApi = {
|
|||||||
},
|
},
|
||||||
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
|
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
|
||||||
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
|
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
|
||||||
|
async assetConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/assets/config'); },
|
||||||
|
async uploadAsset(file: File) { return uploadImageRequest<{ data: { key: string; assetUrl: string; kind: 'document' | 'spreadsheet' | 'archive'; sizeBytes: number } }>('/manage/media/assets', file); },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const courseApi = {
|
export const courseApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user