Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DropdownMenuTrigger,
} from "@cap/ui";
import type { Video } from "@cap/web-domain";
import { HttpClient } from "@effect/platform";
import {
faCheck,
faCopy,
Expand All @@ -19,6 +20,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMutation } from "@tanstack/react-query";
import clsx from "clsx";
import { Effect, Option } from "effect";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { type PropsWithChildren, useState } from "react";
Expand Down Expand Up @@ -111,27 +113,29 @@ export const CapCard = ({

const router = useRouter();

const downloadMutation = useMutation({
mutationFn: async () => {
const response = await downloadVideo(cap.id);
if (response.success && response.downloadUrl) {
const fetchResponse = await fetch(response.downloadUrl);
const blob = await fetchResponse.blob();

const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = response.filename;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

window.URL.revokeObjectURL(blobUrl);
} else {
throw new Error("Failed to get download URL");
}
},
const downloadMutation = useEffectMutation({
mutationFn: () =>
Effect.gen(function* () {
const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id));
const httpClient = yield* HttpClient.HttpClient;
if (Option.isSome(result)) {
const fetchResponse = yield* httpClient.get(result.value.downloadUrl);
const blob = yield* fetchResponse.arrayBuffer;

const blobUrl = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement("a");
link.href = blobUrl;
link.download = result.value.fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

window.URL.revokeObjectURL(blobUrl);
} else {
throw new Error("Failed to get download URL");
}
}),
});
Comment on lines +116 to 139
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: call arrayBuffer() and guard on non-2xx responses before creating the Blob.

Currently arrayBuffer is referenced but not invoked, and the response status isn’t checked. This can break downloads or save error pages.

Apply this diff:

   const downloadMutation = useEffectMutation({
     mutationFn: () =>
       Effect.gen(function* () {
         const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id));
         const httpClient = yield* HttpClient.HttpClient;
         if (Option.isSome(result)) {
-          const fetchResponse = yield* httpClient.get(result.value.downloadUrl);
-          const blob = yield* fetchResponse.arrayBuffer;
-
-          const blobUrl = window.URL.createObjectURL(new Blob([blob]));
+          const res = yield* httpClient.get(result.value.downloadUrl);
+          if (res.status < 200 || res.status >= 300) {
+            throw new Error(`Download failed (${res.status})`);
+          }
+          const bytes = yield* res.arrayBuffer();
+          const blobUrl = window.URL.createObjectURL(
+            new Blob([bytes], { type: "video/mp4" }),
+          );
           const link = document.createElement("a");
           link.href = blobUrl;
           link.download = result.value.fileName;
           link.style.display = "none";
           document.body.appendChild(link);
           link.click();
           document.body.removeChild(link);
 
           window.URL.revokeObjectURL(blobUrl);
         } else {
           throw new Error("Failed to get download URL");
         }
       }),
   });

Optional follow-ups:

  • Delay revokeObjectURL slightly (setTimeout) to avoid edge-case races in some browsers.
  • Consider streaming strategies for very large files to reduce memory pressure (if/when feasible in your target browsers).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const downloadMutation = useEffectMutation({
mutationFn: () =>
Effect.gen(function* () {
const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id));
const httpClient = yield* HttpClient.HttpClient;
if (Option.isSome(result)) {
const fetchResponse = yield* httpClient.get(result.value.downloadUrl);
const blob = yield* fetchResponse.arrayBuffer;
const blobUrl = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement("a");
link.href = blobUrl;
link.download = result.value.fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} else {
throw new Error("Failed to get download URL");
}
}),
});
const downloadMutation = useEffectMutation({
mutationFn: () =>
Effect.gen(function* () {
const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id));
const httpClient = yield* HttpClient.HttpClient;
if (Option.isSome(result)) {
const res = yield* httpClient.get(result.value.downloadUrl);
if (res.status < 200 || res.status >= 300) {
throw new Error(`Download failed (${res.status})`);
}
const bytes = yield* res.arrayBuffer();
const blobUrl = window.URL.createObjectURL(
new Blob([bytes], { type: "video/mp4" }),
);
const link = document.createElement("a");
link.href = blobUrl;
link.download = result.value.fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} else {
throw new Error("Failed to get download URL");
}
}),
});
🤖 Prompt for AI Agents
In apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx around lines
116-139, the download flow incorrectly references fetchResponse.arrayBuffer
without invoking it and doesn’t guard on non-2xx responses; update the code to
check fetchResponse.ok (or status) and throw a descriptive error if not OK, then
call and await the arrayBuffer() method (i.e., invoke arrayBuffer()) before
creating the Blob and proceeding with the link download; optionally delay
URL.revokeObjectURL with setTimeout to avoid race conditions.


