forked from Mike/freno-dev
The v0.10.0 publish stopped at the enclosure check: the appcast names Nook17-9.delta / Nook17-10.delta, and the proxy 400'd both with 'Invalid file format' while the TheNook DMG passed. Sparkle names delta files after the app bundle, not the DMG, so the prefix allowlist needs 'Nook' alongside 'TheNook'. Both deltas sat in S3 the whole time; the appcast was never published and the live one still serves 0.9.0. Adds a static source-audit regression test in the repo's downloads.test pattern; proven red against the unfixed allowlist. Tests: bun test src/routes/api/downloads/filename.test.ts NOOK-337 Co-Authored-By: glm-5.3-flash <noreply@neuralwatt.com>
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
import type { APIEvent } from "@solidjs/start/server";
|
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
import { env } from "~/env/server";
|
|
|
|
/**
|
|
* Serves macOS app DMG files and delta updates from S3
|
|
* This endpoint is used by Sparkle updater to download updates
|
|
*
|
|
* Handles:
|
|
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
|
|
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/Nook17-9.delta
|
|
*
|
|
* The Nook delta files are named after the app bundle ("Nook"), not the DMG
|
|
* ("TheNook"), so both prefixes must be allowed (NOOK-337).
|
|
*
|
|
* URL: https://freno.me/api/downloads/[filename]
|
|
*/
|
|
export async function GET(event: APIEvent) {
|
|
const filename = event.params.filename;
|
|
|
|
if (!filename) {
|
|
return new Response("Filename required", {
|
|
status: 400,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
const validPrefixes = ["Gaze", "InputHalo", "TheNook", "Nook"];
|
|
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
|
if (
|
|
!isValidPrefix ||
|
|
(!filename.endsWith(".dmg") &&
|
|
!filename.endsWith(".delta") &&
|
|
!filename.endsWith(".zip"))
|
|
) {
|
|
return new Response("Invalid file format", {
|
|
status: 400,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
|
const key = `downloads/${filename}`;
|
|
|
|
const credentials = {
|
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
|
};
|
|
|
|
try {
|
|
const client = new S3Client({
|
|
region: env.AWS_REGION,
|
|
credentials: credentials
|
|
});
|
|
|
|
const command = new GetObjectCommand({
|
|
Bucket: bucket,
|
|
Key: key
|
|
});
|
|
|
|
const response = await client.send(command);
|
|
|
|
if (!response.Body) {
|
|
console.error(`File not found in S3: ${key}`);
|
|
return new Response("File not found", {
|
|
status: 404,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
const contentType = filename.endsWith(".zip")
|
|
? "application/zip"
|
|
: filename.endsWith(".dmg")
|
|
? "application/x-apple-diskimage"
|
|
: "application/octet-stream";
|
|
|
|
const body = await response.Body.transformToByteArray();
|
|
|
|
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
|
|
|
return new Response(body, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Content-Length": body.length.toString(),
|
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
|
"Access-Control-Allow-Origin": "*"
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to fetch ${filename} from S3:`, error);
|
|
|
|
if (error instanceof Error && error.name === "NoSuchKey") {
|
|
return new Response("File not found in storage", {
|
|
status: 404,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
return new Response("Internal Server Error", {
|
|
status: 500,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
}
|