diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx new file mode 100644 index 0000000000..358230aa55 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx @@ -0,0 +1,348 @@ +import { Button } from "@cap/ui-solid"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"; +import { createSignal, For, Show, Suspense } from "solid-js"; +import { commands } from "~/utils/tauri"; +import { apiClient, protectedHeaders } from "~/utils/web-api"; + +interface GoogleDriveConfig { + id: string; + email: string | null; + folderId: string | null; + folderName: string | null; + connected: boolean; +} + +interface DriveFolder { + id: string; + name: string; +} + +export default function GoogleDriveConfigPage() { + const queryClient = useQueryClient(); + const [showFolderSelector, setShowFolderSelector] = createSignal(false); + const [newFolderName, setNewFolderName] = createSignal(""); + + const configQuery = useQuery(() => ({ + queryKey: ["googleDriveConfig"], + queryFn: async () => { + const response = await apiClient.desktop.getGoogleDriveConfig({ + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to fetch config"); + return response.body.config; + }, + })); + + const foldersQuery = useQuery(() => ({ + queryKey: ["googleDriveFolders"], + queryFn: async () => { + const response = await apiClient.desktop.getGoogleDriveFolders({ + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to fetch folders"); + return response.body.folders; + }, + enabled: !!configQuery.data?.connected && showFolderSelector(), + })); + + const connectMutation = useMutation(() => ({ + mutationFn: async () => { + const response = await apiClient.desktop.getGoogleDriveAuthUrl({ + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to get auth URL"); + + const authUrl = response.body.authUrl; + + return new Promise((resolve, reject) => { + const handleMessage = async (event: MessageEvent) => { + if (event.data?.type === "google-drive-auth-success") { + window.removeEventListener("message", handleMessage); + resolve(event.data.code); + } else if (event.data?.type === "google-drive-auth-error") { + window.removeEventListener("message", handleMessage); + reject(new Error(event.data.error)); + } + }; + + window.addEventListener("message", handleMessage); + + const popup = window.open( + authUrl, + "google-drive-auth", + "width=600,height=700,scrollbars=yes", + ); + + if (!popup) { + window.removeEventListener("message", handleMessage); + reject(new Error("Popup blocked")); + } + + const checkClosed = setInterval(() => { + if (popup?.closed) { + clearInterval(checkClosed); + window.removeEventListener("message", handleMessage); + } + }, 1000); + }); + }, + onSuccess: async (code) => { + const response = await apiClient.desktop.exchangeGoogleDriveCode({ + body: { code }, + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to exchange code"); + await queryClient.invalidateQueries({ queryKey: ["googleDriveConfig"] }); + await commands.globalMessageDialog( + "Google Drive connected successfully!", + ); + }, + onError: async (error) => { + await commands.globalMessageDialog( + `Failed to connect Google Drive: ${error.message}`, + ); + }, + })); + + const disconnectMutation = useMutation(() => ({ + mutationFn: async () => { + const response = await apiClient.desktop.deleteGoogleDriveConfig({ + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to disconnect"); + return response; + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["googleDriveConfig"] }); + await commands.globalMessageDialog( + "Google Drive disconnected successfully", + ); + }, + })); + + const setFolderMutation = useMutation(() => ({ + mutationFn: async (folder: { id: string; name: string } | null) => { + const response = await apiClient.desktop.setGoogleDriveFolder({ + body: { + folderId: folder?.id ?? null, + folderName: folder?.name ?? null, + }, + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to set folder"); + return response; + }, + onSuccess: async () => { + setShowFolderSelector(false); + await queryClient.invalidateQueries({ queryKey: ["googleDriveConfig"] }); + await commands.globalMessageDialog("Folder updated successfully"); + }, + })); + + const createFolderMutation = useMutation(() => ({ + mutationFn: async (name: string) => { + const response = await apiClient.desktop.createGoogleDriveFolder({ + body: { name }, + headers: await protectedHeaders(), + }); + if (response.status !== 200) throw new Error("Failed to create folder"); + return response.body.folder; + }, + onSuccess: async (folder) => { + setNewFolderName(""); + await queryClient.invalidateQueries({ queryKey: ["googleDriveFolders"] }); + await setFolderMutation.mutateAsync(folder); + }, + })); + + return ( +
+
+
+ + +
+ } + > +
+
+
+ +

Google Drive

+
+

+ Connect your Google Drive to store and serve your screen + recordings directly from your Drive. Files will be uploaded to + a folder you choose, and shareable links will serve content + from Google Drive. +

+
+ + +
+

+ Click the button below to connect your Google account + and authorize Cap to store recordings in your Drive. +

+
+ + +
+ } + > +
+
+ +
+

Connected

+ +

+ {configQuery.data?.email} +

+
+
+
+ +
+ +
+
+ + {configQuery.data?.folderName || "Root (My Drive)"} + +
+ +
+
+ + +
+

+ Select a folder +

+ + + +
+ } + > +
+ + + {(folder) => ( + + )} + +
+ +
+

+ Or create a new folder: +

+
+ + setNewFolderName(e.currentTarget.value) + } + placeholder="New folder name" + class="flex-1 px-3 py-2 text-sm rounded-lg border border-transparent outline-none bg-gray-2 focus:border-gray-8" + /> + +
+
+
+
+ +
+ +
+ + + +
+ +
+ +
+
+
+ + ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx index 3581b30e07..0901e08ec7 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx @@ -25,6 +25,14 @@ export default function AppsTab() { url: "/settings/integrations/s3-config", pro: true, }, + { + name: "Google Drive", + description: + "Store and serve your screen recordings directly from your Google Drive. Connect your Google account to upload recordings to a folder you choose. Shareable links will serve content from Google Drive, giving you full control over your files in a familiar cloud storage environment.", + icon: IconLucideHardDrive, + url: "/settings/integrations/google-drive-config", + pro: true, + }, ]; const handleAppClick = async (app: (typeof apps)[number]) => { diff --git a/apps/web/app/api/desktop/[...route]/googleDriveConfig.ts b/apps/web/app/api/desktop/[...route]/googleDriveConfig.ts new file mode 100644 index 0000000000..d356cc8dd0 --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/googleDriveConfig.ts @@ -0,0 +1,815 @@ +import { db } from "@cap/database"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { nanoId } from "@cap/database/helpers"; +import { googleDriveConfigs } from "@cap/database/schema"; +import { GoogleDrive } from "@cap/web-domain"; +import { zValidator } from "@hono/zod-validator"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import { withAuth } from "@/app/api/utils"; + +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + +export const app = new Hono().use(withAuth); + +app.get("/get", async (c) => { + const user = c.get("user"); + + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + if (!config) { + return c.json({ + config: null, + }); + } + + return c.json({ + config: { + id: config.id, + email: config.email, + folderId: config.folderId, + folderName: config.folderName, + connected: true, + }, + }); + } catch (error) { + console.error("Error in Google Drive config get route:", error); + return c.json( + { + error: "Failed to fetch Google Drive configuration", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +}); + +app.get("/auth-url", async (c) => { + const clientId = process.env.GOOGLE_CLIENT_ID; + const redirectUri = `${process.env.NEXT_PUBLIC_WEB_URL || process.env.WEB_URL}/api/desktop/google-drive/config/callback`; + + if (!clientId) { + return c.json({ error: "Google OAuth not configured" }, { status: 500 }); + } + + const scope = [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ].join(" "); + + const authUrl = + "https://accounts.google.com/o/oauth2/v2/auth?" + + new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope, + access_type: "offline", + prompt: "consent", + }).toString(); + + return c.json({ authUrl }); +}); + +app.post( + "/exchange", + zValidator( + "json", + z.object({ + code: z.string(), + }), + ), + async (c) => { + const user = c.get("user"); + const { code } = c.req.valid("json"); + + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = `${process.env.NEXT_PUBLIC_WEB_URL || process.env.WEB_URL}/api/desktop/google-drive/config/callback`; + + if (!clientId || !clientSecret) { + return c.json({ error: "Google OAuth not configured" }, { status: 500 }); + } + + try { + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + console.error("Token exchange failed:", error); + return c.json( + { error: "Failed to exchange code for tokens" }, + { status: 500 }, + ); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + const userInfoResponse = await fetch(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + let email: string | null = null; + if (userInfoResponse.ok) { + const userInfo = (await userInfoResponse.json()) as { email: string }; + email = userInfo.email; + } + + const encryptedAccessToken = await encrypt(tokens.access_token); + const encryptedRefreshToken = await encrypt(tokens.refresh_token); + const expiresAt = Math.floor(Date.now() / 1000) + tokens.expires_in - 60; + + const [existingConfig] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + const configId = + existingConfig?.id || GoogleDrive.GoogleDriveConfigId.make(nanoId()); + + if (existingConfig) { + await db() + .update(googleDriveConfigs) + .set({ + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + expiresAt, + email, + }) + .where(eq(googleDriveConfigs.id, existingConfig.id)); + } else { + await db().insert(googleDriveConfigs).values({ + id: configId, + ownerId: user.id, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + expiresAt, + email, + }); + } + + return c.json({ + success: true, + config: { + id: configId, + email, + connected: true, + }, + }); + } catch (error) { + console.error("Error exchanging code:", error); + return c.json( + { + error: "Failed to connect Google Drive", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } + }, +); + +app.post( + "/set-folder", + zValidator( + "json", + z.object({ + folderId: z.string().nullable(), + folderName: z.string().nullable(), + }), + ), + async (c) => { + const user = c.get("user"); + const { folderId, folderName } = c.req.valid("json"); + + try { + await db() + .update(googleDriveConfigs) + .set({ folderId, folderName }) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + return c.json({ success: true }); + } catch (error) { + console.error("Error setting folder:", error); + return c.json( + { + error: "Failed to set folder", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } + }, +); + +app.get("/folders", async (c) => { + const user = c.get("user"); + + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + if (!config) { + return c.json({ error: "Google Drive not connected" }, { status: 400 }); + } + + let accessToken = await decrypt(config.accessToken); + const refreshToken = await decrypt(config.refreshToken); + + const now = Math.floor(Date.now() / 1000); + if (config.expiresAt <= now) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return c.json( + { error: "Google OAuth not configured" }, + { status: 500 }, + ); + } + + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + return c.json({ error: "Failed to refresh token" }, { status: 500 }); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + }; + + accessToken = tokens.access_token; + const newExpiresAt = + Math.floor(Date.now() / 1000) + tokens.expires_in - 60; + + await db() + .update(googleDriveConfigs) + .set({ + accessToken: await encrypt(accessToken), + expiresAt: newExpiresAt, + }) + .where(eq(googleDriveConfigs.id, config.id)); + } + + const response = await fetch( + `https://www.googleapis.com/drive/v3/files?` + + new URLSearchParams({ + q: "mimeType='application/vnd.google-apps.folder' and trashed=false", + fields: "files(id, name)", + pageSize: "100", + }), + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + + if (!response.ok) { + const error = await response.text(); + console.error("Failed to list folders:", error); + return c.json({ error: "Failed to list folders" }, { status: 500 }); + } + + const data = (await response.json()) as { + files: Array<{ id: string; name: string }>; + }; + return c.json({ folders: data.files }); + } catch (error) { + console.error("Error listing folders:", error); + return c.json( + { + error: "Failed to list folders", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +}); + +app.post( + "/create-folder", + zValidator( + "json", + z.object({ + name: z.string(), + }), + ), + async (c) => { + const user = c.get("user"); + const { name } = c.req.valid("json"); + + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + if (!config) { + return c.json({ error: "Google Drive not connected" }, { status: 400 }); + } + + let accessToken = await decrypt(config.accessToken); + const refreshToken = await decrypt(config.refreshToken); + + const now = Math.floor(Date.now() / 1000); + if (config.expiresAt <= now) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return c.json( + { error: "Google OAuth not configured" }, + { status: 500 }, + ); + } + + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + return c.json({ error: "Failed to refresh token" }, { status: 500 }); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + }; + + accessToken = tokens.access_token; + const newExpiresAt = + Math.floor(Date.now() / 1000) + tokens.expires_in - 60; + + await db() + .update(googleDriveConfigs) + .set({ + accessToken: await encrypt(accessToken), + expiresAt: newExpiresAt, + }) + .where(eq(googleDriveConfigs.id, config.id)); + } + + const response = await fetch( + "https://www.googleapis.com/drive/v3/files", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + mimeType: "application/vnd.google-apps.folder", + }), + }, + ); + + if (!response.ok) { + const error = await response.text(); + console.error("Failed to create folder:", error); + return c.json({ error: "Failed to create folder" }, { status: 500 }); + } + + const folder = (await response.json()) as { id: string; name: string }; + return c.json({ folder }); + } catch (error) { + console.error("Error creating folder:", error); + return c.json( + { + error: "Failed to create folder", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } + }, +); + +app.delete("/delete", async (c) => { + const user = c.get("user"); + + try { + await db() + .delete(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + return c.json({ success: true }); + } catch (error) { + console.error("Error in Google Drive config delete route:", error); + return c.json( + { + error: "Failed to delete Google Drive configuration", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +}); + +app.get("/callback", async (c) => { + const code = c.req.query("code"); + const error = c.req.query("error"); + + if (error) { + return c.html(` + + + Google Drive Connection + + +

Error: ${error}. You can close this window.

+ + + `); + } + + if (!code) { + return c.html(` + + + Google Drive Connection + + +

Error: No authorization code received. You can close this window.

+ + + `); + } + + return c.html(` + + + Google Drive Connection + + +

Authorization successful! You can close this window.

+ + + `); +}); + +app.post( + "/initiate-upload", + zValidator( + "json", + z.object({ + videoId: z.string(), + fileName: z.string(), + mimeType: z.string().default("video/mp4"), + }), + ), + async (c) => { + const user = c.get("user"); + const { videoId, fileName, mimeType } = c.req.valid("json"); + + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + if (!config) { + return c.json({ error: "Google Drive not connected" }, { status: 400 }); + } + + let accessToken = await decrypt(config.accessToken); + const refreshToken = await decrypt(config.refreshToken); + + const now = Math.floor(Date.now() / 1000); + if (config.expiresAt <= now) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return c.json( + { error: "Google OAuth not configured" }, + { status: 500 }, + ); + } + + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + return c.json({ error: "Failed to refresh token" }, { status: 500 }); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + }; + + accessToken = tokens.access_token; + const newExpiresAt = + Math.floor(Date.now() / 1000) + tokens.expires_in - 60; + + await db() + .update(googleDriveConfigs) + .set({ + accessToken: await encrypt(accessToken), + expiresAt: newExpiresAt, + }) + .where(eq(googleDriveConfigs.id, config.id)); + } + + const metadata = { + name: fileName, + mimeType, + parents: config.folderId ? [config.folderId] : undefined, + }; + + const initiateResponse = await fetch( + "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json; charset=UTF-8", + }, + body: JSON.stringify(metadata), + }, + ); + + if (!initiateResponse.ok) { + const error = await initiateResponse.text(); + console.error("Failed to initiate upload:", error); + return c.json({ error: "Failed to initiate upload" }, { status: 500 }); + } + + const uploadUrl = initiateResponse.headers.get("Location"); + if (!uploadUrl) { + return c.json({ error: "No upload URL returned" }, { status: 500 }); + } + + return c.json({ + uploadUrl, + accessToken, + expiresAt: config.expiresAt, + }); + } catch (error) { + console.error("Error initiating upload:", error); + return c.json( + { + error: "Failed to initiate upload", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } + }, +); + +app.post( + "/complete-upload", + zValidator( + "json", + z.object({ + videoId: z.string(), + fileId: z.string(), + }), + ), + async (c) => { + const user = c.get("user"); + const { videoId, fileId } = c.req.valid("json"); + + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + if (!config) { + return c.json({ error: "Google Drive not connected" }, { status: 400 }); + } + + let accessToken = await decrypt(config.accessToken); + const refreshToken = await decrypt(config.refreshToken); + + const now = Math.floor(Date.now() / 1000); + if (config.expiresAt <= now) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return c.json( + { error: "Google OAuth not configured" }, + { status: 500 }, + ); + } + + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + return c.json({ error: "Failed to refresh token" }, { status: 500 }); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + }; + + accessToken = tokens.access_token; + const newExpiresAt = + Math.floor(Date.now() / 1000) + tokens.expires_in - 60; + + await db() + .update(googleDriveConfigs) + .set({ + accessToken: await encrypt(accessToken), + expiresAt: newExpiresAt, + }) + .where(eq(googleDriveConfigs.id, config.id)); + } + + const permissionResponse = await fetch( + `https://www.googleapis.com/drive/v3/files/${fileId}/permissions`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "anyone", + role: "reader", + }), + }, + ); + + if (!permissionResponse.ok) { + console.error( + "Failed to make file public:", + await permissionResponse.text(), + ); + } + + const { videos } = await import("@cap/database/schema"); + const { Video } = await import("@cap/web-domain"); + + await db() + .update(videos) + .set({ googleDriveFileId: fileId }) + .where(eq(videos.id, Video.VideoId.make(videoId))); + + return c.json({ success: true, fileId }); + } catch (error) { + console.error("Error completing upload:", error); + return c.json( + { + error: "Failed to complete upload", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } + }, +); + +app.get("/access-token", async (c) => { + const user = c.get("user"); + + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + + if (!config) { + return c.json({ error: "Google Drive not connected" }, { status: 400 }); + } + + let accessToken = await decrypt(config.accessToken); + const refreshToken = await decrypt(config.refreshToken); + + const now = Math.floor(Date.now() / 1000); + if (config.expiresAt <= now) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return c.json( + { error: "Google OAuth not configured" }, + { status: 500 }, + ); + } + + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + return c.json({ error: "Failed to refresh token" }, { status: 500 }); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + }; + + accessToken = tokens.access_token; + const newExpiresAt = + Math.floor(Date.now() / 1000) + tokens.expires_in - 60; + + await db() + .update(googleDriveConfigs) + .set({ + accessToken: await encrypt(accessToken), + expiresAt: newExpiresAt, + }) + .where(eq(googleDriveConfigs.id, config.id)); + + return c.json({ + accessToken, + expiresAt: newExpiresAt, + folderId: config.folderId, + }); + } + + return c.json({ + accessToken, + expiresAt: config.expiresAt, + folderId: config.folderId, + }); + } catch (error) { + console.error("Error getting access token:", error); + return c.json( + { + error: "Failed to get access token", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +}); diff --git a/apps/web/app/api/desktop/[...route]/route.ts b/apps/web/app/api/desktop/[...route]/route.ts index d1a156d4d7..c6bab94d63 100644 --- a/apps/web/app/api/desktop/[...route]/route.ts +++ b/apps/web/app/api/desktop/[...route]/route.ts @@ -3,6 +3,7 @@ import { handle } from "hono/vercel"; import { corsMiddleware } from "../../utils"; +import * as googleDriveConfig from "./googleDriveConfig"; import * as root from "./root"; import * as s3Config from "./s3Config"; import * as session from "./session"; @@ -11,6 +12,7 @@ import * as video from "./video"; const app = new Hono() .basePath("/api/desktop") .use(corsMiddleware) + .route("/google-drive/config", googleDriveConfig.app) .route("/s3/config", s3Config.app) .route("/session", session.app) .route("/video", video.app) diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index ba0f540169..ee3e316307 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -3,6 +3,7 @@ import { sendEmail } from "@cap/database/emails/config"; import { FirstShareableLink } from "@cap/database/emails/first-shareable-link"; import { nanoId } from "@cap/database/helpers"; import { + googleDriveConfigs, organizationMembers, organizations, s3Buckets, @@ -85,6 +86,11 @@ app.get( .from(s3Buckets) .where(eq(s3Buckets.ownerId, user.id)); + const [googleDriveConfig] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.ownerId, user.id)); + const date = new Date(); const formattedDate = `${date.getDate()} ${date.toLocaleString( "default", @@ -180,6 +186,7 @@ app.get( : undefined, isScreenshot, bucket: customBucket?.id, + googleDriveConfigId: googleDriveConfig?.id, public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, duration: durationInSecs, width, @@ -244,8 +251,13 @@ app.get( return c.json({ id: idToUse, - // All deprecated user_id: user.id, + storageType: googleDriveConfig + ? "google-drive" + : customBucket + ? "s3" + : "default", + googleDriveConfigId: googleDriveConfig?.id ?? null, aws_region: "n/a", aws_bucket: "n/a", }); diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 7636468546..0255039602 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -1,3 +1,6 @@ +import { db } from "@cap/database"; +import { decrypt } from "@cap/database/crypto"; +import { googleDriveConfigs } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { provideOptionalAuth, S3Buckets, Videos } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -9,6 +12,7 @@ import { HttpApiGroup, HttpServerResponse, } from "@effect/platform"; +import { eq } from "drizzle-orm"; import { Effect, Layer, Option, Schema } from "effect"; import { apiToHandler } from "@/lib/server"; import { CACHE_CONTROL_HEADERS } from "@/utils/helpers"; @@ -17,6 +21,8 @@ import { generateMasterPlaylist, } from "@/utils/video/ffmpeg/helpers"; +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; + export const dynamic = "force-dynamic"; const GetPlaylistParams = Schema.Struct({ @@ -76,11 +82,82 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( ), ); +const getGoogleDriveVideoUrl = async (video: { + googleDriveConfigId: string; + googleDriveFileId: string; +}): Promise => { + try { + const [config] = await db() + .select() + .from(googleDriveConfigs) + .where(eq(googleDriveConfigs.id, video.googleDriveConfigId as any)); + + if (!config) { + return null; + } + + let accessToken = await decrypt(config.accessToken); + const refreshToken = await decrypt(config.refreshToken); + + const now = Math.floor(Date.now() / 1000); + if (config.expiresAt <= now) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return null; + } + + const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + return null; + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + }; + + accessToken = tokens.access_token; + } + + return `https://www.googleapis.com/drive/v3/files/${video.googleDriveFileId}?alt=media&access_token=${encodeURIComponent(accessToken)}`; + } catch (error) { + console.error("Error getting Google Drive video URL:", error); + return null; + } +}; + const getPlaylistResponse = ( video: Video.Video, urlParams: (typeof GetPlaylistParams)["Type"], ) => Effect.gen(function* () { + const googleDriveConfigId = Option.getOrNull(video.googleDriveConfigId); + const googleDriveFileId = Option.getOrNull(video.googleDriveFileId); + if (googleDriveConfigId && googleDriveFileId) { + const googleDriveUrl = yield* Effect.promise(() => + getGoogleDriveVideoUrl({ + ...video, + googleDriveConfigId, + googleDriveFileId, + }), + ); + if (googleDriveUrl) { + return HttpServerResponse.redirect(googleDriveUrl); + } + } + const [s3, customBucket] = yield* S3Buckets.getBucketAccess(video.bucketId); const isMp4Source = video.source.type === "desktopMP4" || video.source.type === "webMP4"; diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index 71368eec24..8ee5650410 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -128,6 +128,8 @@ export default async function EmbedVideoPage( effectiveCreatedAt: videos.effectiveCreatedAt, updatedAt: videos.updatedAt, bucket: videos.bucket, + googleDriveConfigId: videos.googleDriveConfigId, + googleDriveFileId: videos.googleDriveFileId, metadata: videos.metadata, public: videos.public, videoStartTime: videos.videoStartTime, diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 3e5d7a9b13..8add32d8ab 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -291,6 +291,8 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { updatedAt: videos.updatedAt, effectiveCreatedAt: videos.effectiveCreatedAt, bucket: videos.bucket, + googleDriveConfigId: videos.googleDriveConfigId, + googleDriveFileId: videos.googleDriveFileId, metadata: videos.metadata, public: videos.public, videoStartTime: videos.videoStartTime, diff --git a/core b/core new file mode 100644 index 0000000000..5282f3f6f9 Binary files /dev/null and b/core differ diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 14a2e9685e..9216442b27 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1,6 +1,7 @@ import type { Comment, Folder, + GoogleDrive, ImageUpload, Organisation, S3Bucket, @@ -296,7 +297,10 @@ export const videos = mysqlTable( orgId: nanoIdRequired("orgId").$type(), name: varchar("name", { length: 255 }).notNull().default("My Video"), bucket: nanoIdNullable("bucket").$type(), - // in seconds + googleDriveConfigId: nanoIdNullable( + "googleDriveConfigId", + ).$type(), + googleDriveFileId: varchar("googleDriveFileId", { length: 255 }), duration: float("duration"), width: int("width"), height: int("height"), @@ -460,7 +464,6 @@ export const notifications = mysqlTable( export const s3Buckets = mysqlTable("s3_buckets", { id: nanoId("id").notNull().primaryKey().$type(), ownerId: nanoId("ownerId").notNull().$type(), - // Use encryptedText for sensitive fields region: encryptedText("region").notNull(), endpoint: encryptedTextNullable("endpoint"), bucketName: encryptedText("bucketName").notNull(), @@ -469,6 +472,22 @@ export const s3Buckets = mysqlTable("s3_buckets", { provider: text("provider").notNull().default("aws"), }); +export const googleDriveConfigs = mysqlTable("google_drive_configs", { + id: nanoId("id") + .notNull() + .primaryKey() + .$type(), + ownerId: nanoId("ownerId").notNull().$type(), + accessToken: encryptedText("accessToken").notNull(), + refreshToken: encryptedText("refreshToken").notNull(), + expiresAt: int("expiresAt").notNull(), + email: varchar("email", { length: 255 }), + folderId: varchar("folderId", { length: 255 }), + folderName: varchar("folderName", { length: 255 }), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), +}); + export const notificationsRelations = relations(notifications, ({ one }) => ({ org: one(organizations, { fields: [notifications.orgId], @@ -527,6 +546,16 @@ export const s3BucketsRelations = relations(s3Buckets, ({ one }) => ({ }), })); +export const googleDriveConfigsRelations = relations( + googleDriveConfigs, + ({ one }) => ({ + owner: one(users, { + fields: [googleDriveConfigs.ownerId], + references: [users.id], + }), + }), +); + export const organizationsRelations = relations( organizations, ({ one, many }) => ({ diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index c0942de1d3..f847c0ba94 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -118,6 +118,119 @@ const protectedContract = c.router( }), responses: { 200: z.object({ success: z.literal(true) }) }, }, + getGoogleDriveConfig: { + method: "GET", + path: "/desktop/google-drive/config/get", + responses: { + 200: z.object({ + config: z + .object({ + id: z.string(), + email: z.string().nullable(), + folderId: z.string().nullable(), + folderName: z.string().nullable(), + connected: z.boolean(), + }) + .nullable(), + }), + }, + }, + getGoogleDriveAuthUrl: { + method: "GET", + path: "/desktop/google-drive/config/auth-url", + responses: { + 200: z.object({ authUrl: z.string() }), + }, + }, + exchangeGoogleDriveCode: { + method: "POST", + path: "/desktop/google-drive/config/exchange", + body: z.object({ code: z.string() }), + responses: { + 200: z.object({ + success: z.literal(true), + config: z.object({ + id: z.string(), + email: z.string().nullable(), + connected: z.boolean(), + }), + }), + }, + }, + setGoogleDriveFolder: { + method: "POST", + path: "/desktop/google-drive/config/set-folder", + body: z.object({ + folderId: z.string().nullable(), + folderName: z.string().nullable(), + }), + responses: { 200: z.object({ success: z.literal(true) }) }, + }, + getGoogleDriveFolders: { + method: "GET", + path: "/desktop/google-drive/config/folders", + responses: { + 200: z.object({ + folders: z.array(z.object({ id: z.string(), name: z.string() })), + }), + }, + }, + createGoogleDriveFolder: { + method: "POST", + path: "/desktop/google-drive/config/create-folder", + body: z.object({ name: z.string() }), + responses: { + 200: z.object({ + folder: z.object({ id: z.string(), name: z.string() }), + }), + }, + }, + deleteGoogleDriveConfig: { + method: "DELETE", + path: "/desktop/google-drive/config/delete", + responses: { 200: z.object({ success: z.literal(true) }) }, + }, + initiateGoogleDriveUpload: { + method: "POST", + path: "/desktop/google-drive/config/initiate-upload", + body: z.object({ + videoId: z.string(), + fileName: z.string(), + mimeType: z.string().optional(), + }), + responses: { + 200: z.object({ + uploadUrl: z.string(), + accessToken: z.string(), + expiresAt: z.number(), + }), + }, + }, + completeGoogleDriveUpload: { + method: "POST", + path: "/desktop/google-drive/config/complete-upload", + body: z.object({ + videoId: z.string(), + fileId: z.string(), + }), + responses: { + 200: z.object({ + success: z.literal(true), + fileId: z.string(), + }), + }, + }, + getGoogleDriveAccessToken: { + method: "GET", + path: "/desktop/google-drive/config/access-token", + responses: { + 200: z.object({ + accessToken: z.string(), + expiresAt: z.number(), + folderId: z.string().nullable(), + }), + }, + }, getProSubscribeURL: { method: "POST", path: "/desktop/subscribe", diff --git a/packages/web-backend/src/GoogleDrive/GoogleDriveAccess.ts b/packages/web-backend/src/GoogleDrive/GoogleDriveAccess.ts new file mode 100644 index 0000000000..f96656aae7 --- /dev/null +++ b/packages/web-backend/src/GoogleDrive/GoogleDriveAccess.ts @@ -0,0 +1,409 @@ +import { Readable } from "node:stream"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { GoogleDrive } from "@cap/web-domain"; +import { Effect, Option } from "effect"; + +export interface GoogleDriveTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +export interface GoogleDriveUploadResult { + fileId: string; + webViewLink: string; + webContentLink: string; +} + +export interface GoogleDriveFileInfo { + id: string; + name: string; + mimeType: string; + size?: string; + webViewLink?: string; + webContentLink?: string; +} + +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_DRIVE_API_URL = "https://www.googleapis.com/drive/v3"; +const GOOGLE_DRIVE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3"; + +export const createGoogleDriveAccess = (config: { + clientId: string; + clientSecret: string; + accessToken: string; + refreshToken: string; + expiresAt: number; + configId: GoogleDrive.GoogleDriveConfigId; + onTokenRefresh?: ( + accessToken: string, + expiresAt: number, + ) => Effect.Effect; +}) => + Effect.gen(function* () { + let currentAccessToken = config.accessToken; + let currentExpiresAt = config.expiresAt; + + const refreshAccessToken = Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: config.refreshToken, + grant_type: "refresh_token", + }), + }), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok) { + const error = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error(`Token refresh failed: ${error}`), + }), + ); + } + + const data = yield* Effect.tryPromise({ + try: () => + response.json() as Promise<{ + access_token: string; + expires_in: number; + }>, + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + currentAccessToken = data.access_token; + currentExpiresAt = Math.floor(Date.now() / 1000) + data.expires_in - 60; + + if (config.onTokenRefresh) { + yield* config.onTokenRefresh(currentAccessToken, currentExpiresAt); + } + + return currentAccessToken; + }); + + const getValidAccessToken = Effect.gen(function* () { + const now = Math.floor(Date.now() / 1000); + if (currentExpiresAt <= now) { + return yield* refreshAccessToken; + } + return currentAccessToken; + }); + + const makeRequest = ( + path: string, + options: RequestInit = {}, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* getValidAccessToken; + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${GOOGLE_DRIVE_API_URL}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok) { + const error = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error(`API request failed: ${error}`), + }), + ); + } + + return yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + }); + + const getUserInfo = Effect.gen(function* () { + const token = yield* getValidAccessToken; + const response = yield* Effect.tryPromise({ + try: () => + fetch("https://www.googleapis.com/oauth2/v2/userinfo", { + headers: { Authorization: `Bearer ${token}` }, + }), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok) { + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error("Failed to get user info"), + }), + ); + } + + return yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ email: string; name: string }>, + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + }); + + const createFolder = ( + name: string, + parentId?: string, + ): Effect.Effect< + { id: string; name: string }, + GoogleDrive.GoogleDriveError + > => + makeRequest("/files", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + mimeType: "application/vnd.google-apps.folder", + parents: parentId ? [parentId] : undefined, + }), + }); + + const listFolders = Effect.gen(function* () { + const result = yield* makeRequest<{ + files: Array<{ id: string; name: string }>; + }>( + "/files?" + + new URLSearchParams({ + q: "mimeType='application/vnd.google-apps.folder' and trashed=false", + fields: "files(id, name)", + pageSize: "100", + }), + ); + return result.files; + }); + + const uploadFile = ( + fileName: string, + mimeType: string, + data: ArrayBuffer | Uint8Array | Readable, + folderId?: string, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* getValidAccessToken; + + const metadata = { + name: fileName, + mimeType, + parents: folderId ? [folderId] : undefined, + }; + + let body: ArrayBuffer | Uint8Array; + if (data instanceof Readable) { + body = yield* Effect.promise(async () => { + const chunks: Uint8Array[] = []; + for await (const chunk of data) { + chunks.push(chunk); + } + return Buffer.concat(chunks); + }); + } else { + body = data; + } + + const boundary = "cap_upload_boundary_" + Date.now(); + const delimiter = "\r\n--" + boundary + "\r\n"; + const closeDelimiter = "\r\n--" + boundary + "--"; + + const metadataPart = `${delimiter}Content-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}`; + const mediaPart = `${delimiter}Content-Type: ${mimeType}\r\n\r\n`; + + const encoder = new TextEncoder(); + const metadataBytes = encoder.encode(metadataPart); + const mediaPartBytes = encoder.encode(mediaPart); + const closeBytes = encoder.encode(closeDelimiter); + + const bodyData = + body instanceof ArrayBuffer ? new Uint8Array(body) : body; + const fullBody = new Uint8Array( + metadataBytes.length + + mediaPartBytes.length + + bodyData.length + + closeBytes.length, + ); + fullBody.set(metadataBytes, 0); + fullBody.set(mediaPartBytes, metadataBytes.length); + fullBody.set(bodyData, metadataBytes.length + mediaPartBytes.length); + fullBody.set( + closeBytes, + metadataBytes.length + mediaPartBytes.length + bodyData.length, + ); + + const response = yield* Effect.tryPromise({ + try: () => + fetch( + `${GOOGLE_DRIVE_UPLOAD_URL}/files?uploadType=multipart&fields=id,webViewLink,webContentLink`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": `multipart/related; boundary=${boundary}`, + }, + body: fullBody, + }, + ), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok) { + const error = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error(`Upload failed: ${error}`), + }), + ); + } + + const result = yield* Effect.tryPromise({ + try: () => + response.json() as Promise<{ + id: string; + webViewLink: string; + webContentLink: string; + }>, + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + return { + fileId: result.id, + webViewLink: result.webViewLink, + webContentLink: result.webContentLink, + }; + }); + + const getFile = ( + fileId: string, + ): Effect.Effect => + makeRequest( + `/files/${fileId}?fields=id,name,mimeType,size,webViewLink,webContentLink`, + ); + + const getFileContent = ( + fileId: string, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* getValidAccessToken; + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${GOOGLE_DRIVE_API_URL}/files/${fileId}?alt=media`, { + headers: { Authorization: `Bearer ${token}` }, + }), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok) { + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error(`Failed to get file content`), + }), + ); + } + + return yield* Effect.tryPromise({ + try: () => response.arrayBuffer(), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + }); + + const getSignedUrl = ( + fileId: string, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* getValidAccessToken; + return `${GOOGLE_DRIVE_API_URL}/files/${fileId}?alt=media&access_token=${encodeURIComponent(token)}`; + }); + + const makeFilePublic = ( + fileId: string, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* getValidAccessToken; + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${GOOGLE_DRIVE_API_URL}/files/${fileId}/permissions`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "anyone", + role: "reader", + }), + }), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok) { + const error = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error(`Failed to make file public: ${error}`), + }), + ); + } + }); + + const deleteFile = ( + fileId: string, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* getValidAccessToken; + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${GOOGLE_DRIVE_API_URL}/files/${fileId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }), + catch: (e) => new GoogleDrive.GoogleDriveError({ cause: e }), + }); + + if (!response.ok && response.status !== 404) { + return yield* Effect.fail( + new GoogleDrive.GoogleDriveError({ + cause: new Error("Failed to delete file"), + }), + ); + } + }); + + return { + getUserInfo, + createFolder, + listFolders, + uploadFile, + getFile, + getFileContent, + getSignedUrl, + makeFilePublic, + deleteFile, + refreshAccessToken, + }; + }); + +export type GoogleDriveAccess = Effect.Effect.Success< + ReturnType +>; diff --git a/packages/web-backend/src/GoogleDrive/GoogleDriveConfigsRepo.ts b/packages/web-backend/src/GoogleDrive/GoogleDriveConfigsRepo.ts new file mode 100644 index 0000000000..6b2d6dc38d --- /dev/null +++ b/packages/web-backend/src/GoogleDrive/GoogleDriveConfigsRepo.ts @@ -0,0 +1,141 @@ +import * as Db from "@cap/database/schema"; +import { GoogleDrive, type User } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; +import { Effect, Option } from "effect"; + +import { Database } from "../Database.ts"; + +export class GoogleDriveConfigsRepo extends Effect.Service()( + "GoogleDriveConfigsRepo", + { + effect: Effect.gen(function* () { + const db = yield* Database; + + const getById = Effect.fn("GoogleDriveConfigsRepo.getById")( + (id: GoogleDrive.GoogleDriveConfigId) => + Effect.gen(function* () { + const [res] = yield* db.use((db) => + db + .select({ config: Db.googleDriveConfigs }) + .from(Db.googleDriveConfigs) + .where(Dz.eq(Db.googleDriveConfigs.id, id)), + ); + + return Option.fromNullable(res).pipe( + Option.map((v) => GoogleDrive.decodeSync(v.config)), + ); + }), + ); + + const getForUser = Effect.fn("GoogleDriveConfigsRepo.getForUser")( + (userId: User.UserId) => + Effect.gen(function* () { + const [res] = yield* db.use((db) => + db + .select({ config: Db.googleDriveConfigs }) + .from(Db.googleDriveConfigs) + .where(Dz.eq(Db.googleDriveConfigs.ownerId, userId)), + ); + + return Option.fromNullable(res).pipe( + Option.map((v) => GoogleDrive.decodeSync(v.config)), + ); + }), + ); + + const upsert = Effect.fn("GoogleDriveConfigsRepo.upsert")( + ( + userId: User.UserId, + data: { + id: GoogleDrive.GoogleDriveConfigId; + accessToken: string; + refreshToken: string; + expiresAt: number; + email?: string | null; + folderId?: string | null; + folderName?: string | null; + }, + ) => + Effect.gen(function* () { + yield* db.use((db) => + db + .insert(Db.googleDriveConfigs) + .values({ + id: data.id, + ownerId: userId, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresAt: data.expiresAt, + email: data.email ?? null, + folderId: data.folderId ?? null, + folderName: data.folderName ?? null, + }) + .onDuplicateKeyUpdate({ + set: { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresAt: data.expiresAt, + email: data.email ?? null, + folderId: data.folderId ?? null, + folderName: data.folderName ?? null, + }, + }), + ); + }), + ); + + const updateTokens = Effect.fn("GoogleDriveConfigsRepo.updateTokens")( + ( + id: GoogleDrive.GoogleDriveConfigId, + accessToken: string, + expiresAt: number, + ) => + Effect.gen(function* () { + yield* db.use((db) => + db + .update(Db.googleDriveConfigs) + .set({ accessToken, expiresAt }) + .where(Dz.eq(Db.googleDriveConfigs.id, id)), + ); + }), + ); + + const updateFolder = Effect.fn("GoogleDriveConfigsRepo.updateFolder")( + ( + id: GoogleDrive.GoogleDriveConfigId, + folderId: string | null, + folderName: string | null, + ) => + Effect.gen(function* () { + yield* db.use((db) => + db + .update(Db.googleDriveConfigs) + .set({ folderId, folderName }) + .where(Dz.eq(Db.googleDriveConfigs.id, id)), + ); + }), + ); + + const deleteForUser = Effect.fn("GoogleDriveConfigsRepo.deleteForUser")( + (userId: User.UserId) => + Effect.gen(function* () { + yield* db.use((db) => + db + .delete(Db.googleDriveConfigs) + .where(Dz.eq(Db.googleDriveConfigs.ownerId, userId)), + ); + }), + ); + + return { + getById, + getForUser, + upsert, + updateTokens, + updateFolder, + deleteForUser, + }; + }), + dependencies: [Database.Default], + }, +) {} diff --git a/packages/web-backend/src/GoogleDrive/index.ts b/packages/web-backend/src/GoogleDrive/index.ts new file mode 100644 index 0000000000..4e9581c11d --- /dev/null +++ b/packages/web-backend/src/GoogleDrive/index.ts @@ -0,0 +1,92 @@ +import { decrypt, encrypt } from "@cap/database/crypto"; +import type { GoogleDrive, User } from "@cap/web-domain"; +import { Config, Effect, Option } from "effect"; + +import { Database } from "../Database.ts"; +import { createGoogleDriveAccess } from "./GoogleDriveAccess.ts"; +import { GoogleDriveConfigsRepo } from "./GoogleDriveConfigsRepo.ts"; + +export { + createGoogleDriveAccess, + type GoogleDriveAccess, + type GoogleDriveUploadResult, +} from "./GoogleDriveAccess.ts"; +export { GoogleDriveConfigsRepo } from "./GoogleDriveConfigsRepo.ts"; + +export class GoogleDriveService extends Effect.Service()( + "GoogleDriveService", + { + effect: Effect.gen(function* () { + const repo = yield* GoogleDriveConfigsRepo; + + const clientId = yield* Config.string("GOOGLE_CLIENT_ID").pipe( + Config.orElse(() => Config.succeed("")), + ); + const clientSecret = yield* Config.string("GOOGLE_CLIENT_SECRET").pipe( + Config.orElse(() => Config.succeed("")), + ); + + const getAccessForConfig = Effect.fn( + "GoogleDriveService.getAccessForConfig", + )(function* (config: GoogleDrive.GoogleDriveConfig) { + const accessToken = yield* Effect.promise(() => + decrypt(config.accessToken), + ); + const refreshToken = yield* Effect.promise(() => + decrypt(config.refreshToken), + ); + + return yield* createGoogleDriveAccess({ + clientId, + clientSecret, + accessToken, + refreshToken, + expiresAt: config.expiresAt, + configId: config.id, + }); + }); + + const getAccessForUser = Effect.fn("GoogleDriveService.getAccessForUser")( + function* (userId: User.UserId) { + const configOption = yield* repo.getForUser(userId); + if (Option.isNone(configOption)) { + return Option.none< + Effect.Effect.Success> + >(); + } + const access = yield* getAccessForConfig(configOption.value); + return Option.some(access); + }, + ); + + const getAccessById = Effect.fn("GoogleDriveService.getAccessById")( + function* (configId: GoogleDrive.GoogleDriveConfigId) { + const configOption = yield* repo.getById(configId); + if (Option.isNone(configOption)) { + return Option.none< + Effect.Effect.Success> + >(); + } + const access = yield* getAccessForConfig(configOption.value); + return Option.some(access); + }, + ); + + return { + repo, + clientId, + clientSecret, + getAccessForUser, + getAccessById, + getAccessForConfig, + }; + }), + dependencies: [GoogleDriveConfigsRepo.Default, Database.Default], + }, +) { + static getAccessForUser = (userId: User.UserId) => + Effect.flatMap(GoogleDriveService, (s) => s.getAccessForUser(userId)); + + static getAccessById = (configId: GoogleDrive.GoogleDriveConfigId) => + Effect.flatMap(GoogleDriveService, (s) => s.getAccessById(configId)); +} diff --git a/packages/web-backend/src/Loom/ImportVideo.ts b/packages/web-backend/src/Loom/ImportVideo.ts index fde9f79f12..267377bbb1 100644 --- a/packages/web-backend/src/Loom/ImportVideo.ts +++ b/packages/web-backend/src/Loom/ImportVideo.ts @@ -72,6 +72,8 @@ export const LoomImportVideoLive = Loom.ImportVideo.toLayer( ownerId: payload.cap.userId, orgId: payload.cap.orgId, bucketId: customBucketId, + googleDriveConfigId: Option.none(), + googleDriveFileId: Option.none(), source: { type: "desktopMP4" as const }, name: payload.loom.video.name, duration: Option.fromNullable(loomVideo.durationSecs), diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index fb744addb7..965edc4409 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -69,6 +69,12 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { id, orgId: data.orgId, bucket: Option.getOrNull(data.bucketId ?? Option.none()), + googleDriveConfigId: Option.getOrNull( + data.googleDriveConfigId ?? Option.none(), + ), + googleDriveFileId: Option.getOrNull( + data.googleDriveFileId ?? Option.none(), + ), metadata: Option.getOrNull(data.metadata ?? Option.none()), transcriptionStatus: Option.getOrNull( data.transcriptionStatus ?? Option.none(), diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index ca26c15f7b..954e1c6dd6 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -392,6 +392,8 @@ export class Videos extends Effect.Service()("Videos", { public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, source: { type: "webMP4" }, bucketId, + googleDriveConfigId: Option.none(), + googleDriveFileId: Option.none(), folderId, width, height, diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 011fca9ce7..66bd4ab1c5 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -2,6 +2,10 @@ export * from "./Auth.ts"; export * from "./Aws.ts"; export * from "./Database.ts"; export { Folders } from "./Folders/index.ts"; +export { + GoogleDriveConfigsRepo, + GoogleDriveService, +} from "./GoogleDrive/index.ts"; export { HttpLive } from "./Http/Live.ts"; export { ImageUploads } from "./ImageUploads/index.ts"; export * from "./Loom/index.ts"; diff --git a/packages/web-domain/src/GoogleDrive.ts b/packages/web-domain/src/GoogleDrive.ts new file mode 100644 index 0000000000..08ccaa26ec --- /dev/null +++ b/packages/web-domain/src/GoogleDrive.ts @@ -0,0 +1,31 @@ +import { Schema } from "effect"; +import { UserId } from "./User.ts"; + +export const GoogleDriveConfigId = Schema.String.pipe( + Schema.brand("GoogleDriveConfigId"), +); +export type GoogleDriveConfigId = typeof GoogleDriveConfigId.Type; + +export class GoogleDriveConfig extends Schema.Class( + "GoogleDriveConfig", +)({ + id: GoogleDriveConfigId, + ownerId: UserId, + accessToken: Schema.String, + refreshToken: Schema.String, + expiresAt: Schema.Number, + email: Schema.OptionFromNullOr(Schema.String), + folderId: Schema.OptionFromNullOr(Schema.String), + folderName: Schema.OptionFromNullOr(Schema.String), +}) {} + +export const Workflows = [] as const; + +export const decodeSync = Schema.decodeSync(GoogleDriveConfig); + +export class GoogleDriveError extends Schema.TaggedError()( + "GoogleDriveError", + { + cause: Schema.Unknown, + }, +) {} diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 53112f6c61..355d9386ec 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -4,6 +4,7 @@ import { Context, Effect, Option, Schema } from "effect"; import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; import { FolderId } from "./Folder.ts"; +import { GoogleDriveConfigId } from "./GoogleDrive.ts"; import { OrganisationId } from "./Organisation.ts"; import { PolicyDeniedError } from "./Policy.ts"; import { S3BucketId } from "./S3Bucket.ts"; @@ -26,6 +27,8 @@ export class Video extends Schema.Class