diff --git a/src/env/client.ts b/src/env/client.ts index 03b4823..68090f9 100644 --- a/src/env/client.ts +++ b/src/env/client.ts @@ -3,6 +3,7 @@ import { z } from "zod"; const clientEnvSchema = z.object({ VITE_DOMAIN: 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_MAGIC_DELVE: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1), diff --git a/src/env/server.ts b/src/env/server.ts index be01087..5b19e59 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -5,7 +5,6 @@ const serverEnvSchema = z.object({ ADMIN_EMAIL: z.string().min(1), ADMIN_ID: 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_S3_BUCKET_NAME: 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), VITE_DOMAIN: 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_MAGIC_DELVE: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1), @@ -112,7 +112,6 @@ export const getMissingEnvVars = (): string[] => { "ADMIN_EMAIL", "ADMIN_ID", "JWT_SECRET_KEY", - "DANGEROUS_DBCOMMAND_PASSWORD", "AWS_REGION", "AWS_S3_BUCKET_NAME", "_AWS_ACCESS_KEY", @@ -133,6 +132,7 @@ export const getMissingEnvVars = (): string[] => { "GITHUB_API_TOKEN", "VITE_DOMAIN", "VITE_AWS_BUCKET_STRING", + "VITE_DOWNLOAD_BUCKET_STRING", "VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GITHUB_CLIENT_ID", diff --git a/src/routes/downloads.tsx b/src/routes/downloads.tsx index 2760f42..da70456 100644 --- a/src/routes/downloads.tsx +++ b/src/routes/downloads.tsx @@ -25,26 +25,36 @@ export default function DownloadsPage() { const [LaLText, setLaLText] = createSignal("Life and Lineage"); const [SwAText, setSwAText] = createSignal("Shapes with Abigail!"); const [corkText, setCorkText] = createSignal("Cork"); + const [gazeText, setGazeText] = createSignal("Gaze"); const download = (assetName: string) => { - fetch(`/api/downloads/public/${assetName}`) - .then((response) => response.json()) - .then((data) => { - const url = data.downloadURL; - window.location.href = url; - }) - .catch((error) => console.error(error)); + // Call the tRPC endpoint directly + import("~/lib/api").then(({ api }) => { + api.downloads.getDownloadUrl + .query({ asset_name: assetName }) + .then((data) => { + const url = data.downloadURL; + 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(() => { const lalInterval = glitchText(LaLText(), setLaLText); const swaInterval = glitchText(SwAText(), setSwAText); const corkInterval = glitchText(corkText(), setCorkText); + const gazeInterval = glitchText(gazeText(), setGazeText); onCleanup(() => { clearInterval(lalInterval); clearInterval(swaInterval); clearInterval(corkInterval); + clearInterval(gazeInterval); }); }); @@ -68,8 +78,25 @@ export default function DownloadsPage() {
+
+ Ordered by date of initial release +
- {/* Life and Lineage */} + {/* Gaze */} +
+

+ {">"} {gazeText()} +

+ +
+ + platform: macOS (14.6+) + + download("gaze")}> + download.dmg + +
+

{">"} {LaLText()} @@ -101,6 +128,24 @@ export default function DownloadsPage() {

+ {/* Cork */} +
+

+ {">"} {corkText()} +

+ +
+ + platform: macOS (13+) + + download("cork")}> + download.zip + + + # unzip → drag to /Applications + +
+
{/* Shapes with Abigail */}
@@ -133,25 +178,6 @@ export default function DownloadsPage() {
- - {/* Cork */} -
-

- {">"} {corkText()} -

- -
- - platform: macOS (13+) - - download("cork")}> - download.zip - - - # unzip → drag to /Applications - -
-
diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 0031656..0ef0e39 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -10,6 +10,7 @@ import { gitActivityRouter } from "./routers/git-activity"; import { postHistoryRouter } from "./routers/post-history"; import { infillRouter } from "./routers/infill"; import { accountRouter } from "./routers/account"; +import { downloadsRouter } from "./routers/downloads"; import { createTRPCRouter, createTRPCContext } from "./utils"; import type { H3Event } from "h3"; @@ -25,7 +26,8 @@ export const appRouter = createTRPCRouter({ gitActivity: gitActivityRouter, postHistory: postHistoryRouter, infill: infillRouter, - account: accountRouter + account: accountRouter, + downloads: downloadsRouter }); export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/downloads.test.ts b/src/server/api/routers/downloads.test.ts new file mode 100644 index 0000000..e369fad --- /dev/null +++ b/src/server/api/routers/downloads.test.ts @@ -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"); + } + }); +}); diff --git a/src/server/api/routers/downloads.ts b/src/server/api/routers/downloads.ts new file mode 100644 index 0000000..7acb7b4 --- /dev/null +++ b/src/server/api/routers/downloads.ts @@ -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 = { + 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" + }); + } + }) +});