Skip to content
Open
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
38 changes: 30 additions & 8 deletions apps/web/actions/videos/get-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import { db } from "@cap/database";
import { videos } from "@cap/database/schema";
import { Tinybird } from "@cap/web-backend";
import { Video } from "@cap/web-domain";
import { provideOptionalAuth, Tinybird, VideosPolicy } from "@cap/web-backend";
import { Policy, Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect } from "effect";
import { Effect, Exit } from "effect";
import * as EffectRuntime from "@/lib/server";
import { runPromise } from "@/lib/server";

const DAY_IN_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -41,11 +42,32 @@ export async function getVideoAnalytics(
) {
if (!videoId) throw new Error("Video ID is required");

const [{ orgId } = { orgId: null }] = await db()
.select({ orgId: videos.orgId })
.from(videos)
.where(eq(videos.id, Video.VideoId.make(videoId)))
.limit(1);
const id = Video.VideoId.make(videoId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor edge case: Video.VideoId.make(videoId) can throw on malformed IDs; might be worth mapping that to the same not-found error so clients don’t get a 500 for invalid input.

Suggested change
const id = Video.VideoId.make(videoId);
const id = (() => {
try {
return Video.VideoId.make(videoId);
} catch {
throw new Error("Video not found");
}
})();


// This is a server action, so it is directly callable by any client
// independently of `/api/analytics` — the gate on that route does not
// protect this entry point. Without this check the view count of a private
// or password-protected video is readable by anyone who knows its ID.
const exit = await Effect.gen(function* () {
const videosPolicy = yield* VideosPolicy;

return yield* Effect.promise(() =>
db()
.select({ orgId: videos.orgId })
.from(videos)
.where(eq(videos.id, id))
.limit(1),
).pipe(Policy.withPublicPolicy(videosPolicy.canView(id)));
}).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit);

// If the video doesn't exist or isn't viewable, don't query Tinybird.
// Otherwise we'd fall through with a null orgId, dropping the tenant
// filter, and return analytics for an arbitrary `/s/${videoId}` pathname.
if (Exit.isFailure(exit) || exit.value.length === 0) {
throw new Error("Video not found");
}

const orgId = exit.value[0]?.orgId ?? null;

return runPromise(
Effect.gen(function* () {
Expand Down