+ {setting.pro && (
+
+ Cap Pro
+
+ )}
-
{setting.label}
+
handleChange(
setting.key,
@@ -149,10 +162,11 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
? "checked"
: "unchecked"
}
- class={`pointer-events-none block h-4 w-4 rounded-full bg-gray-50 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 border-2 ${settings[setting.key as keyof GeneralSettingsStore]
- ? "border-blue-400"
- : "border-gray-300"
- }`}
+ class={`pointer-events-none block h-4 w-4 rounded-full bg-gray-50 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 border-2 ${
+ settings[setting.key as keyof GeneralSettingsStore]
+ ? "border-blue-400"
+ : "border-gray-300"
+ }`}
/>
diff --git a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx
index e49585a56..cb0b15c6a 100644
--- a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx
@@ -39,8 +39,6 @@ export default function Page() {
return;
}
- console.log("auth:", auth);
-
const response = await fetch(
`${clientEnv.VITE_SERVER_URL}/api/desktop/subscribe?origin=${window.location.origin}`,
{
diff --git a/apps/web/app/api/desktop/s3/config/get/route.ts b/apps/web/app/api/desktop/s3/config/get/route.ts
new file mode 100644
index 000000000..b4a886db2
--- /dev/null
+++ b/apps/web/app/api/desktop/s3/config/get/route.ts
@@ -0,0 +1,119 @@
+import { type NextRequest } from "next/server";
+import { db } from "@cap/database";
+import { s3Buckets } from "@cap/database/schema";
+import { getCurrentUser } from "@cap/database/auth/session";
+import { eq } from "drizzle-orm";
+import { cookies } from "next/headers";
+
+const allowedOrigins = [
+ process.env.NEXT_PUBLIC_URL,
+ "http://localhost:3001",
+ "http://localhost:3000",
+ "tauri://localhost",
+ "http://tauri.localhost",
+ "https://tauri.localhost",
+];
+
+export async function OPTIONS(req: NextRequest) {
+ const params = req.nextUrl.searchParams;
+ const origin = params.get("origin") || null;
+ const originalOrigin = req.nextUrl.origin;
+
+ return new Response(null, {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers":
+ "Content-Type, Authorization, sentry-trace, baggage",
+ },
+ });
+}
+
+export async function GET(request: NextRequest) {
+ const token = request.headers.get("authorization")?.split(" ")[1];
+ if (token) {
+ cookies().set({
+ name: "next-auth.session-token",
+ value: token,
+ path: "/",
+ sameSite: "none",
+ secure: true,
+ httpOnly: true,
+ });
+ }
+
+ const params = request.nextUrl.searchParams;
+ const origin = params.get("origin") || null;
+ const originalOrigin = request.nextUrl.origin;
+
+ try {
+ const user = await getCurrentUser();
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
+ }
+
+ const config = await db
+ .select({
+ accessKeyId: s3Buckets.accessKeyId,
+ secretAccessKey: s3Buckets.secretAccessKey,
+ endpoint: s3Buckets.endpoint,
+ bucketName: s3Buckets.bucketName,
+ region: s3Buckets.region,
+ })
+ .from(s3Buckets)
+ .where(eq(s3Buckets.ownerId, user.id));
+
+ return new Response(
+ JSON.stringify({
+ config: config[0] || null,
+ }),
+ {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ }
+ );
+ } catch (error) {
+ console.error("Error fetching S3 config:", error);
+ return new Response(
+ JSON.stringify({ error: "Failed to fetch S3 configuration" }),
+ {
+ status: 500,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/apps/web/app/api/desktop/s3/config/route.ts b/apps/web/app/api/desktop/s3/config/route.ts
new file mode 100644
index 000000000..e8ec9c392
--- /dev/null
+++ b/apps/web/app/api/desktop/s3/config/route.ts
@@ -0,0 +1,187 @@
+import { type NextRequest } from "next/server";
+import { db } from "@cap/database";
+import { s3Buckets, users } from "@cap/database/schema";
+import { getCurrentUser } from "@cap/database/auth/session";
+import { eq } from "drizzle-orm";
+import { nanoId } from "@cap/database/helpers";
+import { cookies } from "next/headers";
+
+const allowedOrigins = [
+ process.env.NEXT_PUBLIC_URL,
+ "http://localhost:3001",
+ "http://localhost:3000",
+ "tauri://localhost",
+ "http://tauri.localhost",
+ "https://tauri.localhost",
+];
+
+export async function OPTIONS(req: NextRequest) {
+ const params = req.nextUrl.searchParams;
+ const origin = params.get("origin") || null;
+ const originalOrigin = req.nextUrl.origin;
+
+ console.log("Handling OPTIONS request");
+ console.log("OPTIONS request params:", { origin, originalOrigin });
+
+ return new Response(null, {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers":
+ "Content-Type, Authorization, sentry-trace, baggage",
+ },
+ });
+}
+
+export async function POST(request: NextRequest) {
+ console.log("Handling POST request");
+
+ const token = request.headers.get("authorization")?.split(" ")[1];
+ if (token) {
+ console.log("Setting session token cookie");
+ cookies().set({
+ name: "next-auth.session-token",
+ value: token,
+ path: "/",
+ sameSite: "none",
+ secure: true,
+ httpOnly: true,
+ });
+ }
+
+ const params = request.nextUrl.searchParams;
+ const origin = params.get("origin") || null;
+ const originalOrigin = request.nextUrl.origin;
+
+ console.log("POST request params:", { origin, originalOrigin });
+
+ try {
+ console.log("Attempting to save S3 configuration");
+ const user = await getCurrentUser();
+ if (!user) {
+ console.log("User not authenticated");
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
+ }
+
+ console.log("User authenticated:", user.id);
+
+ const body = await request.json();
+ const { accessKeyId, secretAccessKey, endpoint, bucketName, region } = body;
+
+ console.log("Received S3 config request:", { endpoint, bucketName, region });
+
+ // Validate required fields
+ if (!accessKeyId || !secretAccessKey || !bucketName) {
+ return new Response(
+ JSON.stringify({ error: "Missing required fields" }),
+ {
+ status: 400,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ }
+ );
+ }
+
+ console.log("Checking for existing S3 configuration");
+ const existingConfigs = await db
+ .select()
+ .from(s3Buckets)
+ .where(eq(s3Buckets.ownerId, user.id));
+
+ const existingConfig = existingConfigs[0];
+ console.log("Existing config found:", !!existingConfig);
+
+ let bucketId: string;
+ if (existingConfig) {
+ bucketId = existingConfig.id;
+ console.log("Updating existing S3 configuration");
+ await db
+ .update(s3Buckets)
+ .set({
+ accessKeyId,
+ secretAccessKey,
+ endpoint,
+ bucketName,
+ region,
+ })
+ .where(eq(s3Buckets.id, bucketId));
+ } else {
+ console.log("Creating new S3 configuration for user:", user.id);
+ bucketId = nanoId();
+ await db.insert(s3Buckets).values({
+ id: bucketId,
+ ownerId: user.id,
+ region: region || "us-east-1",
+ endpoint,
+ bucketName,
+ accessKeyId,
+ secretAccessKey,
+ });
+ console.log("Successfully created new S3 configuration");
+ }
+
+ console.log("Updating user's customBucket field");
+ await db
+ .update(users)
+ .set({
+ customBucket: bucketId,
+ })
+ .where(eq(users.id, user.id));
+
+ console.log("S3 configuration saved successfully");
+ return new Response(JSON.stringify({ success: true }), {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
+ } catch (error) {
+ console.error("Error saving S3 config:", error);
+ return new Response(
+ JSON.stringify({ error: "Failed to save S3 configuration" }),
+ {
+ status: 500,
+ headers: {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts
index 978386591..2ef6a89af 100644
--- a/packages/ui-solid/src/auto-imports.d.ts
+++ b/packages/ui-solid/src/auto-imports.d.ts
@@ -6,6 +6,7 @@
// biome-ignore lint: disable
export {}
declare global {
+ const IconCapApps: typeof import('~icons/cap/apps.jsx')['default']
const IconCapArrows: typeof import('~icons/cap/arrows.jsx')['default']
const IconCapAudioOn: typeof import('~icons/cap/audio-on.jsx')['default']
const IconCapBlur: typeof import('~icons/cap/blur.jsx')['default']
@@ -46,8 +47,12 @@ declare global {
const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default']
const IconCapUpload: typeof import('~icons/cap/upload.jsx')['default']
const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default']
+ const IconLucideCable: typeof import('~icons/lucide/cable.jsx')['default']
const IconLucideCamera: typeof import('~icons/lucide/camera.jsx')['default']
const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default']
+ const IconLucideCloud: typeof import('~icons/lucide/cloud.jsx')['default']
+ const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default']
+ const IconLucideDevice: typeof import('~icons/lucide/device.jsx')['default']
const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default']
const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default']
const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default']
@@ -56,6 +61,7 @@ declare global {
const IconLucideRabbit: typeof import('~icons/lucide/rabbit.jsx')['default']
const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default']
const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default']
+ const IconLucideSettings: typeof import('~icons/lucide/settings.jsx')['default']
const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default']
const IconLucideVolume2: typeof import('~icons/lucide/volume2.jsx')['default']
const IconLucideVolumeX: typeof import('~icons/lucide/volume-x.jsx')['default']
From 4aca804b6cec752450faf71261567d8c5cb2978e Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Fri, 22 Nov 2024 16:52:07 +0000
Subject: [PATCH 3/7] feat: Tidy up S3 config stuff (e.g. use v.cap.so still if
not custom bucket)
---
.env.example | 2 +-
apps/desktop/src-tauri/src/lib.rs | 3 +-
apps/desktop/src-tauri/src/windows.rs | 10 +-
.../src/routes/(window-chrome)/(main).tsx | 20 +++-
.../view/[videoId]/_components/ShareVideo.tsx | 27 +++--
apps/embed/next.config.mjs | 20 +---
apps/web/app/api/playlist/route.ts | 32 ++++-
apps/web/app/api/screenshot/route.ts | 30 ++++-
apps/web/app/api/thumbnail/route.ts | 68 ++++++++---
apps/web/app/api/video/[videoId]/route.ts | 41 +++++++
apps/web/app/api/video/og/route.tsx | 112 +++++++++---------
.../[videoId]/_components/MP4VideoPlayer.tsx | 32 ++---
.../s/[videoId]/_components/ShareVideo.tsx | 25 ++--
apps/web/next.config.mjs | 20 +---
14 files changed, 291 insertions(+), 151 deletions(-)
create mode 100644 apps/web/app/api/video/[videoId]/route.ts
diff --git a/.env.example b/.env.example
index 6ed4f524e..55ea26031 100644
--- a/.env.example
+++ b/.env.example
@@ -74,4 +74,4 @@ CAP_DESKTOP_SENTRY_URL=https://efd3156d9c0a8a49bee3ee675bec80d8@o450685977152716
# Google OAuth
GOOGLE_CLIENT_ID="your-google-client-id"
-GOOGLE_CLIENT_SECRET="your-google-client-secret"
+GOOGLE_CLIENT_SECRET="your-google-client-secret"
\ No newline at end of file
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 99219cbe4..e0a8980c3 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -1536,12 +1536,11 @@ async fn delete_auth_open_signin(app: AppHandle) -> Result<(), String> {
if let Some(window) = CapWindowId::Camera.get(&app) {
window.close().ok();
}
-
if let Some(window) = CapWindowId::Main.get(&app) {
window.close().ok();
}
- while CapWindowId::Main.get(&app).is_some() {
+ while CapWindowId::Main.get(&app).is_none() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs
index 1ecadcd79..48f887b9b 100644
--- a/apps/desktop/src-tauri/src/windows.rs
+++ b/apps/desktop/src-tauri/src/windows.rs
@@ -414,12 +414,12 @@ fn position_traffic_lights_impl(
let c_win = window.clone();
window
.run_on_main_thread(move || {
+ let ns_window = match c_win.ns_window() {
+ Ok(handle) => handle,
+ Err(_) => return,
+ };
position_window_controls(
- UnsafeWindowHandle(
- c_win
- .ns_window()
- .expect("Failed to get native window handle"),
- ),
+ UnsafeWindowHandle(ns_window),
&controls_inset.unwrap_or(DEFAULT_TRAFFIC_LIGHTS_INSET),
);
})
diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx
index 07013faf1..bfe9bd0e3 100644
--- a/apps/desktop/src/routes/(window-chrome)/(main).tsx
+++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx
@@ -78,6 +78,8 @@ export default function () {
},
}));
+ const [isUpgraded] = createResource(() => commands.checkUpgradedAndUpdate());
+
createAsync(() => getAuth());
createUpdateCheck();
@@ -118,7 +120,23 @@ export default function () {
return (
-
+
+
+ {
+ if (!isUpgraded()) {
+ await commands.showWindow("Upgrade");
+ }
+ }}
+ class={`text-[0.625rem] ${
+ isUpgraded()
+ ? "bg-blue-400 text-gray-50"
+ : "bg-gray-200 text-gray-400 cursor-pointer hover:bg-gray-300"
+ } rounded-lg px-1.5 py-0.5`}
+ >
+ {isUpgraded() ? "Pro" : "Free"}
+
+
diff --git a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
index 56c46e93e..f70f5f84d 100644
--- a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
+++ b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
@@ -83,29 +83,38 @@ export const ShareVideo = ({
useEffect(() => {
if (videoMetadataLoaded) {
- console.log("Metadata loaded");
setIsLoading(false);
}
}, [videoMetadataLoaded]);
useEffect(() => {
const onVideoLoadedMetadata = () => {
- console.log("Video metadata loaded");
- setVideoMetadataLoaded(true);
if (videoRef.current) {
setLongestDuration(videoRef.current.duration);
+ setVideoMetadataLoaded(true);
+ setIsLoading(false);
}
};
- const videoElement = videoRef.current;
+ const onCanPlay = () => {
+ setVideoMetadataLoaded(true);
+ setIsLoading(false);
+ };
- videoElement?.addEventListener("loadedmetadata", onVideoLoadedMetadata);
+ const videoElement = videoRef.current;
+ if (videoElement) {
+ videoElement.addEventListener("loadedmetadata", onVideoLoadedMetadata);
+ videoElement.addEventListener("canplay", onCanPlay);
+ }
return () => {
- videoElement?.removeEventListener(
- "loadedmetadata",
- onVideoLoadedMetadata
- );
+ if (videoElement) {
+ videoElement.removeEventListener(
+ "loadedmetadata",
+ onVideoLoadedMetadata
+ );
+ videoElement.removeEventListener("canplay", onCanPlay);
+ }
};
}, []);
diff --git a/apps/embed/next.config.mjs b/apps/embed/next.config.mjs
index 488d3b215..d360e34a0 100644
--- a/apps/embed/next.config.mjs
+++ b/apps/embed/next.config.mjs
@@ -34,25 +34,7 @@ const nextConfig = {
remotePatterns: [
{
protocol: "https",
- hostname: "*.amazonaws.com",
- port: "",
- pathname: "**",
- },
- {
- protocol: "https",
- hostname: "*.cloudfront.net",
- port: "",
- pathname: "**",
- },
- {
- protocol: "https",
- hostname: "*v.cap.so",
- port: "",
- pathname: "**",
- },
- {
- protocol: "https",
- hostname: "*tasks.cap.so",
+ hostname: "**",
port: "",
pathname: "**",
},
diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts
index 9b5e04605..c21841f60 100644
--- a/apps/web/app/api/playlist/route.ts
+++ b/apps/web/app/api/playlist/route.ts
@@ -78,6 +78,37 @@ export async function GET(request: NextRequest) {
}
}
+ if (!bucket || video.awsBucket === process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) {
+ if (video.source.type === "desktopMP4") {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ ...getHeaders(origin),
+ Location: `https://v.cap.so/${userId}/${videoId}/result.mp4`,
+ },
+ });
+ }
+
+ if (video.source.type === "MediaConvert") {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ ...getHeaders(origin),
+ Location: `https://v.cap.so/${userId}/${videoId}/output/video_recording_000.m3u8`,
+ },
+ });
+ }
+
+ const playlistUrl = `https://v.cap.so/${userId}/${videoId}/combined-source/stream.m3u8`;
+ return new Response(null, {
+ status: 302,
+ headers: {
+ ...getHeaders(origin),
+ Location: playlistUrl,
+ },
+ });
+ }
+
const Bucket = getS3Bucket(bucket);
const videoPrefix = `${userId}/${videoId}/video/`;
const audioPrefix = `${userId}/${videoId}/audio/`;
@@ -129,7 +160,6 @@ export async function GET(request: NextRequest) {
{ expiresIn: 3600 }
);
- console.log({ playlistUrl });
return new Response(null, {
status: 302,
headers: {
diff --git a/apps/web/app/api/screenshot/route.ts b/apps/web/app/api/screenshot/route.ts
index 167a6ab8d..4390806ca 100644
--- a/apps/web/app/api/screenshot/route.ts
+++ b/apps/web/app/api/screenshot/route.ts
@@ -2,12 +2,13 @@ import { type NextRequest } from "next/server";
import { db } from "@cap/database";
import { s3Buckets, videos } from "@cap/database/schema";
import { eq } from "drizzle-orm";
-import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
+import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getCurrentUser } from "@cap/database/auth/session";
import { getHeaders } from "@/utils/helpers";
import { createS3Client, getS3Bucket } from "@/utils/s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
-export const revalidate = 3599;
+export const revalidate = 0;
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get("origin") as string;
@@ -47,7 +48,15 @@ export async function GET(request: NextRequest) {
);
}
- const { video, bucket } = query[0];
+ const result = query[0];
+ if (!result?.video || !result?.bucket) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video or bucket not found" }),
+ { status: 401, headers: getHeaders(origin) }
+ );
+ }
+
+ const { video, bucket } = result;
if (video.public === false) {
const user = await getCurrentUser();
@@ -85,7 +94,20 @@ export async function GET(request: NextRequest) {
);
}
- const screenshotUrl = `https://v.cap.so/${screenshot.Key}`;
+ let screenshotUrl: string;
+
+ if (video.awsBucket !== process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) {
+ screenshotUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket,
+ Key: screenshot.Key
+ }),
+ { expiresIn: 3600 }
+ );
+ } else {
+ screenshotUrl = `https://v.cap.so/${screenshot.Key}`;
+ }
return new Response(JSON.stringify({ url: screenshotUrl }), {
status: 200,
diff --git a/apps/web/app/api/thumbnail/route.ts b/apps/web/app/api/thumbnail/route.ts
index bbfb3ac05..be5afb63b 100644
--- a/apps/web/app/api/thumbnail/route.ts
+++ b/apps/web/app/api/thumbnail/route.ts
@@ -1,12 +1,13 @@
import type { NextRequest } from "next/server";
-import { ListObjectsV2Command } from "@aws-sdk/client-s3";
+import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { getHeaders } from "@/utils/helpers";
import { db } from "@cap/database";
import { eq } from "drizzle-orm";
import { s3Buckets, videos } from "@cap/database/schema";
import { createS3Client, getS3Bucket } from "@/utils/s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
-export const revalidate = 3500;
+export const revalidate = 0;
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
@@ -46,12 +47,33 @@ export async function GET(request: NextRequest) {
);
}
- const { video, bucket } = query[0];
- const Bucket = getS3Bucket(bucket);
+ const result = query[0];
+ if (!result?.video) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video not found" }),
+ {
+ status: 401,
+ headers: getHeaders(origin),
+ }
+ );
+ }
- const s3Client = createS3Client(bucket);
+ const { video } = result;
const prefix = `${userId}/${videoId}/`;
+ let thumbnailUrl: string;
+
+ if (!result.bucket || video.awsBucket === process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) {
+ thumbnailUrl = `https://v.cap.so/${prefix}screenshot/screen-capture.jpg`;
+ return new Response(JSON.stringify({ screen: thumbnailUrl }), {
+ status: 200,
+ headers: getHeaders(origin),
+ });
+ }
+
+ const Bucket = getS3Bucket(result.bucket);
+ const s3Client = createS3Client(result.bucket);
+
try {
const listCommand = new ListObjectsV2Command({
Bucket,
@@ -61,24 +83,37 @@ export async function GET(request: NextRequest) {
const listResponse = await s3Client.send(listCommand);
const contents = listResponse.Contents || [];
- let thumbnailKey = contents.find((item) => item.Key?.endsWith(".png"))?.Key;
+ const thumbnailKey = contents.find((item) =>
+ item.Key?.endsWith("screen-capture.jpg")
+ )?.Key;
if (!thumbnailKey) {
- thumbnailKey = `${prefix}screenshot/screen-capture.jpg`;
+ return new Response(
+ JSON.stringify({
+ error: true,
+ message: "No thumbnail found for this video",
+ }),
+ {
+ status: 404,
+ headers: getHeaders(origin),
+ }
+ );
}
- const thumbnailUrl = `https://v.cap.so/${thumbnailKey}`;
-
- return new Response(JSON.stringify({ screen: thumbnailUrl }), {
- status: 200,
- headers: getHeaders(origin),
- });
+ thumbnailUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket,
+ Key: thumbnailKey
+ }),
+ { expiresIn: 3600 }
+ );
} catch (error) {
- console.error("Error generating thumbnail URL:", error);
return new Response(
JSON.stringify({
error: true,
message: "Error generating thumbnail URL",
+ details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
@@ -86,4 +121,9 @@ export async function GET(request: NextRequest) {
}
);
}
+
+ return new Response(JSON.stringify({ screen: thumbnailUrl }), {
+ status: 200,
+ headers: getHeaders(origin),
+ });
}
diff --git a/apps/web/app/api/video/[videoId]/route.ts b/apps/web/app/api/video/[videoId]/route.ts
new file mode 100644
index 000000000..e2290f0cc
--- /dev/null
+++ b/apps/web/app/api/video/[videoId]/route.ts
@@ -0,0 +1,41 @@
+import { db } from "@cap/database";
+import { s3Buckets, videos } from "@cap/database/schema";
+import { eq } from "drizzle-orm";
+import { NextResponse } from "next/server";
+
+export async function GET(
+ request: Request,
+ { params }: { params: { videoId: string } }
+) {
+ const videoId = params.videoId;
+
+ const query = await db
+ .select({
+ video: videos,
+ bucket: s3Buckets,
+ })
+ .from(videos)
+ .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
+ .where(eq(videos.id, videoId));
+
+ if (query.length === 0) {
+ return NextResponse.json({ error: "Video not found" }, { status: 404 });
+ }
+
+ const result = query[0];
+ if (!result?.video) {
+ return NextResponse.json({ error: "Video not found" }, { status: 404 });
+ }
+
+ const defaultBucket = {
+ name: process.env.NEXT_PUBLIC_CAP_AWS_BUCKET,
+ region: process.env.NEXT_PUBLIC_CAP_AWS_REGION,
+ accessKeyId: process.env.CAP_AWS_ACCESS_KEY,
+ secretAccessKey: process.env.CAP_AWS_SECRET_KEY,
+ };
+
+ return NextResponse.json({
+ video: result.video,
+ bucket: result.bucket || defaultBucket,
+ });
+}
\ No newline at end of file
diff --git a/apps/web/app/api/video/og/route.tsx b/apps/web/app/api/video/og/route.tsx
index 2c7c256bb..5ac8459bd 100644
--- a/apps/web/app/api/video/og/route.tsx
+++ b/apps/web/app/api/video/og/route.tsx
@@ -1,6 +1,3 @@
-import { db } from "@cap/database";
-import { s3Buckets, videos } from "@cap/database/schema";
-import { eq } from "drizzle-orm";
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
@@ -11,25 +8,18 @@ export const runtime = "edge";
export async function GET(req: NextRequest) {
const videoId = req.nextUrl.searchParams.get("videoId") as string;
- const query = await db
- .select({
- video: videos,
- bucket: s3Buckets,
- })
- .from(videos)
- .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
- .where(eq(videos.id, videoId));
- type FileKey = {
- type: "screen";
- key: string;
- };
-
- type ResponseObject = {
- screen: string | null;
- };
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/api/video/${videoId}`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
- if (query.length === 0 || query?.[0]?.video.public === false) {
+ if (!response.ok) {
return new ImageResponse(
(
-
Video not found
+
Cap not found
+
+ The video you are looking for does not exist or has moved.
+
),
{
@@ -53,37 +47,47 @@ export async function GET(req: NextRequest) {
);
}
- const { video, bucket } = query[0];
+ const { video, bucket } = await response.json();
- const s3Client = createS3Client(bucket);
+ if (!video || !bucket || video.public === false) {
+ return new ImageResponse(
+ (
+
+
Video or bucket not found
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ }
+ );
+ }
+ const s3Client = createS3Client(bucket);
const Bucket = getS3Bucket(bucket);
- const fileKeys: FileKey[] = [
- {
- type: "screen",
- key: `${video.ownerId}/${video.id}/screenshot/screen-capture.jpg`,
- },
- ];
- const responseObject: ResponseObject = {
- screen: null,
- };
+ const screenshotKey = `${video.ownerId}/${video.id}/screenshot/screen-capture.jpg`;
+ let screenshotUrl = null;
- await Promise.all(
- fileKeys.map(async ({ type, key }) => {
- try {
- const url = await getSignedUrl(
- s3Client,
- new GetObjectCommand({ Bucket, Key: key }),
- { expiresIn: 3600 }
- );
- responseObject[type] = url;
- } catch (error) {
- console.error("Error generating URL for:", key, error);
- responseObject[type] = null;
- }
- })
- );
+ try {
+ screenshotUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({ Bucket, Key: screenshotKey }),
+ { expiresIn: 3600 }
+ );
+ } catch (error) {
+ console.error("Error generating URL for screenshot:", error);
+ }
return new ImageResponse(
(
@@ -95,7 +99,7 @@ export async function GET(req: NextRequest) {
alignItems: "center",
justifyContent: "center",
background:
- "radial-gradient(90.01% 80.01% at 53.53% 49.99%,#d3e5ff 30.65%,#82c6f1 88.48%,#fff 100%)",
+ "radial-gradient(90.01% 80.01% at 53.53% 49.99%,#d3e5ff 30.65%,#4785ff 88.48%,#fff 100%)",
}}
>
- {responseObject.screen && (
+ {screenshotUrl && (
)}
diff --git a/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx
index 266bbf6ee..4c8e0990a 100644
--- a/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx
+++ b/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx
@@ -21,24 +21,26 @@ export const MP4VideoPlayer = memo(
const video = videoRef.current;
if (!video) return;
- const startTime = Date.now();
- const maxDuration = 2 * 60 * 1000;
-
- const checkAndReload = () => {
- if (video.readyState === 0) {
- // HAVE_NOTHING
- video.load();
- }
-
- if (Date.now() - startTime < maxDuration) {
- setTimeout(checkAndReload, 3000);
- }
+ const handleLoadedMetadata = () => {
+ // Trigger a canplay event after metadata is loaded
+ video.dispatchEvent(new Event("canplay"));
};
- checkAndReload();
+ const handleError = (e: ErrorEvent) => {
+ console.error("Video loading error:", e);
+ // Attempt to reload on error
+ video.load();
+ };
+
+ video.addEventListener("loadedmetadata", handleLoadedMetadata);
+ video.addEventListener("error", handleError as EventListener);
+
+ // Initial load
+ video.load();
return () => {
- clearTimeout(checkAndReload as unknown as number);
+ video.removeEventListener("loadedmetadata", handleLoadedMetadata);
+ video.removeEventListener("error", handleError as EventListener);
};
}, [videoSrc]);
@@ -47,7 +49,7 @@ export const MP4VideoPlayer = memo(
id="video-player"
ref={videoRef}
className="w-full h-full object-contain"
- preload="metadata"
+ preload="auto"
playsInline
controls={false}
muted
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
index ac130c638..782d60329 100644
--- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
+++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
@@ -139,21 +139,32 @@ export const ShareVideo = ({
useEffect(() => {
const onVideoLoadedMetadata = () => {
- setVideoMetadataLoaded(true);
if (videoRef.current) {
setLongestDuration(videoRef.current.duration);
+ setVideoMetadataLoaded(true);
+ setIsLoading(false);
}
};
- const videoElement = videoRef.current;
+ const onCanPlay = () => {
+ setVideoMetadataLoaded(true);
+ setIsLoading(false);
+ };
- videoElement?.addEventListener("loadedmetadata", onVideoLoadedMetadata);
+ const videoElement = videoRef.current;
+ if (videoElement) {
+ videoElement.addEventListener("loadedmetadata", onVideoLoadedMetadata);
+ videoElement.addEventListener("canplay", onCanPlay);
+ }
return () => {
- videoElement?.removeEventListener(
- "loadedmetadata",
- onVideoLoadedMetadata
- );
+ if (videoElement) {
+ videoElement.removeEventListener(
+ "loadedmetadata",
+ onVideoLoadedMetadata
+ );
+ videoElement.removeEventListener("canplay", onCanPlay);
+ }
};
}, []);
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index 583d784dc..4c2c888c4 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -38,25 +38,7 @@ const nextConfig = {
remotePatterns: [
{
protocol: "https",
- hostname: "*.amazonaws.com",
- port: "",
- pathname: "**",
- },
- {
- protocol: "https",
- hostname: "*.cloudfront.net",
- port: "",
- pathname: "**",
- },
- {
- protocol: "https",
- hostname: "*v.cap.so",
- port: "",
- pathname: "**",
- },
- {
- protocol: "https",
- hostname: "*tasks.cap.so",
+ hostname: "**",
port: "",
pathname: "**",
},
From ea80881566d9dd9ee031e0924fbc264758271166 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Sat, 23 Nov 2024 12:56:56 +0000
Subject: [PATCH 4/7] feat: Encrypt/decrypt S3 keys
---
.env.example | 2 +
apps/desktop/src-tauri/src/lib.rs | 5 +-
.../src/routes/(window-chrome)/(main).tsx | 2 +-
.../src/routes/(window-chrome)/settings.tsx | 2 +-
.../settings/apps/s3-config.tsx | 16 +-
.../app/api/desktop/s3/config/get/route.ts | 14 +-
apps/web/app/api/desktop/s3/config/route.ts | 189 ++++++------------
apps/web/app/api/upload/signed/route.ts | 105 ++++++----
apps/web/utils/cors.ts | 29 +++
apps/web/utils/s3.ts | 38 +++-
packages/database/crypto.ts | 87 ++++++++
packages/database/schema.ts | 24 ++-
packages/ui-solid/src/auto-imports.d.ts | 4 +
13 files changed, 336 insertions(+), 181 deletions(-)
create mode 100644 apps/web/utils/cors.ts
create mode 100644 packages/database/crypto.ts
diff --git a/.env.example b/.env.example
index 55ea26031..6bcbb876d 100644
--- a/.env.example
+++ b/.env.example
@@ -38,7 +38,9 @@ NEXT_PUBLIC_LOCAL_MODE=false
# which will automatically be created on pnpm dev.
# It is used for local development only.
# For production, use the DATABASE_URL from your DB provider. Must include https for production use, mysql:// for local.
+# DATABASE_ENCRYPTION_KEY is used to encrypt sensitive data in the database (like S3 credentials). Not required for local development.
DATABASE_URL=mysql://root:@localhost:3306/planetscale
+DATABASE_ENCRYPTION_KEY=
# This is the secret for the NextAuth.js authentication library.
# It is used for local development only.
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index e0a8980c3..cec1e300c 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -1500,7 +1500,10 @@ fn list_screenshots(app: AppHandle) -> Result
Result {
if let Err(e) = AuthStore::fetch_and_update_plan(&app).await {
- return Err(format!("Failed to update plan information: {}", e));
+ return Err(format!(
+ "Failed to update plan information. Try signing out and signing back in: {}",
+ e
+ ));
}
let auth = AuthStore::get(&app).map_err(|e| e.to_string())?;
diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx
index bfe9bd0e3..3cf4f29c4 100644
--- a/apps/desktop/src/routes/(window-chrome)/(main).tsx
+++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx
@@ -146,7 +146,7 @@ export default function () {
commands.showWindow({ Settings: { page: "apps" } })
}
>
-
+
diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx
index 29e9bf9e8..e09384e03 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx
@@ -33,7 +33,7 @@ export default function Settings(props: RouteSectionProps) {
{
href: "apps",
name: "Cap Apps",
- icon: IconLucideCable,
+ icon: IconLucideLayoutGrid,
},
{
href: "feedback",
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
index 887586faf..346933534 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
@@ -120,10 +120,24 @@ export default function S3ConfigPage() {
) : (
+
+
+ It should take under 10 minutes to set up and connect your S3
+ bucket to Cap. View the{" "}
+
+ S3 Config Guide
+ {" "}
+ to get started.
+
+
Access Key ID
0 });
- let bucketId: string;
- if (existingConfig) {
- bucketId = existingConfig.id;
- console.log("Updating existing S3 configuration");
- await db
- .update(s3Buckets)
- .set({
- accessKeyId,
- secretAccessKey,
- endpoint,
- bucketName,
- region,
- })
- .where(eq(s3Buckets.id, bucketId));
- } else {
- console.log("Creating new S3 configuration for user:", user.id);
- bucketId = nanoId();
- await db.insert(s3Buckets).values({
- id: bucketId,
- ownerId: user.id,
- region: region || "us-east-1",
- endpoint,
- bucketName,
- accessKeyId,
- secretAccessKey,
- });
- console.log("Successfully created new S3 configuration");
- }
+ // Encrypt sensitive data before storing
+ const encryptedData = {
+ id: existingBucket[0]?.id || nanoId(),
+ accessKeyId: encrypt(accessKeyId),
+ secretAccessKey: encrypt(secretAccessKey),
+ endpoint: endpoint ? encrypt(endpoint) : null,
+ bucketName: encrypt(bucketName),
+ region: encrypt(region),
+ ownerId: user.id,
+ };
+
+ console.log("[S3 Config] Encrypted data prepared", { id: encryptedData.id });
- console.log("Updating user's customBucket field");
await db
- .update(users)
- .set({
- customBucket: bucketId,
- })
- .where(eq(users.id, user.id));
+ .insert(s3Buckets)
+ .values(encryptedData)
+ .onDuplicateKeyUpdate({
+ set: {
+ accessKeyId: encryptedData.accessKeyId,
+ secretAccessKey: encryptedData.secretAccessKey,
+ endpoint: encryptedData.endpoint,
+ bucketName: encryptedData.bucketName,
+ region: encryptedData.region,
+ },
+ });
+
+ console.log("[S3 Config] Successfully saved S3 configuration");
- console.log("S3 configuration saved successfully");
return new Response(JSON.stringify({ success: true }), {
status: 200,
- headers: {
- "Access-Control-Allow-Origin":
- origin && allowedOrigins.includes(origin)
- ? origin
- : allowedOrigins.includes(originalOrigin)
- ? originalOrigin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- },
+ headers: getCorsHeaders(origin, originalOrigin),
});
} catch (error) {
- console.error("Error saving S3 config:", error);
+ console.error("[S3 Config] Error saving S3 config:", error);
return new Response(
- JSON.stringify({ error: "Failed to save S3 configuration" }),
+ JSON.stringify({
+ error: "Failed to save S3 configuration",
+ details: error instanceof Error ? error.message : 'Unknown error'
+ }),
{
status: 500,
- headers: {
- "Access-Control-Allow-Origin":
- origin && allowedOrigins.includes(origin)
- ? origin
- : allowedOrigins.includes(originalOrigin)
- ? originalOrigin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- },
+ headers: getCorsHeaders(origin, originalOrigin),
}
);
}
diff --git a/apps/web/app/api/upload/signed/route.ts b/apps/web/app/api/upload/signed/route.ts
index 4e834a685..7c07548ff 100644
--- a/apps/web/app/api/upload/signed/route.ts
+++ b/apps/web/app/api/upload/signed/route.ts
@@ -9,6 +9,7 @@ import { s3Buckets } from "@cap/database/schema";
import { eq } from "drizzle-orm";
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
+import { decrypt } from "@cap/database/crypto";
export async function POST(request: NextRequest) {
try {
@@ -17,7 +18,6 @@ export async function POST(request: NextRequest) {
if (!fileKey) {
console.error("Missing required fields in /api/upload/signed/route.ts");
-
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
@@ -53,51 +53,82 @@ export async function POST(request: NextRequest) {
});
}
- const [bucket] = await db
- .select()
- .from(s3Buckets)
- .where(eq(s3Buckets.ownerId, user.id));
+ try {
+ const [bucket] = await db
+ .select()
+ .from(s3Buckets)
+ .where(eq(s3Buckets.ownerId, user.id));
+
+ // Create a decrypted config for S3 client
+ const s3Config = bucket ? {
+ endpoint: bucket.endpoint || undefined,
+ region: bucket.region,
+ accessKeyId: bucket.accessKeyId,
+ secretAccessKey: bucket.secretAccessKey,
+ } : null;
- const s3Client = createS3Client(bucket);
+ console.log("Creating S3 client with config:", {
+ hasEndpoint: !!s3Config?.endpoint,
+ hasRegion: !!s3Config?.region,
+ hasAccessKey: !!s3Config?.accessKeyId,
+ hasSecretKey: !!s3Config?.secretAccessKey,
+ });
- const contentType = fileKey.endsWith(".aac")
- ? "audio/aac"
- : fileKey.endsWith(".webm")
- ? "audio/webm"
- : fileKey.endsWith(".mp4")
- ? "video/mp4"
- : fileKey.endsWith(".mp3")
- ? "audio/mpeg"
- : fileKey.endsWith(".m3u8")
- ? "application/x-mpegURL"
- : "video/mp2t";
+ const s3Client = createS3Client(s3Config);
- const Fields = {
- "Content-Type": contentType,
- "x-amz-meta-userid": user.id,
- "x-amz-meta-duration": duration ?? "",
- "x-amz-meta-bandwidth": bandwidth ?? "",
- "x-amz-meta-resolution": resolution ?? "",
- "x-amz-meta-videocodec": videoCodec ?? "",
- "x-amz-meta-audiocodec": audioCodec ?? "",
- };
+ const contentType = fileKey.endsWith(".aac")
+ ? "audio/aac"
+ : fileKey.endsWith(".webm")
+ ? "audio/webm"
+ : fileKey.endsWith(".mp4")
+ ? "video/mp4"
+ : fileKey.endsWith(".mp3")
+ ? "audio/mpeg"
+ : fileKey.endsWith(".m3u8")
+ ? "application/x-mpegURL"
+ : "video/mp2t";
- const presignedPostData: PresignedPost = await createPresignedPost(
- s3Client,
- { Bucket: getS3Bucket(bucket), Key: fileKey, Fields, Expires: 1800 }
- );
+ const Fields = {
+ "Content-Type": contentType,
+ "x-amz-meta-userid": user.id,
+ "x-amz-meta-duration": duration ?? "",
+ "x-amz-meta-bandwidth": bandwidth ?? "",
+ "x-amz-meta-resolution": resolution ?? "",
+ "x-amz-meta-videocodec": videoCodec ?? "",
+ "x-amz-meta-audiocodec": audioCodec ?? "",
+ };
- console.log("Presigned URL created successfully");
+ const bucketName = getS3Bucket(bucket);
+ console.log("Using bucket:", bucketName);
+
+ const presignedPostData: PresignedPost = await createPresignedPost(
+ s3Client,
+ {
+ Bucket: bucketName,
+ Key: fileKey,
+ Fields,
+ Expires: 1800
+ }
+ );
- return new Response(JSON.stringify({ presignedPostData }), {
- headers: {
- "Content-Type": "application/json",
- },
- });
+ console.log("Presigned URL created successfully");
+
+ return new Response(JSON.stringify({ presignedPostData }), {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ } catch (s3Error) {
+ console.error("S3 operation failed:", s3Error);
+ throw new Error(`S3 operation failed: ${s3Error instanceof Error ? s3Error.message : 'Unknown error'}`);
+ }
} catch (error) {
console.error("Error creating presigned URL", error);
return new Response(
- JSON.stringify({ error: "Error creating presigned URL" }),
+ JSON.stringify({
+ error: "Error creating presigned URL",
+ details: error instanceof Error ? error.message : String(error)
+ }),
{
status: 500,
headers: {
diff --git a/apps/web/utils/cors.ts b/apps/web/utils/cors.ts
new file mode 100644
index 000000000..55b181aac
--- /dev/null
+++ b/apps/web/utils/cors.ts
@@ -0,0 +1,29 @@
+export const allowedOrigins = [
+ process.env.NEXT_PUBLIC_URL,
+ "http://localhost:3001",
+ "http://localhost:3000",
+ "tauri://localhost",
+ "http://tauri.localhost",
+ "https://tauri.localhost",
+];
+
+export function getCorsHeaders(origin: string | null, originalOrigin: string) {
+ return {
+ "Access-Control-Allow-Origin":
+ origin && allowedOrigins.includes(origin)
+ ? origin
+ : allowedOrigins.includes(originalOrigin)
+ ? originalOrigin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ };
+}
+
+export function getOptionsHeaders(origin: string | null, originalOrigin: string, methods = "GET, OPTIONS") {
+ return {
+ ...getCorsHeaders(origin, originalOrigin),
+ "Access-Control-Allow-Methods": methods,
+ "Access-Control-Allow-Headers":
+ "Content-Type, Authorization, sentry-trace, baggage",
+ };
+}
\ No newline at end of file
diff --git a/apps/web/utils/s3.ts b/apps/web/utils/s3.ts
index 80c571fbf..7bcfe842c 100644
--- a/apps/web/utils/s3.ts
+++ b/apps/web/utils/s3.ts
@@ -1,6 +1,7 @@
import { S3Client } from "@aws-sdk/client-s3";
import type { s3Buckets } from "@cap/database/schema";
import type { InferSelectModel } from "drizzle-orm";
+import { decrypt } from "@cap/database/crypto";
type S3Config = {
endpoint?: string | null;
@@ -13,14 +14,35 @@ export function createS3Client(config?: S3Config) {
return new S3Client(getS3Config(config));
}
+function tryDecrypt(text: string | null | undefined): string | undefined {
+ if (!text) return undefined;
+ try {
+ return decrypt(text);
+ } catch (error) {
+ // If decryption fails, assume the data is not encrypted yet
+ console.log("Decryption failed, using original value");
+ return text;
+ }
+}
+
export function getS3Config(config?: S3Config) {
+ if (!config) {
+ return {
+ endpoint: process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT,
+ region: process.env.NEXT_PUBLIC_CAP_AWS_REGION,
+ credentials: {
+ accessKeyId: process.env.CAP_AWS_ACCESS_KEY ?? "",
+ secretAccessKey: process.env.CAP_AWS_SECRET_KEY ?? "",
+ },
+ };
+ }
+
return {
- endpoint: config?.endpoint ?? process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT,
- region: config?.region ?? process.env.NEXT_PUBLIC_CAP_AWS_REGION,
+ endpoint: config.endpoint ? tryDecrypt(config.endpoint) : process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT,
+ region: tryDecrypt(config.region) ?? process.env.NEXT_PUBLIC_CAP_AWS_REGION,
credentials: {
- accessKeyId: config?.accessKeyId ?? process.env.CAP_AWS_ACCESS_KEY ?? "",
- secretAccessKey:
- config?.secretAccessKey ?? process.env.CAP_AWS_SECRET_KEY ?? "",
+ accessKeyId: tryDecrypt(config.accessKeyId) ?? process.env.CAP_AWS_ACCESS_KEY ?? "",
+ secretAccessKey: tryDecrypt(config.secretAccessKey) ?? process.env.CAP_AWS_SECRET_KEY ?? "",
},
};
}
@@ -28,5 +50,9 @@ export function getS3Config(config?: S3Config) {
export function getS3Bucket(
bucket?: InferSelectModel
| null
) {
- return bucket?.bucketName ?? (process.env.NEXT_PUBLIC_CAP_AWS_BUCKET || "");
+ if (!bucket?.bucketName) {
+ return process.env.NEXT_PUBLIC_CAP_AWS_BUCKET || "";
+ }
+
+ return (tryDecrypt(bucket.bucketName) ?? process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) || "";
}
diff --git a/packages/database/crypto.ts b/packages/database/crypto.ts
new file mode 100644
index 000000000..151f1af8d
--- /dev/null
+++ b/packages/database/crypto.ts
@@ -0,0 +1,87 @@
+import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
+
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 12;
+const SALT_LENGTH = 16;
+const TAG_LENGTH = 16;
+const KEY_LENGTH = 32;
+const ITERATIONS = 100000;
+
+const ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY as string;
+
+// Verify the encryption key is valid hex and correct length
+try {
+ const keyBuffer = Buffer.from(ENCRYPTION_KEY, 'hex');
+ if (keyBuffer.length !== KEY_LENGTH) {
+ throw new Error(`Encryption key must be ${KEY_LENGTH} bytes (${KEY_LENGTH * 2} hex characters)`);
+ }
+} catch (error: unknown) {
+ if (error instanceof Error) {
+ throw new Error(`Invalid encryption key format: ${error.message}`);
+ }
+ throw new Error('Invalid encryption key format');
+}
+
+function deriveKey(salt: Buffer): Buffer {
+ return pbkdf2Sync(
+ ENCRYPTION_KEY,
+ salt,
+ ITERATIONS,
+ KEY_LENGTH,
+ 'sha256'
+ );
+}
+
+export function encrypt(text: string): string {
+ if (!text) {
+ throw new Error('Cannot encrypt empty or null text');
+ }
+
+ try {
+ const salt = randomBytes(SALT_LENGTH);
+ const iv = randomBytes(IV_LENGTH);
+ const key = deriveKey(salt);
+
+ const cipher = createCipheriv(ALGORITHM, key, iv);
+ const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+
+ // Combine salt, IV, tag, and encrypted content
+ const result = Buffer.concat([salt, iv, tag, encrypted]);
+ return result.toString('base64');
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ throw new Error(`Encryption failed: ${error.message}`);
+ }
+ throw new Error('Encryption failed');
+ }
+}
+
+export function decrypt(encryptedText: string): string {
+ if (!encryptedText) {
+ throw new Error('Cannot decrypt empty or null text');
+ }
+
+ try {
+ const encrypted = Buffer.from(encryptedText, 'base64');
+
+ // Extract the components
+ const salt = encrypted.subarray(0, SALT_LENGTH);
+ const iv = encrypted.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
+ const tag = encrypted.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + TAG_LENGTH);
+ const content = encrypted.subarray(SALT_LENGTH + IV_LENGTH + TAG_LENGTH);
+
+ // Derive the same key using the extracted salt
+ const key = deriveKey(salt);
+
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
+ decipher.setAuthTag(tag);
+ const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
+ return decrypted.toString('utf8');
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ throw new Error(`Decryption failed: ${error.message}`);
+ }
+ throw new Error('Decryption failed');
+ }
+}
\ No newline at end of file
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index ecca79e1b..d2c1b3117 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -27,6 +27,19 @@ const nanoIdNullable = customType<{ data: string; notNull: false }>({
},
});
+// Add a custom type for encrypted strings
+const encryptedText = customType<{ data: string; notNull: true }>({
+ dataType() {
+ return 'text';
+ },
+});
+
+const encryptedTextNullable = customType<{ data: string; notNull: false }>({
+ dataType() {
+ return 'text';
+ },
+});
+
export const users = mysqlTable(
"users",
{
@@ -252,11 +265,12 @@ export const comments = mysqlTable(
export const s3Buckets = mysqlTable("s3_buckets", {
id: nanoId("id").notNull().primaryKey().unique(),
ownerId: nanoId("ownerId").notNull(),
- region: varchar("region", { length: 255 }).notNull(),
- endpoint: text("endpoint"),
- bucketName: varchar("bucketName", { length: 255 }).notNull(),
- accessKeyId: varchar("accessKeyId", { length: 255 }).notNull(),
- secretAccessKey: varchar("secretAccessKey", { length: 255 }).notNull(),
+ // Use encryptedText for sensitive fields
+ region: encryptedText("region").notNull(),
+ endpoint: encryptedTextNullable("endpoint"),
+ bucketName: encryptedText("bucketName").notNull(),
+ accessKeyId: encryptedText("accessKeyId").notNull(),
+ secretAccessKey: encryptedText("secretAccessKey").notNull(),
});
export const commentsRelations = relations(comments, ({ one }) => ({
diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts
index 2ef6a89af..bff1f0e77 100644
--- a/packages/ui-solid/src/auto-imports.d.ts
+++ b/packages/ui-solid/src/auto-imports.d.ts
@@ -47,6 +47,8 @@ declare global {
const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default']
const IconCapUpload: typeof import('~icons/cap/upload.jsx')['default']
const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default']
+ const IconLucideBlocks: typeof import('~icons/lucide/blocks.jsx')['default']
+ const IconLucideBrickWall: typeof import('~icons/lucide/brick-wall.jsx')['default']
const IconLucideCable: typeof import('~icons/lucide/cable.jsx')['default']
const IconLucideCamera: typeof import('~icons/lucide/camera.jsx')['default']
const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default']
@@ -56,8 +58,10 @@ declare global {
const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default']
const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default']
const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default']
+ const IconLucideLayoutGrid: typeof import('~icons/lucide/layout-grid.jsx')['default']
const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default']
const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default']
+ const IconLucidePlugZap: typeof import('~icons/lucide/plug-zap.jsx')['default']
const IconLucideRabbit: typeof import('~icons/lucide/rabbit.jsx')['default']
const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default']
const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default']
From 1cef9e48b2316b57c66d4bbfeac1cab786b9fb8c Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Sat, 23 Nov 2024 14:32:47 +0000
Subject: [PATCH 5/7] feat: Add provider select
---
.../settings/apps/s3-config.tsx | 40 +++++++++++++++++--
.../app/api/desktop/s3/config/get/route.ts | 3 +-
apps/web/app/api/desktop/s3/config/route.ts | 5 ++-
apps/web/utils/s3.ts | 1 +
packages/database/schema.ts | 1 +
5 files changed, 45 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
index 346933534..b6a76f3fb 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
@@ -6,6 +6,7 @@ import { clientEnv } from "~/utils/env";
import { commands } from "~/utils/tauri";
interface S3Config {
+ provider: string;
accessKeyId: string;
secretAccessKey: string;
endpoint: string;
@@ -14,6 +15,7 @@ interface S3Config {
}
export default function S3ConfigPage() {
+ const [provider, setProvider] = createSignal("aws");
const [accessKeyId, setAccessKeyId] = createSignal("");
const [secretAccessKey, setSecretAccessKey] = createSignal("");
const [endpoint, setEndpoint] = createSignal("https://s3.amazonaws.com");
@@ -51,6 +53,7 @@ export default function S3ConfigPage() {
const data = await response.json();
if (data.config) {
const config = data.config as S3Config;
+ setProvider(config.provider || "aws");
setAccessKeyId(config.accessKeyId);
setSecretAccessKey(config.secretAccessKey);
setEndpoint(config.endpoint || "https://s3.amazonaws.com");
@@ -86,6 +89,7 @@ export default function S3ConfigPage() {
Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify({
+ provider: provider(),
accessKeyId: accessKeyId(),
secretAccessKey: secretAccessKey(),
endpoint: endpoint(),
@@ -122,18 +126,48 @@ export default function S3ConfigPage() {
- It should take under 10 minutes to set up and connect your S3
- bucket to Cap. View the{" "}
+ It should take under 10 minutes to set up and connect your
+ storage bucket to Cap. View the{" "}
- S3 Config Guide
+ Storage Config Guide
{" "}
to get started.
+
+
+
Storage Provider
+
+
setProvider(e.currentTarget.value)}
+ class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none bg-white pr-10"
+ >
+ AWS S3
+ Cloudflare R2
+ Other S3-Compatible
+
+
+
+
+
Access Key ID
({
From 4ae23e3217a919d5cac390f74132af5776c6c22d Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Sat, 23 Nov 2024 19:46:13 +0000
Subject: [PATCH 6/7] feat: Multiple route fixes + self hosted transcription
---
apps/desktop/src-tauri/src/upload.rs | 54 +++++-
.../settings/apps/s3-config.tsx | 16 +-
apps/desktop/src/utils/tauri.ts | 2 +-
.../view/[videoId]/_components/ShareVideo.tsx | 29 +++-
apps/web/app/api/changelog/status/route.ts | 20 ++-
.../app/api/desktop/s3/config/get/route.ts | 162 ++++++++----------
apps/web/app/api/desktop/s3/config/route.ts | 157 ++++++++---------
.../web/app/api/desktop/video/create/route.ts | 25 +--
.../desktop/video/metadata/retrieve/route.ts | 2 +-
apps/web/app/api/playlist/route.ts | 45 ++++-
apps/web/app/api/screenshot/route.ts | 5 +-
.../app/api/settings/billing/usage/route.ts | 14 ++
apps/web/app/api/thumbnail/route.ts | 4 +-
apps/web/app/api/upload/mux/create/route.ts | 23 ++-
apps/web/app/api/upload/mux/status/route.ts | 16 ++
apps/web/app/api/upload/signed/route.ts | 5 +-
apps/web/app/api/video/delete/route.ts | 17 +-
apps/web/app/api/video/individual/route.ts | 14 +-
apps/web/app/api/video/metadata/route.ts | 12 +-
apps/web/app/api/video/og/route.tsx | 4 +-
apps/web/app/api/video/playlistUrl/route.ts | 9 +
apps/web/app/api/video/title/route.ts | 12 +-
apps/web/app/api/video/transcribe/route.ts | 54 +-----
.../s/[videoId]/_components/ShareVideo.tsx | 39 ++++-
apps/web/app/s/[videoId]/page.tsx | 33 +++-
apps/web/utils/s3.ts | 29 ++--
packages/database/crypto.ts | 85 ++++++---
27 files changed, 547 insertions(+), 340 deletions(-)
diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs
index 6db055255..37996fd62 100644
--- a/apps/desktop/src-tauri/src/upload.rs
+++ b/apps/desktop/src-tauri/src/upload.rs
@@ -12,6 +12,7 @@ use tokio::task;
use crate::web_api::{self, ManagerExt};
use crate::UploadProgress;
+use serde::de::{self, Deserializer};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -19,10 +20,51 @@ use specta::Type;
pub struct S3UploadMeta {
id: String,
user_id: String,
+ #[serde(default)]
aws_region: String,
+ #[serde(default, deserialize_with = "deserialize_empty_object_as_string")]
aws_bucket: String,
}
+fn deserialize_empty_object_as_string<'de, D>(deserializer: D) -> Result
+where
+ D: Deserializer<'de>,
+{
+ struct StringOrObject;
+
+ impl<'de> de::Visitor<'de> for StringOrObject {
+ type Value = String;
+
+ fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+ formatter.write_str("string or empty object")
+ }
+
+ fn visit_str(self, value: &str) -> Result
+ where
+ E: de::Error,
+ {
+ Ok(value.to_string())
+ }
+
+ fn visit_string(self, value: String) -> Result
+ where
+ E: de::Error,
+ {
+ Ok(value)
+ }
+
+ fn visit_map(self, _map: M) -> Result
+ where
+ M: de::MapAccess<'de>,
+ {
+ // Return empty string for empty objects
+ Ok(String::new())
+ }
+ }
+
+ deserializer.deserialize_any(StringOrObject)
+}
+
impl S3UploadMeta {
pub fn id(&self) -> &str {
&self.id
@@ -48,6 +90,15 @@ impl S3UploadMeta {
aws_bucket,
}
}
+
+ pub fn ensure_defaults(&mut self) {
+ if self.aws_region.is_empty() {
+ self.aws_region = std::env::var("NEXT_PUBLIC_CAP_AWS_REGION").unwrap_or_default();
+ }
+ if self.aws_bucket.is_empty() {
+ self.aws_bucket = std::env::var("NEXT_PUBLIC_CAP_AWS_BUCKET").unwrap_or_default();
+ }
+ }
}
#[derive(serde::Serialize)]
@@ -456,13 +507,14 @@ pub async fn get_s3_config(
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
- let config = serde_json::from_str::(&response_text).map_err(|e| {
+ let mut config = serde_json::from_str::(&response_text).map_err(|e| {
format!(
"Failed to deserialize response: {}. Response body: {}",
e, response_text
)
})?;
+ config.ensure_defaults();
Ok(config)
}
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
index b6a76f3fb..14e7d1602 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
@@ -7,11 +7,11 @@ import { commands } from "~/utils/tauri";
interface S3Config {
provider: string;
- accessKeyId: string;
- secretAccessKey: string;
- endpoint: string;
- bucketName: string;
- region: string;
+ accessKeyId: string | null;
+ secretAccessKey: string | null;
+ endpoint: string | null;
+ bucketName: string | null;
+ region: string | null;
}
export default function S3ConfigPage() {
@@ -54,10 +54,10 @@ export default function S3ConfigPage() {
if (data.config) {
const config = data.config as S3Config;
setProvider(config.provider || "aws");
- setAccessKeyId(config.accessKeyId);
- setSecretAccessKey(config.secretAccessKey);
+ setAccessKeyId(config.accessKeyId || "");
+ setSecretAccessKey(config.secretAccessKey || "");
setEndpoint(config.endpoint || "https://s3.amazonaws.com");
- setBucketName(config.bucketName);
+ setBucketName(config.bucketName || "");
setRegion(config.region || "us-east-1");
}
} catch (error) {
diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts
index 91117069a..377c701e2 100644
--- a/apps/desktop/src/utils/tauri.ts
+++ b/apps/desktop/src/utils/tauri.ts
@@ -262,7 +262,7 @@ export type RequestOpenSettings = { page: string }
export type RequestRestartRecording = null
export type RequestStartRecording = null
export type RequestStopRecording = null
-export type S3UploadMeta = { id: string; user_id: string; aws_region: string; aws_bucket: string }
+export type S3UploadMeta = { id: string; user_id: string; aws_region?: string; aws_bucket?: string }
export type ScreenCaptureTarget = ({ variant: "window" } & CaptureWindow) | ({ variant: "screen" } & CaptureScreen)
export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordings; path: string; prettyName: string }
export type SharingMeta = { id: string; link: string }
diff --git a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
index f70f5f84d..48dd8e969 100644
--- a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
+++ b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
@@ -286,13 +286,28 @@ export const ShareVideo = ({
};
useEffect(() => {
- const fetchSubtitles = () => {
- fetch(`https://v.cap.so/${data.ownerId}/${data.id}/transcription.vtt`)
- .then((response) => response.text())
- .then((text) => {
- const parsedSubtitles = fromVtt(text);
- setSubtitles(parsedSubtitles);
- });
+ const fetchSubtitles = async () => {
+ let transcriptionUrl;
+
+ if (
+ data.bucket &&
+ data.awsBucket !== process.env.NEXT_PUBLIC_CAP_AWS_BUCKET
+ ) {
+ // For custom S3 buckets, fetch through the API
+ transcriptionUrl = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&fileType=transcription`;
+ } else {
+ // For default Cap storage
+ transcriptionUrl = `https://v.cap.so/${data.ownerId}/${data.id}/transcription.vtt`;
+ }
+
+ try {
+ const response = await fetch(transcriptionUrl);
+ const text = await response.text();
+ const parsedSubtitles = fromVtt(text);
+ setSubtitles(parsedSubtitles);
+ } catch (error) {
+ console.error("Error fetching subtitles:", error);
+ }
};
if (data.transcriptionStatus === "COMPLETE") {
diff --git a/apps/web/app/api/changelog/status/route.ts b/apps/web/app/api/changelog/status/route.ts
index d794e16ee..339683bf8 100644
--- a/apps/web/app/api/changelog/status/route.ts
+++ b/apps/web/app/api/changelog/status/route.ts
@@ -10,7 +10,14 @@ export async function GET(request: Request) {
const allUpdates = getChangelogPosts();
- const changelogs = allUpdates
+ const changelogs: {
+ content: string;
+ title: string;
+ app: string;
+ publishedAt: string;
+ version: string;
+ image?: string;
+ }[] = allUpdates
.map((post) => ({
metadata: post.metadata,
content: post.content,
@@ -19,7 +26,16 @@ export async function GET(request: Request) {
.sort((a, b) => b.slug - a.slug)
.map(({ metadata, content }) => ({ ...metadata, content }));
- const latestVersion = changelogs[0].version;
+ if (changelogs.length === 0) {
+ return NextResponse.json({ hasUpdate: false });
+ }
+
+ const firstChangelog = changelogs[0];
+ if (!firstChangelog) {
+ return NextResponse.json({ hasUpdate: false });
+ }
+
+ const latestVersion = firstChangelog.version;
const hasUpdate = version ? latestVersion === version : false;
const response = NextResponse.json({ hasUpdate });
diff --git a/apps/web/app/api/desktop/s3/config/get/route.ts b/apps/web/app/api/desktop/s3/config/get/route.ts
index a516c1192..482f98b44 100644
--- a/apps/web/app/api/desktop/s3/config/get/route.ts
+++ b/apps/web/app/api/desktop/s3/config/get/route.ts
@@ -1,127 +1,111 @@
import { type NextRequest } from "next/server";
import { db } from "@cap/database";
import { s3Buckets } from "@cap/database/schema";
-import { getCurrentUser } from "@cap/database/auth/session";
import { eq } from "drizzle-orm";
+import { getCurrentUser } from "@cap/database/auth/session";
import { decrypt } from "@cap/database/crypto";
import { cookies } from "next/headers";
-const allowedOrigins = [
- process.env.NEXT_PUBLIC_URL,
- "http://localhost:3001",
- "http://localhost:3000",
- "tauri://localhost",
- "http://tauri.localhost",
- "https://tauri.localhost",
-];
-
-export async function OPTIONS(req: NextRequest) {
- const params = req.nextUrl.searchParams;
- const origin = params.get("origin") || null;
- const originalOrigin = req.nextUrl.origin;
-
+export async function OPTIONS(request: NextRequest) {
return new Response(null, {
status: 200,
headers: {
- "Access-Control-Allow-Origin":
- origin && allowedOrigins.includes(origin)
- ? origin
- : allowedOrigins.includes(originalOrigin)
- ? originalOrigin
- : "null",
- "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Origin": request.headers.get("origin") as string,
"Access-Control-Allow-Methods": "GET, OPTIONS",
- "Access-Control-Allow-Headers":
- "Content-Type, Authorization, sentry-trace, baggage",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Allow-Credentials": "true",
},
});
}
export async function GET(request: NextRequest) {
- const token = request.headers.get("authorization")?.split(" ")[1];
- if (token) {
- cookies().set({
- name: "next-auth.session-token",
- value: token,
- path: "/",
- sameSite: "none",
- secure: true,
- httpOnly: true,
- });
- }
-
- const params = request.nextUrl.searchParams;
- const origin = params.get("origin") || null;
- const originalOrigin = request.nextUrl.origin;
-
try {
+ const token = request.headers.get("authorization")?.split(" ")[1];
+ if (token) {
+ cookies().set({
+ name: "next-auth.session-token",
+ value: token,
+ path: "/",
+ sameSite: "none",
+ secure: true,
+ httpOnly: true,
+ });
+ }
+
const user = await getCurrentUser();
+ const origin = request.headers.get("origin") as string;
+
if (!user) {
- return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: {
- "Access-Control-Allow-Origin":
- origin && allowedOrigins.includes(origin)
- ? origin
- : allowedOrigins.includes(originalOrigin)
- ? originalOrigin
- : "null",
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
},
});
}
- const encryptedConfig = await db
- .select({
- provider: s3Buckets.provider,
- accessKeyId: s3Buckets.accessKeyId,
- secretAccessKey: s3Buckets.secretAccessKey,
- endpoint: s3Buckets.endpoint,
- bucketName: s3Buckets.bucketName,
- region: s3Buckets.region,
- })
+ const [bucket] = await db
+ .select()
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));
- const config = encryptedConfig[0] ? {
- provider: encryptedConfig[0].provider,
- accessKeyId: decrypt(encryptedConfig[0].accessKeyId),
- secretAccessKey: decrypt(encryptedConfig[0].secretAccessKey),
- endpoint: encryptedConfig[0].endpoint ? decrypt(encryptedConfig[0].endpoint) : null,
- bucketName: decrypt(encryptedConfig[0].bucketName),
- region: decrypt(encryptedConfig[0].region),
- } : null;
+ if (!bucket) {
+ return new Response(
+ JSON.stringify({
+ config: {
+ provider: "aws",
+ accessKeyId: "",
+ secretAccessKey: "",
+ endpoint: "https://s3.amazonaws.com",
+ bucketName: "",
+ region: "us-east-1",
+ },
+ }),
+ {
+ headers: {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ }
+ );
+ }
- return new Response(
- JSON.stringify({
- config: config,
- }),
- {
- status: 200,
- headers: {
- "Access-Control-Allow-Origin":
- origin && allowedOrigins.includes(origin)
- ? origin
- : allowedOrigins.includes(originalOrigin)
- ? originalOrigin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- },
- }
- );
+ // Decrypt the values before sending
+ const decryptedConfig = {
+ provider: bucket.provider,
+ accessKeyId: await decrypt(bucket.accessKeyId),
+ secretAccessKey: await decrypt(bucket.secretAccessKey),
+ endpoint: bucket.endpoint ? await decrypt(bucket.endpoint) : "https://s3.amazonaws.com",
+ bucketName: await decrypt(bucket.bucketName),
+ region: await decrypt(bucket.region),
+ };
+
+ return new Response(JSON.stringify({ config: decryptedConfig }), {
+ headers: {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
} catch (error) {
- console.error("Error fetching S3 config:", error);
+ console.error("Error in S3 config get route:", error);
return new Response(
- JSON.stringify({ error: "Failed to fetch S3 configuration" }),
+ JSON.stringify({
+ error: "Failed to fetch S3 configuration",
+ details: error instanceof Error ? error.message : String(error)
+ }),
{
status: 500,
headers: {
- "Access-Control-Allow-Origin":
- origin && allowedOrigins.includes(origin)
- ? origin
- : allowedOrigins.includes(originalOrigin)
- ? originalOrigin
- : "null",
+ "Access-Control-Allow-Origin": request.headers.get("origin") as string,
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
},
}
diff --git a/apps/web/app/api/desktop/s3/config/route.ts b/apps/web/app/api/desktop/s3/config/route.ts
index afe7af81e..dca383539 100644
--- a/apps/web/app/api/desktop/s3/config/route.ts
+++ b/apps/web/app/api/desktop/s3/config/route.ts
@@ -1,125 +1,102 @@
import { type NextRequest } from "next/server";
import { db } from "@cap/database";
import { s3Buckets } from "@cap/database/schema";
-import { getCurrentUser } from "@cap/database/auth/session";
import { eq } from "drizzle-orm";
+import { getCurrentUser } from "@cap/database/auth/session";
import { encrypt } from "@cap/database/crypto";
-import { nanoId } from "@cap/database/helpers";
-import { getCorsHeaders, getOptionsHeaders } from "@/utils/cors";
import { cookies } from "next/headers";
-
-export async function OPTIONS(req: NextRequest) {
- console.log("[S3 Config] OPTIONS request received");
- const params = req.nextUrl.searchParams;
- const origin = params.get("origin") || null;
- const originalOrigin = req.nextUrl.origin;
-
- console.log("[S3 Config] Responding to OPTIONS request", { origin, originalOrigin });
- return new Response(null, {
- status: 200,
- headers: getOptionsHeaders(origin, originalOrigin, "POST, OPTIONS"),
- });
-}
+import { nanoId } from "@cap/database/helpers";
export async function POST(request: NextRequest) {
- console.log("[S3 Config] POST request received");
- const params = request.nextUrl.searchParams;
- const origin = params.get("origin") || null;
- const originalOrigin = request.nextUrl.origin;
-
- // Handle authentication token
- const token = request.headers.get("authorization")?.split(" ")[1];
- if (token) {
- console.log("[S3 Config] Setting auth token cookie");
- cookies().set({
- name: "next-auth.session-token",
- value: token,
- path: "/",
- sameSite: "none",
- secure: true,
- httpOnly: true,
- });
- } else {
- console.log("[S3 Config] No auth token provided");
- }
-
try {
+ const token = request.headers.get("authorization")?.split(" ")[1];
+ if (token) {
+ cookies().set({
+ name: "next-auth.session-token",
+ value: token,
+ path: "/",
+ sameSite: "none",
+ secure: true,
+ httpOnly: true,
+ });
+ }
+
const user = await getCurrentUser();
+ const origin = request.headers.get("origin") as string;
+
if (!user) {
- console.log("[S3 Config] User not authenticated");
- return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
- headers: getCorsHeaders(origin, originalOrigin),
+ headers: {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Credentials": "true",
+ },
});
}
- console.log("[S3 Config] User authenticated", { userId: user.id });
+ const data = await request.json();
- const { provider, accessKeyId, secretAccessKey, endpoint, bucketName, region } =
- await request.json();
-
- console.log("[S3 Config] Received S3 config data", {
- provider,
- hasAccessKeyId: !!accessKeyId,
- hasSecretKey: !!secretAccessKey,
- endpoint,
- bucketName,
- region
- });
+ // Encrypt the sensitive data
+ const encryptedConfig = {
+ id: nanoId(),
+ provider: data.provider,
+ accessKeyId: await encrypt(data.accessKeyId),
+ secretAccessKey: await encrypt(data.secretAccessKey),
+ endpoint: data.endpoint ? await encrypt(data.endpoint) : null,
+ bucketName: await encrypt(data.bucketName),
+ region: await encrypt(data.region),
+ ownerId: user.id,
+ };
- // Get existing bucket for this user
- const existingBucket = await db
+ // Check if user already has a bucket config
+ const [existingBucket] = await db
.select()
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));
- console.log("[S3 Config] Existing bucket found:", { exists: existingBucket.length > 0 });
-
- // Encrypt sensitive data before storing
- const encryptedData = {
- id: existingBucket[0]?.id || nanoId(),
- provider,
- accessKeyId: encrypt(accessKeyId),
- secretAccessKey: encrypt(secretAccessKey),
- endpoint: endpoint ? encrypt(endpoint) : null,
- bucketName: encrypt(bucketName),
- region: encrypt(region),
- ownerId: user.id,
- };
-
- console.log("[S3 Config] Encrypted data prepared", { id: encryptedData.id });
-
- await db
- .insert(s3Buckets)
- .values(encryptedData)
- .onDuplicateKeyUpdate({
- set: {
- provider: encryptedData.provider,
- accessKeyId: encryptedData.accessKeyId,
- secretAccessKey: encryptedData.secretAccessKey,
- endpoint: encryptedData.endpoint,
- bucketName: encryptedData.bucketName,
- region: encryptedData.region,
- },
- });
-
- console.log("[S3 Config] Successfully saved S3 configuration");
+ if (existingBucket) {
+ // Update existing config
+ await db
+ .update(s3Buckets)
+ .set(encryptedConfig)
+ .where(eq(s3Buckets.id, existingBucket.id));
+ } else {
+ // Insert new config
+ await db.insert(s3Buckets).values(encryptedConfig);
+ }
return new Response(JSON.stringify({ success: true }), {
- status: 200,
- headers: getCorsHeaders(origin, originalOrigin),
+ headers: {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Credentials": "true",
+ },
});
} catch (error) {
- console.error("[S3 Config] Error saving S3 config:", error);
+ console.error("Error in S3 config route:", error);
return new Response(
JSON.stringify({
error: "Failed to save S3 configuration",
- details: error instanceof Error ? error.message : 'Unknown error'
+ details: error instanceof Error ? error.message : String(error)
}),
{
status: 500,
- headers: getCorsHeaders(origin, originalOrigin),
+ headers: {
+ "Access-Control-Allow-Origin": request.headers.get("origin") as string,
+ "Access-Control-Allow-Credentials": "true",
+ },
}
);
}
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return new Response(null, {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin": request.headers.get("origin") as string,
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
}
\ No newline at end of file
diff --git a/apps/web/app/api/desktop/video/create/route.ts b/apps/web/app/api/desktop/video/create/route.ts
index 978f6ff8d..8182ee88b 100644
--- a/apps/web/app/api/desktop/video/create/route.ts
+++ b/apps/web/app/api/desktop/video/create/route.ts
@@ -86,8 +86,8 @@ export async function GET(req: NextRequest) {
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));
- const s3Config = getS3Config(bucket);
- const bucketName = getS3Bucket(bucket);
+ const s3Config = await getS3Config(bucket);
+ const bucketName = await getS3Bucket(bucket);
const id = nanoId();
const date = new Date();
@@ -144,21 +144,22 @@ export async function GET(req: NextRequest) {
);
}
- await db.insert(videos).values({
+ const videoData = {
id: id,
name: `Cap ${isScreenshot ? "Screenshot" : "Recording"} - ${formattedDate}`,
ownerId: user.id,
awsRegion: s3Config.region,
awsBucket: bucketName,
- source:
- recordingMode === "hls"
- ? { type: "local" }
- : recordingMode === "desktopMP4"
- ? { type: "desktopMP4" }
- : undefined,
- isScreenshot: isScreenshot,
+ source: recordingMode === "hls"
+ ? { type: "local" as const }
+ : recordingMode === "desktopMP4"
+ ? { type: "desktopMP4" as const }
+ : undefined,
+ isScreenshot,
bucket: bucket?.id,
- });
+ };
+
+ await db.insert(videos).values(videoData);
if (
process.env.NEXT_PUBLIC_IS_CAP &&
@@ -173,7 +174,7 @@ export async function GET(req: NextRequest) {
return new Response(
JSON.stringify({
- id: id,
+ id,
user_id: user.id,
aws_region: s3Config.region,
aws_bucket: bucketName,
diff --git a/apps/web/app/api/desktop/video/metadata/retrieve/route.ts b/apps/web/app/api/desktop/video/metadata/retrieve/route.ts
index e603fa3a8..9246d5265 100644
--- a/apps/web/app/api/desktop/video/metadata/retrieve/route.ts
+++ b/apps/web/app/api/desktop/video/metadata/retrieve/route.ts
@@ -33,7 +33,7 @@ export async function GET(request: NextRequest) {
});
}
- const video = query[0];
+ const video = query[0]!;
const videoStartTime = video.videoStartTime
? new Date(video.videoStartTime).getTime()
: 0;
diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts
index c21841f60..9dcbe8982 100644
--- a/apps/web/app/api/playlist/route.ts
+++ b/apps/web/app/api/playlist/route.ts
@@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
const videoId = searchParams.get("videoId") || "";
const videoType = searchParams.get("videoType") || "";
const thumbnail = searchParams.get("thumbnail") || "";
+ const fileType = searchParams.get("fileType") || "";
const origin = request.headers.get("origin") as string;
if (!userId || !videoId) {
@@ -78,6 +79,9 @@ export async function GET(request: NextRequest) {
}
}
+ const Bucket = await getS3Bucket(bucket);
+ const s3Client = await createS3Client(bucket);
+
if (!bucket || video.awsBucket === process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) {
if (video.source.type === "desktopMP4") {
return new Response(null, {
@@ -109,13 +113,45 @@ export async function GET(request: NextRequest) {
});
}
- const Bucket = getS3Bucket(bucket);
+ // Handle transcription file request first
+ if (fileType === "transcription") {
+ try {
+ const transcriptionUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket,
+ Key: `${userId}/${videoId}/transcription.vtt`,
+ }),
+ { expiresIn: 3600 }
+ );
+
+ const response = await fetch(transcriptionUrl);
+ const transcriptionContent = await response.text();
+
+ return new Response(transcriptionContent, {
+ status: 200,
+ headers: {
+ ...getHeaders(origin),
+ "Content-Type": "text/vtt",
+ },
+ });
+ } catch (error) {
+ console.error("Error fetching transcription file:", error);
+ return new Response(
+ JSON.stringify({ error: true, message: "Transcription file not found" }),
+ {
+ status: 404,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+ }
+
+ // Handle video/audio files
const videoPrefix = `${userId}/${videoId}/video/`;
const audioPrefix = `${userId}/${videoId}/audio/`;
try {
- const s3Client = createS3Client(bucket);
-
if (video.source.type === "local") {
const playlistUrl = await getSignedUrl(
s3Client,
@@ -150,6 +186,7 @@ export async function GET(request: NextRequest) {
headers: getHeaders(origin),
});
}
+
if (video.source.type === "desktopMP4") {
const playlistUrl = await getSignedUrl(
s3Client,
@@ -208,8 +245,6 @@ export async function GET(request: NextRequest) {
console.warn("No audio segment found for this video", error);
}
- console.log("audioSegment", audioSegment);
-
const [videoSegment] = await Promise.all([
s3Client.send(videoSegmentCommand),
]);
diff --git a/apps/web/app/api/screenshot/route.ts b/apps/web/app/api/screenshot/route.ts
index 4390806ca..78a24ea59 100644
--- a/apps/web/app/api/screenshot/route.ts
+++ b/apps/web/app/api/screenshot/route.ts
@@ -69,16 +69,15 @@ export async function GET(request: NextRequest) {
}
}
- const Bucket = getS3Bucket(bucket);
+ const Bucket = await getS3Bucket(bucket);
const screenshotPrefix = `${userId}/${videoId}/`;
try {
- const s3Client = createS3Client(bucket);
+ const s3Client = await createS3Client(bucket);
const objectsCommand = new ListObjectsV2Command({
Bucket,
Prefix: screenshotPrefix,
- MaxKeys: 1,
});
const objects = await s3Client.send(objectsCommand);
diff --git a/apps/web/app/api/settings/billing/usage/route.ts b/apps/web/app/api/settings/billing/usage/route.ts
index 58cd65e97..eeee1900e 100644
--- a/apps/web/app/api/settings/billing/usage/route.ts
+++ b/apps/web/app/api/settings/billing/usage/route.ts
@@ -22,6 +22,20 @@ export async function GET(request: NextRequest) {
.from(videos)
.where(eq(videos.ownerId, user.id));
+ if (!numberOfVideos[0]) {
+ return new Response(
+ JSON.stringify({
+ error: "Could not fetch video count",
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+
if (
isUserOnProPlan({
subscriptionStatus: user.stripeSubscriptionStatus as string,
diff --git a/apps/web/app/api/thumbnail/route.ts b/apps/web/app/api/thumbnail/route.ts
index be5afb63b..f1cf4fcee 100644
--- a/apps/web/app/api/thumbnail/route.ts
+++ b/apps/web/app/api/thumbnail/route.ts
@@ -71,8 +71,8 @@ export async function GET(request: NextRequest) {
});
}
- const Bucket = getS3Bucket(result.bucket);
- const s3Client = createS3Client(result.bucket);
+ const Bucket = await getS3Bucket(result.bucket);
+ const s3Client = await createS3Client(result.bucket);
try {
const listCommand = new ListObjectsV2Command({
diff --git a/apps/web/app/api/upload/mux/create/route.ts b/apps/web/app/api/upload/mux/create/route.ts
index a82cef0f1..098947f85 100644
--- a/apps/web/app/api/upload/mux/create/route.ts
+++ b/apps/web/app/api/upload/mux/create/route.ts
@@ -89,7 +89,24 @@ export async function GET(request: NextRequest) {
);
}
- const { video, bucket } = query[0];
+ const result = query[0];
+ if (!result) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video does not exist" }),
+ {
+ status: 401,
+ headers: {
+ "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
+ ? origin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ },
+ }
+ );
+ }
+
+ const { video, bucket } = result;
if (video.jobId !== null || video.ownerId !== userId) {
return new Response(JSON.stringify({ assetId: video.jobId }), {
@@ -104,12 +121,12 @@ export async function GET(request: NextRequest) {
});
}
- const Bucket = getS3Bucket(bucket);
+ const Bucket = await getS3Bucket(bucket);
const videoPrefix = `${userId}/${videoId}/video/`;
const audioPrefix = `${userId}/${videoId}/audio/`;
try {
- const s3Client = createS3Client(bucket);
+ const s3Client = await createS3Client(bucket);
const videoSegmentCommand = new ListObjectsV2Command({
Bucket,
diff --git a/apps/web/app/api/upload/mux/status/route.ts b/apps/web/app/api/upload/mux/status/route.ts
index 928b5652d..685fa5ee8 100644
--- a/apps/web/app/api/upload/mux/status/route.ts
+++ b/apps/web/app/api/upload/mux/status/route.ts
@@ -82,6 +82,22 @@ export async function GET(request: NextRequest) {
}
const video = query[0];
+ if (!video) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video not found" }),
+ {
+ status: 404,
+ headers: {
+ "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
+ ? origin
+ : "null",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ },
+ }
+ );
+ }
+
const jobId = video.jobId;
if (!jobId) {
diff --git a/apps/web/app/api/upload/signed/route.ts b/apps/web/app/api/upload/signed/route.ts
index 7c07548ff..95046e27c 100644
--- a/apps/web/app/api/upload/signed/route.ts
+++ b/apps/web/app/api/upload/signed/route.ts
@@ -59,7 +59,6 @@ export async function POST(request: NextRequest) {
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));
- // Create a decrypted config for S3 client
const s3Config = bucket ? {
endpoint: bucket.endpoint || undefined,
region: bucket.region,
@@ -74,7 +73,7 @@ export async function POST(request: NextRequest) {
hasSecretKey: !!s3Config?.secretAccessKey,
});
- const s3Client = createS3Client(s3Config);
+ const s3Client = await createS3Client(s3Config);
const contentType = fileKey.endsWith(".aac")
? "audio/aac"
@@ -98,7 +97,7 @@ export async function POST(request: NextRequest) {
"x-amz-meta-audiocodec": audioCodec ?? "",
};
- const bucketName = getS3Bucket(bucket);
+ const bucketName = await getS3Bucket(bucket);
console.log("Using bucket:", bucketName);
const presignedPostData: PresignedPost = await createPresignedPost(
diff --git a/apps/web/app/api/video/delete/route.ts b/apps/web/app/api/video/delete/route.ts
index e63b268f6..0b8a608b0 100644
--- a/apps/web/app/api/video/delete/route.ts
+++ b/apps/web/app/api/video/delete/route.ts
@@ -16,6 +16,7 @@ export async function DELETE(request: NextRequest) {
const { searchParams } = request.nextUrl;
const videoId = searchParams.get("videoId") || "";
const userId = user?.id as string;
+ const origin = request.headers.get("origin") as string;
if (!videoId || !userId) {
console.error("Missing required data in /api/video/delete/route.ts");
@@ -44,15 +45,23 @@ export async function DELETE(request: NextRequest) {
);
}
- const { bucket } = query[0];
+ const result = query[0];
+ if (!result) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video not found" }),
+ {
+ status: 404,
+ headers: getHeaders(origin),
+ }
+ );
+ }
await db
.delete(videos)
.where(and(eq(videos.id, videoId), eq(videos.ownerId, userId)));
- const s3Client = createS3Client(bucket);
-
- const Bucket = getS3Bucket(bucket);
+ const s3Client = await createS3Client(result.bucket);
+ const Bucket = await getS3Bucket(result.bucket);
const prefix = `${userId}/${videoId}/`;
const listObjectsCommand = new ListObjectsV2Command({
diff --git a/apps/web/app/api/video/individual/route.ts b/apps/web/app/api/video/individual/route.ts
index b5d76ac08..cdd6d3a47 100644
--- a/apps/web/app/api/video/individual/route.ts
+++ b/apps/web/app/api/video/individual/route.ts
@@ -47,7 +47,15 @@ export async function GET(request: NextRequest) {
);
}
- const { video, bucket } = query[0];
+ const result = query[0];
+ if (!result) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video does not exist" }),
+ { status: 401, headers: getHeaders(origin) }
+ );
+ }
+
+ const { video, bucket } = result;
if (video.public === false) {
const user = await getCurrentUser();
@@ -60,11 +68,11 @@ export async function GET(request: NextRequest) {
}
}
- const Bucket = getS3Bucket(bucket);
+ const Bucket = await getS3Bucket(bucket);
const individualPrefix = `${userId}/${videoId}/individual/`;
try {
- const s3Client = createS3Client(bucket);
+ const s3Client = await createS3Client(bucket);
const objectsCommand = new ListObjectsV2Command({
Bucket,
diff --git a/apps/web/app/api/video/metadata/route.ts b/apps/web/app/api/video/metadata/route.ts
index 1c70b516a..f6bf3ecc5 100644
--- a/apps/web/app/api/video/metadata/route.ts
+++ b/apps/web/app/api/video/metadata/route.ts
@@ -31,9 +31,17 @@ export async function PUT(request: NextRequest) {
});
}
- const ownerId = query[0].ownerId;
+ const result = query[0];
+ if (!result) {
+ return new Response(JSON.stringify({ error: true }), {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ }
- if (ownerId !== userId) {
+ if (result.ownerId !== userId) {
return new Response(JSON.stringify({ error: true }), {
status: 401,
headers: {
diff --git a/apps/web/app/api/video/og/route.tsx b/apps/web/app/api/video/og/route.tsx
index 5ac8459bd..f766429ef 100644
--- a/apps/web/app/api/video/og/route.tsx
+++ b/apps/web/app/api/video/og/route.tsx
@@ -73,8 +73,8 @@ export async function GET(req: NextRequest) {
);
}
- const s3Client = createS3Client(bucket);
- const Bucket = getS3Bucket(bucket);
+ const s3Client = await createS3Client(bucket);
+ const Bucket = await getS3Bucket(bucket);
const screenshotKey = `${video.ownerId}/${video.id}/screenshot/screen-capture.jpg`;
let screenshotUrl = null;
diff --git a/apps/web/app/api/video/playlistUrl/route.ts b/apps/web/app/api/video/playlistUrl/route.ts
index 7786c4be1..2c2638b3e 100644
--- a/apps/web/app/api/video/playlistUrl/route.ts
+++ b/apps/web/app/api/video/playlistUrl/route.ts
@@ -47,6 +47,15 @@ export async function GET(request: NextRequest) {
}
const video = query[0];
+ if (!video) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video not found" }),
+ {
+ status: 404,
+ headers: getHeaders(origin),
+ }
+ );
+ }
if (video.jobStatus === "COMPLETE") {
const playlistUrl = `https://v.cap.so/${video.ownerId}/${video.id}/output/video_recording_000_output.m3u8`;
diff --git a/apps/web/app/api/video/title/route.ts b/apps/web/app/api/video/title/route.ts
index aaed8d67b..db73436da 100644
--- a/apps/web/app/api/video/title/route.ts
+++ b/apps/web/app/api/video/title/route.ts
@@ -34,9 +34,17 @@ export async function handlePut(request: NextRequest) {
});
}
- const ownerId = query[0].ownerId;
+ const video = query[0];
+ if (!video) {
+ return new Response(JSON.stringify({ error: true }), {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ }
- if (ownerId !== userId) {
+ if (video.ownerId !== userId) {
return new Response(JSON.stringify({ error: true }), {
status: 401,
headers: {
diff --git a/apps/web/app/api/video/transcribe/route.ts b/apps/web/app/api/video/transcribe/route.ts
index 8fbb9e30f..2d7cabdcf 100644
--- a/apps/web/app/api/video/transcribe/route.ts
+++ b/apps/web/app/api/video/transcribe/route.ts
@@ -15,7 +15,6 @@ import { createS3Client, getS3Bucket } from "@/utils/s3";
export const maxDuration = 120;
export async function OPTIONS(request: NextRequest) {
- console.log("OPTIONS request received");
const origin = request.headers.get("origin") as string;
return new Response(null, {
@@ -25,22 +24,12 @@ export async function OPTIONS(request: NextRequest) {
}
export async function GET(request: NextRequest) {
- console.log("Transcription request received");
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get("userId") || "";
const videoId = searchParams.get("videoId") || "";
const origin = request.headers.get("origin") as string;
- console.log(`UserId: ${userId}, VideoId: ${videoId}`);
-
- if (
- !process.env.NEXT_PUBLIC_CAP_AWS_BUCKET ||
- !process.env.NEXT_PUBLIC_CAP_AWS_REGION ||
- !process.env.CAP_AWS_ACCESS_KEY ||
- !process.env.CAP_AWS_SECRET_KEY ||
- !process.env.DEEPGRAM_API_KEY
- ) {
- console.error("Missing necessary environment variables");
+ if (!process.env.DEEPGRAM_API_KEY) {
return new Response(
JSON.stringify({
error: true,
@@ -54,7 +43,6 @@ export async function GET(request: NextRequest) {
}
if (!userId || !videoId) {
- console.error("userId or videoId not supplied");
return new Response(
JSON.stringify({
error: true,
@@ -67,7 +55,6 @@ export async function GET(request: NextRequest) {
);
}
- console.log("Querying database for video");
const query = await db
.select({
video: videos,
@@ -77,10 +64,7 @@ export async function GET(request: NextRequest) {
.leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
.where(eq(videos.id, videoId));
- console.log("Database query result:", query);
-
if (query.length === 0) {
- console.error("Video does not exist");
return new Response(
JSON.stringify({ error: true, message: "Video does not exist" }),
{
@@ -92,7 +76,6 @@ export async function GET(request: NextRequest) {
const result = query[0];
if (!result || !result.video) {
- console.error("Video information is missing");
return new Response(
JSON.stringify({ error: true, message: "Video information is missing" }),
{
@@ -105,7 +88,6 @@ export async function GET(request: NextRequest) {
const { video, bucket } = result;
if (!video) {
- console.error("Video information is missing");
return new Response(
JSON.stringify({ error: true, message: "Video information is missing" }),
{
@@ -115,12 +97,10 @@ export async function GET(request: NextRequest) {
);
}
- // Use the awsRegion and awsBucket from the video object
const awsRegion = video.awsRegion;
const awsBucket = video.awsBucket;
if (!awsRegion || !awsBucket) {
- console.error("AWS region or bucket information is missing");
return new Response(
JSON.stringify({
error: true,
@@ -133,13 +113,10 @@ export async function GET(request: NextRequest) {
);
}
- console.log("Video and bucket information:", { video, bucket });
-
if (
video.transcriptionStatus === "COMPLETE" ||
video.transcriptionStatus === "PROCESSING"
) {
- console.log("Transcription already completed or in progress");
return new Response(
JSON.stringify({
message: "Transcription already completed or in progress",
@@ -148,57 +125,44 @@ export async function GET(request: NextRequest) {
);
}
- console.log("Updating transcription status to PROCESSING");
await db
.update(videos)
.set({ transcriptionStatus: "PROCESSING" })
.where(eq(videos.id, videoId));
- const Bucket = getS3Bucket(awsBucket);
- console.log("S3 Bucket:", Bucket);
-
- console.log("Creating S3 client");
- const s3Client = createS3Client(awsBucket);
+ const s3Client = await createS3Client(bucket);
try {
const videoKey = `${userId}/${videoId}/result.mp4`;
- console.log("Video key:", videoKey);
- console.log("Getting signed URL for video");
const videoUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
- Bucket,
+ Bucket: awsBucket,
Key: videoKey,
})
);
- console.log("Signed URL obtained");
- console.log("Transcribing audio");
const transcription = await transcribeAudio(videoUrl);
if (transcription === "") {
throw new Error("Failed to transcribe audio");
}
- console.log("Uploading transcription");
const uploadCommand = new PutObjectCommand({
- Bucket,
+ Bucket: awsBucket,
Key: `${userId}/${videoId}/transcription.vtt`,
Body: transcription,
ContentType: "text/vtt",
});
await s3Client.send(uploadCommand);
- console.log("Transcription uploaded successfully");
- console.log("Updating transcription status to COMPLETE");
await db
.update(videos)
.set({ transcriptionStatus: "COMPLETE" })
.where(eq(videos.id, videoId));
- console.log("Transcription process completed successfully");
return new Response(
JSON.stringify({
message: "VTT file generated and uploaded successfully",
@@ -209,7 +173,6 @@ export async function GET(request: NextRequest) {
}
);
} catch (error) {
- console.error("Error processing video file", error);
await db
.update(videos)
.set({ transcriptionStatus: "ERROR" })
@@ -226,7 +189,6 @@ export async function GET(request: NextRequest) {
}
function formatToWebVTT(result: any): string {
- console.log("Formatting transcription to WebVTT");
let output = "WEBVTT\n\n";
let captionIndex = 1;
@@ -255,12 +217,11 @@ function formatToWebVTT(result: any): string {
group = [];
start = words[i + 1] ? formatTimestamp(words[i + 1].start) : start;
- wordCount = 0; // Reset the counter for the next group
+ wordCount = 0;
}
}
});
- console.log("WebVTT formatting completed");
return output;
}
@@ -275,7 +236,6 @@ function formatTimestamp(seconds: number): string {
}
async function transcribeAudio(videoUrl: string): Promise {
- console.log("Starting audio transcription");
const deepgram = createClient(process.env.DEEPGRAM_API_KEY as string);
const { result, error } = await deepgram.listen.prerecorded.transcribeUrl(
@@ -287,16 +247,14 @@ async function transcribeAudio(videoUrl: string): Promise {
smart_format: true,
detect_language: true,
utterances: true,
- mime_type: "video/mp4", // Specify the MIME type for MP4 video
+ mime_type: "video/mp4",
}
);
if (error) {
- console.error("Transcription error:", error);
return "";
}
- console.log("Transcription completed successfully");
const captions = formatToWebVTT(result);
return captions;
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
index 782d60329..b61e6532c 100644
--- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
+++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
@@ -14,7 +14,7 @@ import { LogoSpinner } from "@cap/ui";
import { userSelectProps } from "@cap/database/auth/session";
import { fromVtt, Subtitle } from "subtitles-parser-vtt";
import toast from "react-hot-toast";
-import { Tooltip } from "react-tooltip"; // Make sure to import this if not already present
+import { Tooltip } from "react-tooltip";
declare global {
interface Window {
@@ -372,19 +372,42 @@ export const ShareVideo = ({
};
useEffect(() => {
- const fetchSubtitles = () => {
- fetch(`https://v.cap.so/${data.ownerId}/${data.id}/transcription.vtt`)
- .then((response) => response.text())
- .then((text) => {
- const parsedSubtitles = fromVtt(text);
- setSubtitles(parsedSubtitles);
- });
+ const fetchSubtitles = async () => {
+ let transcriptionUrl;
+
+ if (
+ data.bucket &&
+ data.awsBucket !== process.env.NEXT_PUBLIC_CAP_AWS_BUCKET
+ ) {
+ // For custom S3 buckets, fetch through the API
+ transcriptionUrl = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&fileType=transcription`;
+ } else {
+ // For default Cap storage
+ transcriptionUrl = `https://v.cap.so/${data.ownerId}/${data.id}/transcription.vtt`;
+ }
+
+ try {
+ const response = await fetch(transcriptionUrl);
+ const text = await response.text();
+ const parsedSubtitles = fromVtt(text);
+ setSubtitles(parsedSubtitles);
+ } catch (error) {
+ console.error("Error fetching subtitles:", error);
+ }
};
if (data.transcriptionStatus === "COMPLETE") {
fetchSubtitles();
} else {
+ const startTime = Date.now();
+ const maxDuration = 2 * 60 * 1000;
+
const intervalId = setInterval(() => {
+ if (Date.now() - startTime > maxDuration) {
+ clearInterval(intervalId);
+ return;
+ }
+
fetch(`/api/video/transcribe/status?videoId=${data.id}`)
.then((response) => response.json())
.then(({ transcriptionStatus }) => {
diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx
index f7c3a0ede..8c0f2b886 100644
--- a/apps/web/app/s/[videoId]/page.tsx
+++ b/apps/web/app/s/[videoId]/page.tsx
@@ -12,7 +12,6 @@ type Props = {
params: { [key: string]: string | string[] | undefined };
};
-// Add this type definition at the top of the file
type CommentWithAuthor = typeof comments.$inferSelect & {
authorName: string | null;
};
@@ -22,19 +21,31 @@ export async function generateMetadata(
parent: ResolvingMetadata
): Promise {
const videoId = params.videoId as string;
+ console.log(
+ "[generateMetadata] Fetching video metadata for videoId:",
+ videoId
+ );
const query = await db.select().from(videos).where(eq(videos.id, videoId));
if (query.length === 0) {
+ console.log("[generateMetadata] No video found for videoId:", videoId);
return notFound();
}
const video = query[0];
if (!video) {
+ console.log(
+ "[generateMetadata] Video object is null for videoId:",
+ videoId
+ );
return notFound();
}
if (video.public === false) {
+ console.log(
+ "[generateMetadata] Video is private, returning private metadata"
+ );
return {
title: "Cap: This video is private",
description: "This video is private and cannot be shared.",
@@ -46,6 +57,10 @@ export async function generateMetadata(
};
}
+ console.log(
+ "[generateMetadata] Returning public metadata for video:",
+ video.name
+ );
return {
title: video.name + " | Cap Recording",
description: "Watch this video on Cap",
@@ -60,17 +75,23 @@ export async function generateMetadata(
export default async function ShareVideoPage(props: Props) {
const params = props.params;
const videoId = params.videoId as string;
+ console.log("[ShareVideoPage] Starting page load for videoId:", videoId);
+
const user = (await getCurrentUser()) as typeof userSelectProps | null;
const userId = user?.id as string | undefined;
+ console.log("[ShareVideoPage] Current user:", userId);
+
const query = await db.select().from(videos).where(eq(videos.id, videoId));
if (query.length === 0) {
+ console.log("[ShareVideoPage] No video found for videoId:", videoId);
return No video found
;
}
const video = query[0];
if (!video) {
+ console.log("[ShareVideoPage] Video object is null for videoId:", videoId);
return notFound();
}
@@ -79,6 +100,7 @@ export default async function ShareVideoPage(props: Props) {
video.skipProcessing === false &&
video.source.type === "MediaConvert"
) {
+ console.log("[ShareVideoPage] Creating MUX job for video:", videoId);
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/api/upload/mux/create?videoId=${videoId}&userId=${video.ownerId}`,
{
@@ -92,6 +114,7 @@ export default async function ShareVideoPage(props: Props) {
}
if (video.transcriptionStatus !== "COMPLETE") {
+ console.log("[ShareVideoPage] Starting transcription for video:", videoId);
fetch(
`${process.env.NEXT_PUBLIC_URL}/api/video/transcribe?videoId=${videoId}&userId=${video.ownerId}`,
{
@@ -103,9 +126,11 @@ export default async function ShareVideoPage(props: Props) {
}
if (video.public === false && userId !== video.ownerId) {
+ console.log("[ShareVideoPage] Access denied - private video:", videoId);
return This video is private
;
}
+ console.log("[ShareVideoPage] Fetching comments for video:", videoId);
const commentsQuery: CommentWithAuthor[] = await db
.select({
id: comments.id,
@@ -125,6 +150,7 @@ export default async function ShareVideoPage(props: Props) {
let screenshotUrl;
if (video.isScreenshot === true) {
+ console.log("[ShareVideoPage] Fetching screenshot for video:", videoId);
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/api/screenshot?userId=${video.ownerId}&screenshotId=${videoId}`,
{
@@ -152,6 +178,10 @@ export default async function ShareVideoPage(props: Props) {
}[] = [];
if (video?.source.type === "desktopMP4") {
+ console.log(
+ "[ShareVideoPage] Fetching individual files for desktop MP4 video:",
+ videoId
+ );
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/api/video/individual?videoId=${videoId}&userId=${video.ownerId}`,
{
@@ -165,6 +195,7 @@ export default async function ShareVideoPage(props: Props) {
individualFiles = data.files;
}
+ console.log("[ShareVideoPage] Rendering Share component for video:", videoId);
return (
{
if (!text) return undefined;
try {
- return decrypt(text);
+ const decrypted = await decrypt(text);
+ return decrypted;
} catch (error) {
- // If decryption fails, assume the data is not encrypted yet
- console.log("Decryption failed, using original value");
return text;
}
}
-export function getS3Config(config?: S3Config) {
+export async function getS3Config(config?: S3Config) {
if (!config) {
return {
endpoint: process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT,
@@ -39,21 +34,25 @@ export function getS3Config(config?: S3Config) {
return {
forcePathStyle: true,
- endpoint: config.endpoint ? tryDecrypt(config.endpoint) : process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT,
- region: tryDecrypt(config.region) ?? process.env.NEXT_PUBLIC_CAP_AWS_REGION,
+ endpoint: config.endpoint ? await tryDecrypt(config.endpoint) : process.env.NEXT_PUBLIC_CAP_AWS_ENDPOINT,
+ region: (await tryDecrypt(config.region)) ?? process.env.NEXT_PUBLIC_CAP_AWS_REGION,
credentials: {
- accessKeyId: tryDecrypt(config.accessKeyId) ?? process.env.CAP_AWS_ACCESS_KEY ?? "",
- secretAccessKey: tryDecrypt(config.secretAccessKey) ?? process.env.CAP_AWS_SECRET_KEY ?? "",
+ accessKeyId: (await tryDecrypt(config.accessKeyId)) ?? process.env.CAP_AWS_ACCESS_KEY ?? "",
+ secretAccessKey: (await tryDecrypt(config.secretAccessKey)) ?? process.env.CAP_AWS_SECRET_KEY ?? "",
},
};
}
-export function getS3Bucket(
+export async function getS3Bucket(
bucket?: InferSelectModel | null
) {
if (!bucket?.bucketName) {
return process.env.NEXT_PUBLIC_CAP_AWS_BUCKET || "";
}
- return (tryDecrypt(bucket.bucketName) ?? process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) || "";
+ return (await tryDecrypt(bucket.bucketName) ?? process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) || "";
+}
+
+export async function createS3Client(config?: S3Config) {
+ return new S3Client(await getS3Config(config));
}
diff --git a/packages/database/crypto.ts b/packages/database/crypto.ts
index 151f1af8d..b9aed2826 100644
--- a/packages/database/crypto.ts
+++ b/packages/database/crypto.ts
@@ -1,9 +1,6 @@
-import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
-
-const ALGORITHM = 'aes-256-gcm';
+const ALGORITHM = { name: 'AES-GCM', length: 256 };
const IV_LENGTH = 12;
const SALT_LENGTH = 16;
-const TAG_LENGTH = 16;
const KEY_LENGTH = 32;
const ITERATIONS = 100000;
@@ -22,32 +19,59 @@ try {
throw new Error('Invalid encryption key format');
}
-function deriveKey(salt: Buffer): Buffer {
- return pbkdf2Sync(
- ENCRYPTION_KEY,
- salt,
- ITERATIONS,
- KEY_LENGTH,
- 'sha256'
+async function deriveKey(salt: Uint8Array): Promise {
+ // Convert hex string to ArrayBuffer for Web Crypto API
+ const keyBuffer = Buffer.from(ENCRYPTION_KEY, 'hex');
+
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ keyBuffer,
+ 'PBKDF2',
+ false,
+ ['deriveKey']
+ );
+
+ return crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt,
+ iterations: ITERATIONS,
+ hash: 'SHA-256',
+ },
+ keyMaterial,
+ ALGORITHM,
+ false,
+ ['encrypt', 'decrypt']
);
}
-export function encrypt(text: string): string {
+export async function encrypt(text: string): Promise {
if (!text) {
throw new Error('Cannot encrypt empty or null text');
}
try {
- const salt = randomBytes(SALT_LENGTH);
- const iv = randomBytes(IV_LENGTH);
- const key = deriveKey(salt);
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
+ const key = await deriveKey(salt);
- const cipher = createCipheriv(ALGORITHM, key, iv);
- const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
- const tag = cipher.getAuthTag();
+ const encoded = new TextEncoder().encode(text);
+ const encrypted = await crypto.subtle.encrypt(
+ {
+ name: ALGORITHM.name,
+ iv,
+ },
+ key,
+ encoded
+ );
- // Combine salt, IV, tag, and encrypted content
- const result = Buffer.concat([salt, iv, tag, encrypted]);
+ // Combine salt, IV, and encrypted content
+ const result = Buffer.concat([
+ Buffer.from(salt),
+ Buffer.from(iv),
+ Buffer.from(encrypted)
+ ]);
+
return result.toString('base64');
} catch (error: unknown) {
if (error instanceof Error) {
@@ -57,7 +81,7 @@ export function encrypt(text: string): string {
}
}
-export function decrypt(encryptedText: string): string {
+export async function decrypt(encryptedText: string): Promise {
if (!encryptedText) {
throw new Error('Cannot decrypt empty or null text');
}
@@ -68,16 +92,21 @@ export function decrypt(encryptedText: string): string {
// Extract the components
const salt = encrypted.subarray(0, SALT_LENGTH);
const iv = encrypted.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
- const tag = encrypted.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + TAG_LENGTH);
- const content = encrypted.subarray(SALT_LENGTH + IV_LENGTH + TAG_LENGTH);
+ const content = encrypted.subarray(SALT_LENGTH + IV_LENGTH);
// Derive the same key using the extracted salt
- const key = deriveKey(salt);
+ const key = await deriveKey(salt);
- const decipher = createDecipheriv(ALGORITHM, key, iv);
- decipher.setAuthTag(tag);
- const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
- return decrypted.toString('utf8');
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: ALGORITHM.name,
+ iv,
+ },
+ key,
+ content
+ );
+
+ return new TextDecoder().decode(decrypted);
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(`Decryption failed: ${error.message}`);
From 939c160c17688c1b26c41881a1f48ebcf7a9c499 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Sat, 23 Nov 2024 20:02:22 +0000
Subject: [PATCH 7/7] feat: Remove config button + cleanup
---
.../settings/apps/s3-config.tsx | 324 +++++++++++-------
.../app/api/desktop/s3/config/delete/route.ts | 72 ++++
apps/web/app/api/playlist/route.ts | 1 -
3 files changed, 263 insertions(+), 134 deletions(-)
create mode 100644 apps/web/app/api/desktop/s3/config/delete/route.ts
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
index 14e7d1602..086a828af 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx
@@ -14,51 +14,103 @@ interface S3Config {
region: string | null;
}
+const DEFAULT_CONFIG = {
+ provider: "aws",
+ accessKeyId: "",
+ secretAccessKey: "",
+ endpoint: "https://s3.amazonaws.com",
+ bucketName: "",
+ region: "us-east-1",
+};
+
export default function S3ConfigPage() {
- const [provider, setProvider] = createSignal("aws");
- const [accessKeyId, setAccessKeyId] = createSignal("");
- const [secretAccessKey, setSecretAccessKey] = createSignal("");
- const [endpoint, setEndpoint] = createSignal("https://s3.amazonaws.com");
- const [bucketName, setBucketName] = createSignal("");
- const [region, setRegion] = createSignal("us-east-1");
+ const [provider, setProvider] = createSignal(DEFAULT_CONFIG.provider);
+ const [accessKeyId, setAccessKeyId] = createSignal(
+ DEFAULT_CONFIG.accessKeyId
+ );
+ const [secretAccessKey, setSecretAccessKey] = createSignal(
+ DEFAULT_CONFIG.secretAccessKey
+ );
+ const [endpoint, setEndpoint] = createSignal(DEFAULT_CONFIG.endpoint);
+ const [bucketName, setBucketName] = createSignal(DEFAULT_CONFIG.bucketName);
+ const [region, setRegion] = createSignal(DEFAULT_CONFIG.region);
const [saving, setSaving] = createSignal(false);
const [loading, setLoading] = createSignal(true);
+ const [deleting, setDeleting] = createSignal(false);
+ const [hasConfig, setHasConfig] = createSignal(false);
- onMount(async () => {
- try {
- const auth = await authStore.get();
+ const resetForm = () => {
+ setProvider(DEFAULT_CONFIG.provider);
+ setAccessKeyId(DEFAULT_CONFIG.accessKeyId);
+ setSecretAccessKey(DEFAULT_CONFIG.secretAccessKey);
+ setEndpoint(DEFAULT_CONFIG.endpoint);
+ setBucketName(DEFAULT_CONFIG.bucketName);
+ setRegion(DEFAULT_CONFIG.region);
+ setHasConfig(false);
+ };
+
+ const handleAuthError = async () => {
+ console.error("User not authenticated");
+ const window = getCurrentWindow();
+ window.close();
+ };
- if (!auth) {
- console.error("User not authenticated");
- const window = getCurrentWindow();
- window.close();
- return;
+ const makeAuthenticatedRequest = async (
+ url: string,
+ options: RequestInit
+ ) => {
+ const auth = await authStore.get();
+ if (!auth) {
+ await handleAuthError();
+ return null;
+ }
+
+ const response = await fetch(
+ `${clientEnv.VITE_SERVER_URL}${url}?origin=${window.location.origin}`,
+ {
+ ...options,
+ credentials: "include",
+ headers: {
+ ...options.headers,
+ Authorization: `Bearer ${auth.token}`,
+ },
}
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to ${options.method?.toLowerCase() || "fetch"} S3 configuration`
+ );
+ }
+
+ return response;
+ };
- const response = await fetch(
- `${clientEnv.VITE_SERVER_URL}/api/desktop/s3/config/get?origin=${window.location.origin}`,
+ onMount(async () => {
+ try {
+ const response = await makeAuthenticatedRequest(
+ "/api/desktop/s3/config/get",
{
method: "GET",
- credentials: "include",
- headers: {
- Authorization: `Bearer ${auth.token}`,
- },
}
);
- if (!response.ok) {
- throw new Error("Failed to fetch S3 configuration");
- }
+ if (!response) return;
const data = await response.json();
if (data.config) {
const config = data.config as S3Config;
- setProvider(config.provider || "aws");
- setAccessKeyId(config.accessKeyId || "");
- setSecretAccessKey(config.secretAccessKey || "");
- setEndpoint(config.endpoint || "https://s3.amazonaws.com");
- setBucketName(config.bucketName || "");
- setRegion(config.region || "us-east-1");
+ if (!config.accessKeyId) return;
+
+ setProvider(config.provider || DEFAULT_CONFIG.provider);
+ setAccessKeyId(config.accessKeyId || DEFAULT_CONFIG.accessKeyId);
+ setSecretAccessKey(
+ config.secretAccessKey || DEFAULT_CONFIG.secretAccessKey
+ );
+ setEndpoint(config.endpoint || DEFAULT_CONFIG.endpoint);
+ setBucketName(config.bucketName || DEFAULT_CONFIG.bucketName);
+ setRegion(config.region || DEFAULT_CONFIG.region);
+ setHasConfig(true);
}
} catch (error) {
console.error("Failed to fetch S3 config:", error);
@@ -70,23 +122,12 @@ export default function S3ConfigPage() {
const handleSave = async () => {
setSaving(true);
try {
- const auth = await authStore.get();
-
- if (!auth) {
- console.error("User not authenticated");
- const window = getCurrentWindow();
- window.close();
- return;
- }
-
- const response = await fetch(
- `${clientEnv.VITE_SERVER_URL}/api/desktop/s3/config?origin=${window.location.origin}`,
+ const response = await makeAuthenticatedRequest(
+ "/api/desktop/s3/config",
{
method: "POST",
- credentials: "include",
headers: {
"Content-Type": "application/json",
- Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify({
provider: provider(),
@@ -99,11 +140,12 @@ export default function S3ConfigPage() {
}
);
- if (!response.ok) {
- throw new Error("Failed to save S3 configuration");
+ if (response) {
+ setHasConfig(true);
+ await commands.globalMessageDialog(
+ "S3 configuration saved successfully"
+ );
}
-
- await commands.globalMessageDialog("S3 configuration saved successfully");
} catch (error) {
console.error("Failed to save S3 config:", error);
await commands.globalMessageDialog(
@@ -114,6 +156,57 @@ export default function S3ConfigPage() {
}
};
+ const handleDelete = async () => {
+ setDeleting(true);
+ try {
+ const response = await makeAuthenticatedRequest(
+ "/api/desktop/s3/config/delete",
+ {
+ method: "DELETE",
+ }
+ );
+
+ if (response) {
+ resetForm();
+ await commands.globalMessageDialog(
+ "S3 configuration deleted successfully"
+ );
+ }
+ } catch (error) {
+ console.error("Failed to delete S3 config:", error);
+ await commands.globalMessageDialog(
+ "Failed to delete S3 configuration. Please try again."
+ );
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const renderInput = (
+ label: string,
+ value: () => string,
+ setter: (value: string) => void,
+ placeholder: string,
+ type: "text" | "password" = "text"
+ ) => (
+
+ {label}
+
+ setter(e.currentTarget.value)
+ }
+ placeholder={placeholder}
+ class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
+ autocomplete="off"
+ autocapitalize="off"
+ autocorrect="off"
+ spellcheck={false}
+ />
+
+ );
+
return (
@@ -168,102 +261,67 @@ export default function S3ConfigPage() {
-
- Access Key ID
- setAccessKeyId(e.currentTarget.value)}
- placeholder="PL31OADSQNK"
- class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
- autocomplete="off"
- autocapitalize="off"
- autocorrect="off"
- spellcheck={false}
- />
-
-
-
- Secret Access Key
- setSecretAccessKey(e.currentTarget.value)}
- placeholder="PL31OADSQNK"
- class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
- autocomplete="off"
- autocapitalize="off"
- autocorrect="off"
- spellcheck={false}
- />
-
-
-
- Endpoint
- setEndpoint(e.currentTarget.value)}
- placeholder="https://s3.amazonaws.com"
- class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
- autocomplete="off"
- autocapitalize="off"
- autocorrect="off"
- spellcheck={false}
- />
-
-
-
- Bucket Name
- setBucketName(e.currentTarget.value)}
- placeholder="my-bucket"
- class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
- autocomplete="off"
- autocapitalize="off"
- autocorrect="off"
- spellcheck={false}
- />
-
-
-
- Region
- setRegion(e.currentTarget.value)}
- placeholder="us-east-1"
- class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
- autocomplete="off"
- autocapitalize="off"
- autocorrect="off"
- spellcheck={false}
- />
-
+ {renderInput(
+ "Access Key ID",
+ accessKeyId,
+ setAccessKeyId,
+ "PL31OADSQNK",
+ "password"
+ )}
+ {renderInput(
+ "Secret Access Key",
+ secretAccessKey,
+ setSecretAccessKey,
+ "PL31OADSQNK",
+ "password"
+ )}
+ {renderInput(
+ "Endpoint",
+ endpoint,
+ setEndpoint,
+ "https://s3.amazonaws.com"
+ )}
+ {renderInput(
+ "Bucket Name",
+ bucketName,
+ setBucketName,
+ "my-bucket"
+ )}
+ {renderInput("Region", region, setRegion, "us-east-1")}
)}
-
+
+ {hasConfig() && (
+
+ {deleting() ? "Removing..." : "Remove Config"}
+
+ )}
{saving() ? "Saving..." : "Save"}
diff --git a/apps/web/app/api/desktop/s3/config/delete/route.ts b/apps/web/app/api/desktop/s3/config/delete/route.ts
new file mode 100644
index 000000000..aca13d248
--- /dev/null
+++ b/apps/web/app/api/desktop/s3/config/delete/route.ts
@@ -0,0 +1,72 @@
+import { type NextRequest } from "next/server";
+import { db } from "@cap/database";
+import { s3Buckets } from "@cap/database/schema";
+import { eq } from "drizzle-orm";
+import { getCurrentUser } from "@cap/database/auth/session";
+import { cookies } from "next/headers";
+
+export async function DELETE(request: NextRequest) {
+ try {
+ const token = request.headers.get("authorization")?.split(" ")[1];
+ if (token) {
+ cookies().set({
+ name: "next-auth.session-token",
+ value: token,
+ path: "/",
+ sameSite: "none",
+ secure: true,
+ httpOnly: true,
+ });
+ }
+
+ const user = await getCurrentUser();
+ const origin = request.headers.get("origin") as string;
+
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401,
+ headers: {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
+ }
+
+ // Delete the S3 configuration for the user
+ await db.delete(s3Buckets).where(eq(s3Buckets.ownerId, user.id));
+
+ return new Response(JSON.stringify({ success: true }), {
+ headers: {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
+ } catch (error) {
+ console.error("Error in S3 config delete route:", error);
+ return new Response(
+ JSON.stringify({
+ error: "Failed to delete S3 configuration",
+ details: error instanceof Error ? error.message : String(error)
+ }),
+ {
+ status: 500,
+ headers: {
+ "Access-Control-Allow-Origin": request.headers.get("origin") as string,
+ "Access-Control-Allow-Credentials": "true",
+ },
+ }
+ );
+ }
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return new Response(null, {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin": request.headers.get("origin") as string,
+ "Access-Control-Allow-Methods": "DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ });
+}
\ No newline at end of file
diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts
index 9dcbe8982..de55e963b 100644
--- a/apps/web/app/api/playlist/route.ts
+++ b/apps/web/app/api/playlist/route.ts
@@ -3,7 +3,6 @@ import { db } from "@cap/database";
import { s3Buckets, videos } from "@cap/database/schema";
import { eq } from "drizzle-orm";
import {
- S3Client,
ListObjectsV2Command,
GetObjectCommand,
HeadObjectCommand,