const deleteMutation = useMutation({
Expand Down
7 changes: 6 additions & 1 deletion apps/web/lib/EffectRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as WebSdk from "@effect/opentelemetry/WebSdk";
import { FetchHttpClient } from "@effect/platform";
import { Layer, ManagedRuntime } from "effect";
import {
makeUseEffectMutation,
Expand All @@ -9,7 +10,11 @@ import { getTracingConfig } from "./tracing";

const TracingLayer = WebSdk.layer(getTracingConfig);

const RuntimeLayer = Layer.mergeAll(Rpc.Default, TracingLayer);
const RuntimeLayer = Layer.mergeAll(
Rpc.Default,
TracingLayer,
FetchHttpClient.layer,
);

export type RuntimeLayer = typeof RuntimeLayer;

Expand Down
9 changes: 9 additions & 0 deletions packages/web-backend/src/Videos/VideosRpcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
UnknownException: () => new InternalError({ type: "unknown" }),
}),
),
VideoGetDownloadInfo: (videoId) =>
videos.getDownloadInfo(videoId).pipe(
provideOptionalAuth,
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
UnknownException: () => new InternalError({ type: "unknown" }),
S3Error: () => new InternalError({ type: "s3" }),
}),
),
};
}),
);
44 changes: 37 additions & 7 deletions packages/web-backend/src/Videos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ export class Videos extends Effect.Service<Videos>()("Videos", {
const policy = yield* VideosPolicy;
const s3Buckets = yield* S3Buckets;

const getById = (id: Video.VideoId) =>
repo
.getById(id)
.pipe(
Policy.withPublicPolicy(policy.canView(id)),
Effect.withSpan("Videos.getById"),
);

return {
/*
* Get a video by ID. Will fail if the user does not have access.
*/
// This is only for external use since it does an access check,
// internal use should prefer the repo directly
getById: (id: Video.VideoId) =>
repo
.getById(id)
.pipe(
Policy.withPublicPolicy(policy.canView(id)),
Effect.withSpan("Videos.getById"),
),
getById,

/*
* Delete a video. Will fail if the user does not have access.
Expand Down Expand Up @@ -129,6 +131,34 @@ export class Videos extends Effect.Service<Videos>()("Videos", {
}),

create: Effect.fn("Videos.create")(repo.create),

getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* (
videoId: Video.VideoId,
) {
const [video] = yield* getById(videoId).pipe(
Effect.flatMap(
Effect.catchTag(
"NoSuchElementException",
() => new Video.NotFoundError(),
),
),
);

const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);

return yield* Option.fromNullable(Video.Video.getSource(video)).pipe(
Option.filter((v) => v._tag === "Mp4Source"),
Option.map((v) =>
bucket.getSignedObjectUrl(v.getFileKey()).pipe(
Effect.map((downloadUrl) => ({
fileName: `${video.name}.mp4`,
downloadUrl,
})),
),
),
Effect.transposeOption,
);
}),
};
}),
dependencies: [
Expand Down
12 changes: 12 additions & 0 deletions packages/web-domain/src/Video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,16 @@ export class VideoRpcs extends RpcGroup.make(
VerifyVideoPasswordError,
),
}),
Rpc.make("VideoGetDownloadInfo", {
payload: VideoId,
success: Schema.Option(
Schema.Struct({ fileName: Schema.String, downloadUrl: Schema.String }),
),
error: Schema.Union(
NotFoundError,
InternalError,
PolicyDeniedError,
VerifyVideoPasswordError,
),
}),
) {}
Loading