downloads fix

This commit is contained in:
Michael Freno
2026-01-11 13:40:43 -05:00
parent 41b8a5416e
commit c8c1b754b1
6 changed files with 177 additions and 30 deletions

1
src/env/client.ts vendored
View File

@@ -3,6 +3,7 @@ import { z } from "zod";
const clientEnvSchema = z.object({ const clientEnvSchema = z.object({
VITE_DOMAIN: z.string().min(1), VITE_DOMAIN: z.string().min(1),
VITE_AWS_BUCKET_STRING: z.string().min(1), VITE_AWS_BUCKET_STRING: z.string().min(1),
VITE_DOWNLOAD_BUCKET_STRING: z.string().min(1),
VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID: z.string().min(1),
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1),
VITE_GITHUB_CLIENT_ID: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1),

4
src/env/server.ts vendored
View File

@@ -5,7 +5,6 @@ const serverEnvSchema = z.object({
ADMIN_EMAIL: z.string().min(1), ADMIN_EMAIL: z.string().min(1),
ADMIN_ID: z.string().min(1), ADMIN_ID: z.string().min(1),
JWT_SECRET_KEY: z.string().min(1), JWT_SECRET_KEY: z.string().min(1),
DANGEROUS_DBCOMMAND_PASSWORD: z.string().min(1),
AWS_REGION: z.string().min(1), AWS_REGION: z.string().min(1),
AWS_S3_BUCKET_NAME: z.string().min(1), AWS_S3_BUCKET_NAME: z.string().min(1),
_AWS_ACCESS_KEY: z.string().min(1), _AWS_ACCESS_KEY: z.string().min(1),
@@ -26,6 +25,7 @@ const serverEnvSchema = z.object({
GITHUB_API_TOKEN: z.string().min(1), GITHUB_API_TOKEN: z.string().min(1),
VITE_DOMAIN: z.string().min(1), VITE_DOMAIN: z.string().min(1),
VITE_AWS_BUCKET_STRING: z.string().min(1), VITE_AWS_BUCKET_STRING: z.string().min(1),
VITE_DOWNLOAD_BUCKET_STRING: z.string().min(1),
VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID: z.string().min(1),
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1),
VITE_GITHUB_CLIENT_ID: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1),
@@ -112,7 +112,6 @@ export const getMissingEnvVars = (): string[] => {
"ADMIN_EMAIL", "ADMIN_EMAIL",
"ADMIN_ID", "ADMIN_ID",
"JWT_SECRET_KEY", "JWT_SECRET_KEY",
"DANGEROUS_DBCOMMAND_PASSWORD",
"AWS_REGION", "AWS_REGION",
"AWS_S3_BUCKET_NAME", "AWS_S3_BUCKET_NAME",
"_AWS_ACCESS_KEY", "_AWS_ACCESS_KEY",
@@ -133,6 +132,7 @@ export const getMissingEnvVars = (): string[] => {
"GITHUB_API_TOKEN", "GITHUB_API_TOKEN",
"VITE_DOMAIN", "VITE_DOMAIN",
"VITE_AWS_BUCKET_STRING", "VITE_AWS_BUCKET_STRING",
"VITE_DOWNLOAD_BUCKET_STRING",
"VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID",
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",

View File

@@ -25,26 +25,36 @@ export default function DownloadsPage() {
const [LaLText, setLaLText] = createSignal("Life and Lineage"); const [LaLText, setLaLText] = createSignal("Life and Lineage");
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!"); const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
const [corkText, setCorkText] = createSignal("Cork"); const [corkText, setCorkText] = createSignal("Cork");
const [gazeText, setGazeText] = createSignal("Gaze");
const download = (assetName: string) => { const download = (assetName: string) => {
fetch(`/api/downloads/public/${assetName}`) // Call the tRPC endpoint directly
.then((response) => response.json()) import("~/lib/api").then(({ api }) => {
.then((data) => { api.downloads.getDownloadUrl
const url = data.downloadURL; .query({ asset_name: assetName })
window.location.href = url; .then((data) => {
}) const url = data.downloadURL;
.catch((error) => console.error(error)); window.location.href = url;
})
.catch((error) => {
console.error("Download error:", error);
// Optionally show user a message
alert("Failed to initiate download. Please try again.");
});
});
}; };
onMount(() => { onMount(() => {
const lalInterval = glitchText(LaLText(), setLaLText); const lalInterval = glitchText(LaLText(), setLaLText);
const swaInterval = glitchText(SwAText(), setSwAText); const swaInterval = glitchText(SwAText(), setSwAText);
const corkInterval = glitchText(corkText(), setCorkText); const corkInterval = glitchText(corkText(), setCorkText);
const gazeInterval = glitchText(gazeText(), setGazeText);
onCleanup(() => { onCleanup(() => {
clearInterval(lalInterval); clearInterval(lalInterval);
clearInterval(swaInterval); clearInterval(swaInterval);
clearInterval(corkInterval); clearInterval(corkInterval);
clearInterval(gazeInterval);
}); });
}); });
@@ -68,8 +78,25 @@ export default function DownloadsPage() {
</div> </div>
<div class="relative z-10"> <div class="relative z-10">
<div class="text-center text-xl italic">
Ordered by date of initial release
</div>
<div class="mx-auto max-w-5xl space-y-16"> <div class="mx-auto max-w-5xl space-y-16">
{/* Life and Lineage */} {/* Gaze */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {gazeText()}
</h2>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+)
</span>
<DownloadButton onClick={() => download("gaze")}>
download.dmg
</DownloadButton>
</div>
</div>
<div class="border-overlay0 rounded-lg border p-6 md:p-8"> <div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl"> <h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {LaLText()} <span class="text-yellow">{">"}</span> {LaLText()}
@@ -101,6 +128,24 @@ export default function DownloadsPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Cork */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {corkText()}
</h2>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (13+)
</span>
<DownloadButton onClick={() => download("cork")}>
download.zip
</DownloadButton>
<span class="text-subtext1 text-xs">
# unzip drag to /Applications
</span>
</div>
</div>
{/* Shapes with Abigail */} {/* Shapes with Abigail */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8"> <div class="border-overlay0 rounded-lg border p-6 md:p-8">
@@ -133,25 +178,6 @@ export default function DownloadsPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Cork */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {corkText()}
</h2>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (13+)
</span>
<DownloadButton onClick={() => download("cork")}>
download.zip
</DownloadButton>
<span class="text-subtext1 text-xs">
# unzip drag to /Applications
</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@ import { gitActivityRouter } from "./routers/git-activity";
import { postHistoryRouter } from "./routers/post-history"; import { postHistoryRouter } from "./routers/post-history";
import { infillRouter } from "./routers/infill"; import { infillRouter } from "./routers/infill";
import { accountRouter } from "./routers/account"; import { accountRouter } from "./routers/account";
import { downloadsRouter } from "./routers/downloads";
import { createTRPCRouter, createTRPCContext } from "./utils"; import { createTRPCRouter, createTRPCContext } from "./utils";
import type { H3Event } from "h3"; import type { H3Event } from "h3";
@@ -25,7 +26,8 @@ export const appRouter = createTRPCRouter({
gitActivity: gitActivityRouter, gitActivity: gitActivityRouter,
postHistory: postHistoryRouter, postHistory: postHistoryRouter,
infill: infillRouter, infill: infillRouter,
account: accountRouter account: accountRouter,
downloads: downloadsRouter
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
// Mock the S3 client and getSignedUrl function
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: class {
constructor() {}
send() {
return Promise.resolve({
$metadata: {},
Body: "test content"
});
}
},
GetObjectCommand: class {
constructor(params: any) {
this.params = params;
}
params: any;
}
}));
vi.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: vi.fn().mockResolvedValue("https://test-signed-url.com")
}));
// Mock environment variables
process.env.AWS_REGION = "us-east-1";
process.env._AWS_ACCESS_KEY = "test-access-key";
process.env._AWS_SECRET_KEY = "test-secret-key";
process.env.VITE_DOWNLOAD_BUCKET_STRING = "test-bucket";
describe("downloads router", () => {
it("should return a signed URL for valid asset names", async () => {
const caller = appRouter.createCaller(
await createTRPCContext({ nativeEvent: {} } as any)
);
const result = await caller.downloads.getDownloadUrl.query({
asset_name: "lineage"
});
expect(result).toHaveProperty("downloadURL");
expect(typeof result.downloadURL).toBe("string");
});
it("should throw NOT_FOUND for invalid asset names", async () => {
const caller = appRouter.createCaller(
await createTRPCContext({ nativeEvent: {} } as any)
);
try {
await caller.downloads.getDownloadUrl.query({
asset_name: "invalid-asset"
});
expect.fail("Should have thrown an error");
} catch (error) {
expect(error).toHaveProperty("code", "NOT_FOUND");
}
});
});

View File

@@ -0,0 +1,56 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { z } from "zod";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
const assets: Record<string, string> = {
gaze: "Gaze.dmg",
lineage: "Life and Lineage.apk",
cork: "Cork.zip",
"shapes-with-abigail": "shapes-with-abigail.apk"
};
export const downloadsRouter = createTRPCRouter({
getDownloadUrl: publicProcedure
.input(z.object({ asset_name: z.string() }))
.query(async ({ input }) => {
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
const params = {
Bucket: bucket,
Key: assets[input.asset_name]
};
if (!assets[input.asset_name]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Asset not found"
});
}
const credentials = {
accessKeyId: env._AWS_ACCESS_KEY,
secretAccessKey: env._AWS_SECRET_KEY
};
try {
const client = new S3Client({
region: env.AWS_REGION,
credentials: credentials
});
const command = new GetObjectCommand(params);
const signedUrl = await getSignedUrl(client, command, {
expiresIn: 120
});
return { downloadURL: signedUrl };
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate download URL"
});
}
})
});