123 lines
4.5 KiB
JavaScript
123 lines
4.5 KiB
JavaScript
import Fastify from "fastify";
|
|
import cors from "@fastify/cors";
|
|
import { z } from "zod";
|
|
import { request } from "undici";
|
|
import { pipeline } from "node:stream/promises";
|
|
const app = Fastify({
|
|
logger: true,
|
|
});
|
|
// Registro de CORS básico para permitir o frontend em desenvolvimento/produção.
|
|
await app.register(cors, {
|
|
origin: true,
|
|
});
|
|
app.get("/health", async () => {
|
|
return { status: "ok" };
|
|
});
|
|
const streamQuerySchema = z.object({
|
|
url: z
|
|
.string()
|
|
.url("URL inválida")
|
|
.refine((value) => {
|
|
// Evita SSRF localizando apenas http/https genérico.
|
|
return value.startsWith("http://") || value.startsWith("https://");
|
|
}, { message: "Apenas URLs HTTP/HTTPS são permitidas." }),
|
|
});
|
|
app.get("/stream", async (requestFastify, reply) => {
|
|
const parseResult = streamQuerySchema.safeParse(requestFastify.query);
|
|
if (!parseResult.success) {
|
|
const firstError = parseResult.error.errors[0]?.message ?? "Parâmetros inválidos";
|
|
reply.status(400);
|
|
return { error: firstError };
|
|
}
|
|
const { url } = parseResult.data;
|
|
try {
|
|
// HEAD para validar link e obter metadados.
|
|
const headResponse = await request(url, {
|
|
method: "HEAD",
|
|
// timeout conservador, em produção pode ser configurável.
|
|
headersTimeout: 15000,
|
|
});
|
|
if (headResponse.statusCode && headResponse.statusCode >= 400) {
|
|
reply.status(502);
|
|
return { error: "Não foi possível acessar o recurso de origem." };
|
|
}
|
|
const contentType = headResponse.headers["content-type"] ?? "application/octet-stream";
|
|
const contentLength = headResponse.headers["content-length"];
|
|
const originDisposition = headResponse.headers["content-disposition"];
|
|
// Nome de arquivo derivado do header Content-Disposition ou da URL.
|
|
const fallbackName = (() => {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
const nameFromPath = parsedUrl.pathname.split("/").filter(Boolean).pop();
|
|
return nameFromPath ?? "download.bin";
|
|
}
|
|
catch {
|
|
return "download.bin";
|
|
}
|
|
})();
|
|
const filename = (() => {
|
|
if (typeof originDisposition === "string") {
|
|
const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(originDisposition);
|
|
if (match?.[1]) {
|
|
return decodeURIComponent(match[1].replace(/"/g, ""));
|
|
}
|
|
}
|
|
return fallbackName;
|
|
})();
|
|
// GET streaming da origem.
|
|
const originResponse = await request(url, {
|
|
method: "GET",
|
|
headersTimeout: 30000,
|
|
maxRedirections: 3,
|
|
});
|
|
if (!originResponse.body) {
|
|
reply.status(502);
|
|
return { error: "Resposta de origem sem corpo para streaming." };
|
|
}
|
|
if (originResponse.statusCode && originResponse.statusCode >= 400) {
|
|
reply.status(originResponse.statusCode);
|
|
return { error: "Falha ao obter o arquivo de origem." };
|
|
}
|
|
// Configura headers de resposta para o cliente final.
|
|
reply.headers({
|
|
"Content-Type": Array.isArray(contentType) ? contentType[0] : contentType,
|
|
...(contentLength
|
|
? {
|
|
"Content-Length": Array.isArray(contentLength)
|
|
? contentLength[0]
|
|
: contentLength,
|
|
}
|
|
: {}),
|
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
});
|
|
// Se o cliente fechar a conexão, garantimos o encerramento do stream de origem.
|
|
reply.raw.on("close", () => {
|
|
originResponse.body?.destroy();
|
|
});
|
|
// Conecta o stream de origem diretamente ao socket do cliente.
|
|
await pipeline(originResponse.body, reply.raw);
|
|
// Fastify exige que retornemos void quando manipulamos reply.raw diretamente.
|
|
return reply;
|
|
}
|
|
catch (error) {
|
|
requestFastify.log.error({ err: error, url }, "Erro durante o streaming do arquivo");
|
|
if (!reply.sent) {
|
|
reply.status(502);
|
|
return { error: "Erro ao realizar o proxy do arquivo." };
|
|
}
|
|
return reply;
|
|
}
|
|
});
|
|
const port = Number(process.env.PORT) || 3000;
|
|
try {
|
|
await app.listen({
|
|
port,
|
|
host: "0.0.0.0",
|
|
});
|
|
app.log.info(`Server listening on port ${port}`);
|
|
}
|
|
catch (err) {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
}
|