Skip to content

Commit 4961b34

Browse files
authored
password protect playlist route (#598)
* password protect playlist route * fix auth check * remove framer motion from password overlay
1 parent 029bd76 commit 4961b34

File tree

7 files changed

+124
-116
lines changed

7 files changed

+124
-116
lines changed

apps/web/app/api/playlist/route.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import z from "zod";
1515
import { handle } from "hono/vercel";
1616

1717
import { corsMiddleware, withOptionalAuth } from "../utils";
18+
import { userHasAccessToVideo } from "@/utils/auth";
1819

1920
export const revalidate = "force-dynamic";
2021

@@ -42,32 +43,34 @@ const app = new Hono()
4243
),
4344
async (c) => {
4445
const { videoId, videoType, thumbnail, fileType } = c.req.valid("query");
46+
const user = c.get("user");
4547

4648
const query = await db()
4749
.select({ video: videos, bucket: s3Buckets })
4850
.from(videos)
4951
.leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
5052
.where(eq(videos.id, videoId));
5153

52-
if (!query[0]) {
54+
if (!query[0])
5355
return c.json(
5456
JSON.stringify({ error: true, message: "Video does not exist" }),
5557
404
5658
);
57-
}
5859

5960
const { video, bucket: customBucket } = query[0];
6061

61-
if (video.public === false) {
62-
const user = await getCurrentUser();
62+
const hasAccess = await userHasAccessToVideo(user, video);
6363

64-
if (!user || user.id !== video.ownerId) {
65-
return c.json(
66-
JSON.stringify({ error: true, message: "Video is not public" }),
67-
401
68-
);
69-
}
70-
}
64+
if (hasAccess === "private")
65+
return c.json(
66+
JSON.stringify({ error: true, message: "Video is not public" }),
67+
401
68+
);
69+
else if (hasAccess === "needs-password")
70+
return c.json(
71+
JSON.stringify({ error: true, message: "Video requires password" }),
72+
403
73+
);
7174

7275
const bucket = await createBucketProvider(customBucket);
7376

apps/web/app/s/[videoId]/_components/PasswordOverlay.tsx

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
"use client";
22

3-
import {
4-
Button,
5-
Dialog,
6-
DialogContent,
7-
DialogTitle,
8-
Input,
9-
Logo,
10-
} from "@cap/ui";
11-
import { motion } from "framer-motion";
3+
import { Button, Dialog, DialogContent, Input, Logo } from "@cap/ui";
124
import { useState } from "react";
135
import { toast } from "sonner";
146
import { verifyVideoPassword } from "@/actions/videos/password";
@@ -20,8 +12,6 @@ interface PasswordOverlayProps {
2012
videoId: string;
2113
}
2214

23-
const MotionDialogContent = motion.create(DialogContent);
24-
2515
export const PasswordOverlay: React.FC<PasswordOverlayProps> = ({
2616
isOpen,
2717
videoId,
@@ -46,13 +36,7 @@ export const PasswordOverlay: React.FC<PasswordOverlayProps> = ({
4636

4737
return (
4838
<Dialog open={isOpen}>
49-
<MotionDialogContent
50-
// initial={{ opacity: 0, scale: 0.95 }}
51-
// animate={{ opacity: 1, scale: 1 }}
52-
// exit={{ opacity: 0, scale: 0.95 }}
53-
// transition={{ duration: 0.2 }}
54-
className="w-[90vw] sm:max-w-md p-8 rounded-xl border border-gray-200 bg-white shadow-xl"
55-
>
39+
<DialogContent className="w-[90vw] sm:max-w-md p-8 rounded-xl border border-gray-200 bg-white shadow-xl">
5640
<div className="flex flex-col items-center space-y-6">
5741
<div className="flex flex-col items-center space-y-4">
5842
<Logo className="w-24 h-auto" />
@@ -98,7 +82,7 @@ export const PasswordOverlay: React.FC<PasswordOverlayProps> = ({
9882
</Button>
9983
</div>
10084
</div>
101-
</MotionDialogContent>
85+
</DialogContent>
10286
</Dialog>
10387
);
10488
};

apps/web/app/s/[videoId]/_components/ShareHeader.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export const ShareHeader = ({
3232
}: {
3333
data: typeof videos.$inferSelect;
3434
user: typeof userSelectProps | null;
35-
customDomain: string | null;
36-
domainVerified: boolean;
35+
customDomain?: string | null;
36+
domainVerified?: boolean;
3737
sharedOrganizations?: { id: string; name: string }[];
3838
userOrganizations?: { id: string; name: string }[];
3939
}) => {
@@ -46,7 +46,7 @@ export const ShareHeader = ({
4646
const [currentSharedOrganizations, setCurrentSharedOrganizations] =
4747
useState(sharedOrganizations);
4848

49-
const isOwner = user !== null && user.id.toString() === data.ownerId;
49+
const isOwner = user && user.id.toString() === data.ownerId;
5050

5151
const { webUrl } = usePublicEnv();
5252

@@ -167,10 +167,7 @@ export const ShareHeader = ({
167167
<h1
168168
className="text-xl sm:text-2xl"
169169
onClick={() => {
170-
if (
171-
user !== null &&
172-
user.id.toString() === data.ownerId
173-
) {
170+
if (user && user.id.toString() === data.ownerId) {
174171
setIsEditing(true);
175172
}
176173
}}

apps/web/app/s/[videoId]/page.tsx

Lines changed: 74 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "@cap/database";
2-
import { eq, desc, sql, count } from "drizzle-orm";
2+
import { eq, desc, sql, count, InferSelectModel } from "drizzle-orm";
33
import { Logo } from "@cap/ui";
44
import {
55
videos,
@@ -24,8 +24,8 @@ import { isAiGenerationEnabled, isAiUiEnabled } from "@/utils/flags";
2424
import { Share } from "./Share";
2525
import { PasswordOverlay } from "./_components/PasswordOverlay";
2626
import { ImageViewer } from "./_components/ImageViewer";
27-
import { decrypt } from "@cap/database/crypto";
2827
import { ShareHeader } from "./_components/ShareHeader";
28+
import { userHasAccessToVideo } from "@/utils/auth";
2929

3030
export const dynamic = "auto";
3131
export const dynamicParams = true;
@@ -245,7 +245,7 @@ export default async function ShareVideoPage(props: Props) {
245245
const videoId = params.videoId as string;
246246
console.log("[ShareVideoPage] Starting page load for videoId:", videoId);
247247

248-
const user = (await getCurrentUser()) as typeof userSelectProps | null;
248+
const user = await getCurrentUser();
249249
const userId = user?.id as string | undefined;
250250
console.log("[ShareVideoPage] Current user:", userId);
251251

@@ -287,6 +287,35 @@ export default async function ShareVideoPage(props: Props) {
287287
return <p>No video found</p>;
288288
}
289289

290+
const userAccess = await userHasAccessToVideo(user, video);
291+
292+
if (userAccess === "private") return <p>This video is private</p>;
293+
294+
return (
295+
<div className="min-h-screen flex flex-col bg-[#F7F8FA]">
296+
<PasswordOverlay
297+
isOpen={userAccess === "needs-password"}
298+
videoId={video.id}
299+
/>
300+
{userAccess === "has-access" && (
301+
<AuthorizedContent video={video} user={user} />
302+
)}
303+
</div>
304+
);
305+
}
306+
307+
async function AuthorizedContent({
308+
video,
309+
user,
310+
}: {
311+
video: InferSelectModel<typeof videos> & {
312+
sharedOrganization: { organizationId: string } | null;
313+
};
314+
user: InferSelectModel<typeof users> | null;
315+
}) {
316+
const videoId = video.id;
317+
const userId = user?.id;
318+
290319
let aiGenerationEnabled = false;
291320
const videoOwnerQuery = await db()
292321
.select({
@@ -423,10 +452,6 @@ export default async function ShareVideoPage(props: Props) {
423452
}
424453
}
425454

426-
if (video.public === false && userId !== video.ownerId) {
427-
return <p>This video is private</p>;
428-
}
429-
430455
const commentsQuery: CommentWithAuthor[] = await db()
431456
.select({
432457
id: comments.id,
@@ -593,69 +618,48 @@ export default async function ShareVideoPage(props: Props) {
593618
);
594619
}
595620

596-
const authorized =
597-
!videoWithOrganizationInfo.hasPassword ||
598-
user?.id === videoWithOrganizationInfo.ownerId ||
599-
(await verifyPasswordCookie(video.password ?? ""));
600-
601621
return (
602-
<div className="min-h-screen flex flex-col bg-[#F7F8FA]">
603-
<PasswordOverlay
604-
isOpen={!authorized}
605-
videoId={videoWithOrganizationInfo.id}
606-
/>
607-
{authorized && (
608-
<>
609-
<div className="container flex-1 px-4 py-4 mx-auto">
610-
<ShareHeader
611-
data={{
612-
...videoWithOrganizationInfo,
613-
createdAt: video.metadata?.customCreatedAt
614-
? new Date(video.metadata.customCreatedAt)
615-
: video.createdAt,
616-
}}
617-
user={user}
618-
customDomain={customDomain}
619-
domainVerified={domainVerified}
620-
sharedOrganizations={
621-
videoWithOrganizationInfo.sharedOrganizations || []
622-
}
623-
userOrganizations={userOrganizations}
624-
/>
625-
626-
<Share
627-
data={videoWithOrganizationInfo}
628-
user={user}
629-
comments={commentsQuery}
630-
initialAnalytics={initialAnalytics}
631-
customDomain={customDomain}
632-
domainVerified={domainVerified}
633-
userOrganizations={userOrganizations}
634-
initialAiData={initialAiData}
635-
aiGenerationEnabled={aiGenerationEnabled}
636-
aiUiEnabled={aiUiEnabled}
637-
/>
638-
</div>
639-
<div className="py-4 mt-auto">
640-
<a
641-
target="_blank"
642-
href={`/?ref=video_${video.id}`}
643-
className="flex justify-center items-center px-4 py-2 mx-auto space-x-2 bg-gray-1 rounded-full new-card-style w-fit"
644-
>
645-
<span className="text-sm">Recorded with</span>
646-
<Logo className="w-14 h-auto" />
647-
</a>
648-
</div>
649-
</>
650-
)}
651-
</div>
652-
);
653-
}
654-
655-
async function verifyPasswordCookie(videoPassword: string) {
656-
const password = cookies().get("x-cap-password")?.value;
657-
if (!password) return false;
622+
<>
623+
<div className="container flex-1 px-4 py-4 mx-auto">
624+
<ShareHeader
625+
data={{
626+
...videoWithOrganizationInfo,
627+
createdAt: video.metadata?.customCreatedAt
628+
? new Date(video.metadata.customCreatedAt)
629+
: video.createdAt,
630+
}}
631+
user={user}
632+
customDomain={customDomain}
633+
domainVerified={domainVerified}
634+
sharedOrganizations={
635+
videoWithOrganizationInfo.sharedOrganizations || []
636+
}
637+
userOrganizations={userOrganizations}
638+
/>
658639

659-
const decrypted = await decrypt(password).catch(() => "");
660-
return decrypted === videoPassword;
640+
<Share
641+
data={videoWithOrganizationInfo}
642+
user={user}
643+
comments={commentsQuery}
644+
initialAnalytics={initialAnalytics}
645+
customDomain={customDomain}
646+
domainVerified={domainVerified}
647+
userOrganizations={userOrganizations}
648+
initialAiData={initialAiData}
649+
aiGenerationEnabled={aiGenerationEnabled}
650+
aiUiEnabled={aiUiEnabled}
651+
/>
652+
</div>
653+
<div className="py-4 mt-auto">
654+
<a
655+
target="_blank"
656+
href={`/?ref=video_${video.id}`}
657+
className="flex justify-center items-center px-4 py-2 mx-auto space-x-2 bg-gray-1 rounded-full new-card-style w-fit"
658+
>
659+
<span className="text-sm">Recorded with</span>
660+
<Logo className="w-14 h-auto" />
661+
</a>
662+
</div>
663+
</>
664+
);
661665
}

apps/web/lib/safe-action.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

apps/web/utils/auth.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { decrypt } from "@cap/database/crypto";
2+
import { videos } from "@cap/database/schema";
3+
import { InferSelectModel } from "drizzle-orm";
4+
import { cookies } from "next/headers";
5+
6+
async function verifyPasswordCookie(videoPassword: string) {
7+
const password = cookies().get("x-cap-password")?.value;
8+
if (!password) return false;
9+
10+
const decrypted = await decrypt(password).catch(() => "");
11+
return decrypted === videoPassword;
12+
}
13+
14+
export async function userHasAccessToVideo(
15+
user: { id: string } | undefined | null,
16+
video: InferSelectModel<typeof videos>
17+
): Promise<"has-access" | "private" | "needs-password"> {
18+
if (video.public === false && (!user || user.id !== video.ownerId))
19+
return "private";
20+
if (video.password === null) return "has-access";
21+
if (!(await verifyPasswordCookie(video.password))) return "needs-password";
22+
return "has-access";
23+
}

packages/database/auth/session.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getServerSession, Session } from "next-auth";
2-
import { eq } from "drizzle-orm";
2+
import { eq, InferSelectModel } from "drizzle-orm";
33
import { authOptions } from "./auth-options";
44
import { db } from "../";
55
import { users } from "../schema";
@@ -10,19 +10,19 @@ export const getSession = async () => {
1010
return session;
1111
};
1212

13-
export const getCurrentUser = async (session?: Session) => {
13+
export const getCurrentUser = async (
14+
session?: Session
15+
): Promise<InferSelectModel<typeof users> | null> => {
1416
const _session = session ?? (await getServerSession(authOptions()));
1517

16-
if (!_session) {
17-
return null;
18-
}
18+
if (!_session) return null;
1919

2020
const [currentUser] = await db()
2121
.select()
2222
.from(users)
2323
.where(eq(users.id, _session?.user.id));
2424

25-
return currentUser;
25+
return currentUser ?? null;
2626
};
2727

2828
export const userSelectProps = users.$inferSelect;

0 commit comments

Comments
 (0)