From a878ca748b14f48dc8e7208e49444cae325376de Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:04:07 +0000 Subject: [PATCH 1/7] wip: Apps screen --- apps/desktop/src/routes/(window-chrome)/settings.tsx | 5 +++++ apps/desktop/src/routes/(window-chrome)/settings/apps.tsx | 3 +++ packages/ui-solid/icons/apps.svg | 6 ++++++ packages/ui-solid/src/auto-imports.d.ts | 4 ++++ 4 files changed, 18 insertions(+) create mode 100644 apps/desktop/src/routes/(window-chrome)/settings/apps.tsx create mode 100644 packages/ui-solid/icons/apps.svg diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index 2a21af58d..39b3a47e6 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -30,6 +30,11 @@ export default function Settings(props: RouteSectionProps) { name: "Previous Screenshots", icon: IconLucideCamera, }, + { + href: "apps", + name: "Cap Apps", + icon: IconCapApps, + }, { href: "feedback", name: "Feedback", diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps.tsx new file mode 100644 index 000000000..12f8aeea8 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/apps.tsx @@ -0,0 +1,3 @@ +export default function AppsTab() { + return
; +} diff --git a/packages/ui-solid/icons/apps.svg b/packages/ui-solid/icons/apps.svg new file mode 100644 index 000000000..ec1a56c0f --- /dev/null +++ b/packages/ui-solid/icons/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index c458e30d6..d8bedf606 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 IconCapAudioOn: typeof import('~icons/cap/audio-on.jsx')['default'] const IconCapBlur: typeof import('~icons/cap/blur.jsx')['default'] const IconCapCamera: typeof import('~icons/cap/camera.jsx')['default'] @@ -46,8 +47,11 @@ declare global { const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideCamera: typeof import('~icons/lucide/camera.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.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'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] + const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.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 d58c7d7ecee19d6ad0b9f8d54ac6bfa06290844e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 22 Nov 2024 14:35:55 +0000 Subject: [PATCH 2/7] feat: New Cap Apps tab, with S3 config page (including endpoints etc) --- .../src/routes/(window-chrome)/(main).tsx | 19 ++ .../src/routes/(window-chrome)/settings.tsx | 2 +- .../routes/(window-chrome)/settings/apps.tsx | 3 - .../(window-chrome)/settings/apps/index.tsx | 60 +++++ .../settings/apps/s3-config.tsx | 226 ++++++++++++++++++ .../(window-chrome)/settings/general.tsx | 114 +++++---- .../src/routes/(window-chrome)/upgrade.tsx | 2 - .../app/api/desktop/s3/config/get/route.ts | 119 +++++++++ apps/web/app/api/desktop/s3/config/route.ts | 187 +++++++++++++++ packages/ui-solid/src/auto-imports.d.ts | 6 + 10 files changed, 682 insertions(+), 56 deletions(-) delete mode 100644 apps/desktop/src/routes/(window-chrome)/settings/apps.tsx create mode 100644 apps/desktop/src/routes/(window-chrome)/settings/apps/index.tsx create mode 100644 apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx create mode 100644 apps/web/app/api/desktop/s3/config/get/route.ts create mode 100644 apps/web/app/api/desktop/s3/config/route.ts diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 3d71e8b8e..07013faf1 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -120,6 +120,25 @@ export default function () {
+ + + + + + + Cap Apps + + + + +
; -} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/apps/index.tsx b/apps/desktop/src/routes/(window-chrome)/settings/apps/index.tsx new file mode 100644 index 000000000..32b5e6b1c --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/index.tsx @@ -0,0 +1,60 @@ +import { Button } from "@cap/ui-solid"; +import { useNavigate } from "@solidjs/router"; +import { For, createResource } from "solid-js"; +import { commands } from "~/utils/tauri"; + +export default function AppsTab() { + const navigate = useNavigate(); + const [isUpgraded] = createResource(() => commands.checkUpgradedAndUpdate()); + + const apps = [ + { + name: "S3 Config", + description: + "Connect your own S3 bucket. All new shareable link uploads will be uploaded here. Maintain complete ownership over your data.", + icon: IconLucideDatabase, + url: "/settings/apps/s3-config", + pro: true, + }, + ]; + + const handleAppClick = async (app: (typeof apps)[number]) => { + if (app.pro && !isUpgraded()) { + await commands.showWindow("Upgrade"); + return; + } + navigate(app.url); + }; + + return ( +
+ + {(app) => ( +
+
+
+
+ +
+
+ + {app.name} + +
+
+ +
+
+

{app.description}

+
+
+ )} +
+
+ ); +} 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 new file mode 100644 index 000000000..887586faf --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/apps/s3-config.tsx @@ -0,0 +1,226 @@ +import { Button } from "@cap/ui-solid"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { createSignal, onMount } from "solid-js"; +import { authStore } from "~/store"; +import { clientEnv } from "~/utils/env"; +import { commands } from "~/utils/tauri"; + +interface S3Config { + accessKeyId: string; + secretAccessKey: string; + endpoint: string; + bucketName: string; + region: string; +} + +export default function S3ConfigPage() { + 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 [saving, setSaving] = createSignal(false); + const [loading, setLoading] = createSignal(true); + + onMount(async () => { + 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/get?origin=${window.location.origin}`, + { + method: "GET", + credentials: "include", + headers: { + Authorization: `Bearer ${auth.token}`, + }, + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch S3 configuration"); + } + + const data = await response.json(); + if (data.config) { + const config = data.config as S3Config; + setAccessKeyId(config.accessKeyId); + setSecretAccessKey(config.secretAccessKey); + setEndpoint(config.endpoint || "https://s3.amazonaws.com"); + setBucketName(config.bucketName); + setRegion(config.region || "us-east-1"); + } + } catch (error) { + console.error("Failed to fetch S3 config:", error); + } finally { + setLoading(false); + } + }); + + 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}`, + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + accessKeyId: accessKeyId(), + secretAccessKey: secretAccessKey(), + endpoint: endpoint(), + bucketName: bucketName(), + region: region(), + }), + } + ); + + if (!response.ok) { + throw new Error("Failed to save S3 configuration"); + } + + await commands.globalMessageDialog("S3 configuration saved successfully"); + } catch (error) { + console.error("Failed to save S3 config:", error); + await commands.globalMessageDialog( + "Failed to save S3 configuration. Please check your settings and try again." + ); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+ {loading() ? ( +
+
+
+ ) : ( +
+
+ + 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} + /> +
+ +
+ + 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} + /> +
+ +
+ + 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} + /> +
+ +
+ + 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} + /> +
+ +
+ + 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} + /> +
+
+ )} +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 2ab22cc21..0701e2c31 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -12,48 +12,51 @@ const settingsList: Array<{ key: keyof GeneralSettingsStore; label: string; description: string; - platforms?: OsType[], + platforms?: OsType[]; requiresPermission?: boolean; + pro?: boolean; }> = [ - { - key: "uploadIndividualFiles", - label: "Upload individual recording files when creating shareable link", - description: - 'Warning: this will cause shareable link uploads to become significantly slower, since all individual recording files will be uploaded. Shows "Download Assets" button in Share page.', - }, - { - key: "openEditorAfterRecording", - label: "Open editor automatically after recording stops", - description: - "The editor will be shown immediately after you finish recording.", - }, - { - key: "hideDockIcon", - label: "Hide dock icon", - platforms: ["macos"], - description: - "The dock icon will be hidden when there are no windows available to close.", - }, - { - key: "autoCreateShareableLink", - label: "Cap Pro: Automatically create shareable link after recording", - description: - "When enabled, a shareable link will be created automatically after stopping the recording. You'll be redirected to the URL while the upload continues in the background.", - }, - { - key: "disableAutoOpenLinks", - label: "Cap Pro: Disable automatic link opening", - description: - "When enabled, Cap will not automatically open links in your browser (e.g. after creating a shareable link).", - }, - { - key: "enableNotifications", - label: "Enable System Notifications", - description: - "Show system notifications for events like copying to clipboard, saving files, and more. You may need to manually allow Cap access via your system's notification settings.", - requiresPermission: true, - }, - ]; + { + key: "uploadIndividualFiles", + label: "Upload individual recording files when creating shareable link", + description: + 'Warning: this will cause shareable link uploads to become significantly slower, since all individual recording files will be uploaded. Shows "Download Assets" button in Share page.', + }, + { + key: "openEditorAfterRecording", + label: "Open editor automatically after recording stops", + description: + "The editor will be shown immediately after you finish recording.", + }, + { + key: "hideDockIcon", + label: "Hide dock icon", + platforms: ["macos"], + description: + "The dock icon will be hidden when there are no windows available to close.", + }, + { + key: "autoCreateShareableLink", + label: "Automatically create shareable link after recording", + description: + "When enabled, a shareable link will be created automatically after stopping the recording. You'll be redirected to the URL while the upload continues in the background.", + pro: true, + }, + { + key: "disableAutoOpenLinks", + label: "Disable automatic link opening", + description: + "When enabled, Cap will not automatically open links in your browser (e.g. after creating a shareable link).", + pro: true, + }, + { + key: "enableNotifications", + label: "Enable System Notifications", + description: + "Show system notifications for events like copying to clipboard, saving files, and more. You may need to manually allow Cap access via your system's notification settings.", + requiresPermission: true, + }, +]; export default function GeneralSettings() { const [store] = createResource(() => generalSettingsStore.get()); @@ -112,10 +115,19 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
{(setting) => ( - +
+ {setting.pro && ( + + Cap Pro + + )}
-

{setting.label}

+
+

{setting.label}

+
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. +

+
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.

+ +
+ +
+ +
+ + + +
+
+
+
({ 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" + ) => ( +
+ + + 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() {
-
- - 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} - /> -
- -
- - 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} - /> -
- -
- - 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} - /> -
- -
- - 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} - /> -
- -
- - 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() && ( + + )} 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,