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"
+ });
+ }
+ })
+});