fix: download bunny materials through academy
All checks were successful
CI / Validate frontend and API (push) Successful in 52s
CI / Build and publish Docker images (push) Successful in 24s

This commit is contained in:
Cauê Faleiros
2026-09-09 16:20:49 -03:00
parent a861644f6c
commit 95a4bd4ac7
5 changed files with 112 additions and 31 deletions

View File

@@ -73,6 +73,29 @@ export async function uploadBunnyAsset(asset: StoredAsset) {
return { key, assetUrl: `${cdnHost}/${key}` };
}
export function bunnyAssetKeyFromUrl(url: string) {
if (!config.BUNNY_STORAGE_CDN_HOST) return null;
const cdnHost = config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, '');
if (!url.startsWith(`${cdnHost}/materials/`)) return null;
const key = url.slice(cdnHost.length + 1);
return /^materials\/[0-9a-f-]{36}\.(?:pdf|docx|doc|xlsx|xls|csv|zip)$/i.test(key) ? key : null;
}
export async function downloadBunnyAsset(key: string) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
headers: { AccessKey: password },
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage download failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return {
body: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || 'application/octet-stream',
};
}
export async function downloadBunnyCover(filename: string) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {

View File

@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
import { BunnyStorageConfigurationError, BunnyStorageRequestError, downloadBunnyCover } from '../providers/bunny-storage.js';
import { BunnyStorageConfigurationError, BunnyStorageRequestError, bunnyAssetKeyFromUrl, downloadBunnyAsset, downloadBunnyCover } from '../providers/bunny-storage.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
@@ -14,6 +14,7 @@ const playbackParamsSchema = z.object({
const coverParamsSchema = z.object({
filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i),
});
const assetParamsSchema = z.object({ assetId: z.string().uuid() });
export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select
@@ -133,6 +134,36 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
}
});
app.get('/assets/:assetId/download', { preHandler: app.authenticate }, async (request, reply) => {
const { assetId } = assetParamsSchema.parse(request.params);
const result = await pool.query<{ name: string; url: string; status: string; instructor_id: string }>(
`select a.name, a.url, c.status, c.instructor_id
from assets a
join courses c on c.id = coalesce(a.course_id, (select lesson.course_id from lessons lesson where lesson.id = a.lesson_id))
where a.id = $1`,
[assetId],
);
const asset = result.rows[0];
if (!asset || (asset.status !== 'published' && request.user.role !== 'admin' && asset.instructor_id !== request.user.id)) {
return reply.code(404).send({ error: 'Material não encontrado.' });
}
const key = bunnyAssetKeyFromUrl(asset.url);
if (!key) return reply.redirect(asset.url);
try {
const file = await downloadBunnyAsset(key);
const filename = asset.name.replace(/[\\/\r\n"]/g, '_');
reply.header('Content-Type', file.contentType);
reply.header('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
reply.header('Cache-Control', 'private, no-store');
return reply.send(file.body);
} catch (error) {
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
return reply.code(404).send({ error: 'Material não encontrado.' });
}
throw error;
}
});
app.get('/', async (request) => {
const authenticated = await hasSession(request);
const result = await pool.query(