From fd919fabfc1f87a1bee738ef8cd0639f3e3a1d6a Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:52:26 +1000 Subject: [PATCH] feat(auth): add desktop sign-in and cloud upload foundations --- .env.example | 3 + docs/authentication.md | 52 +++ electron-builder.json5 | 6 + electron/authCallback.ts | 121 ++++++ electron/ipc/cloudShareContract.ts | 100 +++++ electron/ipc/register/cloudShare.test.ts | 92 ++++ electron/ipc/register/cloudShare.ts | 433 +++++++++++++++++++ package-lock.json | 104 ++++- package.json | 2 + src/components/auth/RecordlySignInDialog.tsx | 244 +++++++++++ src/components/auth/useRecordlyAuth.ts | 65 +++ src/lib/auth/recordlyAuth.ts | 83 ++++ 12 files changed, 1304 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 docs/authentication.md create mode 100644 electron/authCallback.ts create mode 100644 electron/ipc/cloudShareContract.ts create mode 100644 electron/ipc/register/cloudShare.test.ts create mode 100644 electron/ipc/register/cloudShare.ts create mode 100644 src/components/auth/RecordlySignInDialog.tsx create mode 100644 src/components/auth/useRecordlyAuth.ts create mode 100644 src/lib/auth/recordlyAuth.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..45d02d2a --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Public client configuration. Never put a Supabase service-role key here. +VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co +VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOUR_KEY diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..fde2b3d4 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,52 @@ +# Recordly authentication setup + +Recordly uses Supabase Auth for one shared session across email/password and Google. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. X and SAML are intentionally hidden until those providers are configured. + +## 1. Create the project + +1. Create a Supabase project. +2. In **Project settings → API**, copy the project URL and publishable key. +3. Copy `.env.example` to `.env.local` and fill in both values. Never use the service-role key in the desktop application. +4. In **Authentication → URL configuration**, add `recordly://auth/callback` and `http://127.0.0.1:43821/auth/callback` to the redirect allow list. + +Email/password works once email authentication is enabled and a user has been created. Password-reset emails use the same desktop callback. + +## 2. Google + +1. Create a Web OAuth client in Google Auth Platform. +2. Add Supabase's callback URL, `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`, as an authorized redirect URI. +3. Enable Google under **Supabase → Authentication → Sign In / Providers** and paste the Google client ID and secret. +4. Keep the requested scopes to `openid`, email, and profile unless Recordly genuinely needs more. + +## 3. X (currently hidden) + +1. Create an OAuth 2.0 app in the X Developer Dashboard and enable requesting the user's email. +2. Set its callback URL to `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`. +3. Enable X/Twitter OAuth 2.0 in Supabase and paste the client ID and secret. + +## 4. SAML SSO (currently hidden) + +SAML is configured per customer workspace. Supabase's SAML support requires Pro or above. + +1. Enable SAML in the Supabase Auth provider settings. +2. Obtain the customer's IdP metadata URL or metadata XML file. +3. Register the connection and its email domain with the Supabase CLI, for example: + + ```sh + supabase sso add --type saml --project-ref YOUR_PROJECT_REF \ + --metadata-url 'https://customer.example/idp/metadata' \ + --domains customer.example + ``` + +The Recordly modal extracts the domain from the entered email address and starts the matching SAML connection. + +## 5. Verify locally + +Restart `npm run dev` after creating `.env.local`. Open an editor and verify: + +1. The account button opens **Sign into Recordly**. +2. Email/password creates a persistent Supabase session. +3. Google opens the system browser and returns to Recordly. +4. Clicking **Create link** while signed out opens this modal; after successful authentication it continues to the share dialog. + +Add the same `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` values to the share Worker's secrets or variables. The Worker validates the user's access token with Supabase before accepting an upload. `API_SECRET` is server-side only and remains available for library administration and explicitly enabled local integration tests; it is never entered into or exposed by the desktop app. diff --git a/electron-builder.json5 b/electron-builder.json5 index 73d57fb8..4a6b6aed 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -11,6 +11,12 @@ "electron/native/bin/**" ], "productName": "Recordly", + "protocols": [ + { + "name": "Recordly authentication", + "schemes": ["recordly"] + } + ], "npmRebuild": true, "buildDependenciesFromSource": true, "compression": "normal", diff --git a/electron/authCallback.ts b/electron/authCallback.ts new file mode 100644 index 00000000..bfbbbea9 --- /dev/null +++ b/electron/authCallback.ts @@ -0,0 +1,121 @@ +import { createServer, type Server } from "node:http"; +import { app, BrowserWindow, ipcMain } from "electron"; + +const DEV_CALLBACK_HOST = "127.0.0.1:43821"; +const DEV_CALLBACK_ORIGIN = `http://${DEV_CALLBACK_HOST}`; +const CALLBACK_PATH = "/callback"; +const LOOPBACK_CALLBACK_PATH = "/auth/callback"; +const MAX_CALLBACK_URL_LENGTH = 8192; +const CALLBACK_PARAMETERS = new Set(["code", "error", "error_code", "error_description"]); + +type AuthCallbackOptions = { + isDev: boolean; + focusApp: () => void; +}; + +function callbackHeaders(contentType: string) { + return { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + "Content-Type": contentType, + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }; +} + +export function createAuthCallbackController({ isDev, focusApp }: AuthCallbackOptions) { + const protocol = isDev ? "recordly-dev" : "recordly"; + let pendingUrl: string | null = null; + let server: Server | null = null; + + function parseCallback(rawUrl: string): URL | null { + if (rawUrl.length > MAX_CALLBACK_URL_LENGTH) return null; + try { + const url = new URL(rawUrl); + if (url.protocol !== `${protocol}:` || url.hostname !== "auth") return null; + if (url.pathname !== CALLBACK_PATH || url.username || url.password) return null; + return url; + } catch { + return null; + } + } + + function find(args: readonly string[]) { + return args.find((arg) => parseCallback(arg) !== null) ?? null; + } + + function dispatch(rawUrl: string) { + const url = parseCallback(rawUrl); + if (!url) return false; + pendingUrl = url.href; + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send("auth:callback", url.href); + } + if (app.isReady()) focusApp(); + return true; + } + + function startDevServer() { + if (!isDev || server) return; + server = createServer((request, response) => { + if ( + request.method !== "GET" || + request.headers.host !== DEV_CALLBACK_HOST || + (request.url?.length ?? 0) > MAX_CALLBACK_URL_LENGTH + ) { + response.writeHead(404, callbackHeaders("text/plain; charset=utf-8")); + response.end("Not found"); + return; + } + + const requestUrl = new URL(request.url ?? "/", DEV_CALLBACK_ORIGIN); + if (requestUrl.pathname !== LOOPBACK_CALLBACK_PATH) { + response.writeHead(404, callbackHeaders("text/plain; charset=utf-8")); + response.end("Not found"); + return; + } + + const appUrl = new URL(`${protocol}://auth${CALLBACK_PATH}`); + for (const key of CALLBACK_PARAMETERS) { + for (const value of requestUrl.searchParams.getAll(key)) { + appUrl.searchParams.append(key, value); + } + } + if (!appUrl.searchParams.has("code") && !appUrl.searchParams.has("error")) { + response.writeHead(400, callbackHeaders("text/plain; charset=utf-8")); + response.end("Invalid authentication callback"); + return; + } + + dispatch(appUrl.href); + response.writeHead(200, callbackHeaders("text/html; charset=utf-8")); + response.end( + 'Signed into Recordly

Signed into Recordly

You can close this tab and return to the app.

', + ); + }); + server.on("error", (error) => { + console.error("[auth] Could not start local callback server", error); + server = null; + }); + server.listen(43821, "127.0.0.1"); + } + + function close() { + server?.close(); + server = null; + } + + app.on("open-url", (event, url) => { + event.preventDefault(); + dispatch(url); + }); + + ipcMain.handle("auth:get-pending-callback", () => { + const callback = pendingUrl; + pendingUrl = null; + return callback; + }); + + return { close, dispatch, find, protocol, startDevServer }; +} diff --git a/electron/ipc/cloudShareContract.ts b/electron/ipc/cloudShareContract.ts new file mode 100644 index 00000000..f85a28a2 --- /dev/null +++ b/electron/ipc/cloudShareContract.ts @@ -0,0 +1,100 @@ +export type CloudShareTicket = { + uploadUrl: string; + shareUrl: string; + shareCode?: string; + method: "PUT"; + headers: Record; + finalizeUrl?: string; +}; + +const TRUSTED_SHARE_ORIGINS = new Set([ + "https://videos.recordly.dev", + "http://localhost:8787", + "http://127.0.0.1:8787", +]); + +function parseUrl(value: unknown, label: string): URL { + if (typeof value !== "string") throw new Error(`${label} is missing.`); + const url = new URL(value); + if (url.username || url.password) throw new Error(`${label} cannot contain credentials.`); + return url; +} + +export function normalizeCloudEndpoint(value: unknown): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("Enter a cloud share endpoint."); + } + const url = parseUrl(value.trim(), "Cloud share endpoint"); + if (!TRUSTED_SHARE_ORIGINS.has(url.origin)) { + throw new Error("Cloud sharing is only allowed through the Recordly service."); + } + if (url.pathname !== "/api/upload" || url.search) { + throw new Error("The cloud share endpoint is invalid."); + } + url.hash = ""; + return url.toString(); +} + +function parseHttpUrl(value: unknown, label: string): string { + const url = parseUrl(value, `${label} from the server response`); + const localHttp = + url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1"); + if (url.protocol !== "https:" && !localHttp) { + throw new Error(`${label} must use HTTPS (HTTP is allowed for localhost).`); + } + return url.toString(); +} + +export function parseCloudShareTicket(value: unknown): CloudShareTicket { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("The cloud share server returned an invalid response."); + } + const input = value as Record; + const uploadUrl = input.uploadUrl ?? input.uploadURL; + const shareUrl = input.shareUrl ?? input.shareURL; + const shareCode = + typeof input.shareCode === "string" && /^[a-z0-9]+$/.test(input.shareCode) + ? input.shareCode + : undefined; + const method = input.method === undefined ? "PUT" : input.method; + if (method !== "PUT") throw new Error("Only presigned PUT uploads are currently supported."); + const headers: Record = {}; + if (input.headers !== undefined) { + if (!input.headers || typeof input.headers !== "object" || Array.isArray(input.headers)) { + throw new Error("Upload headers must be an object."); + } + for (const [key, headerValue] of Object.entries(input.headers)) { + if (typeof headerValue !== "string") + throw new Error("Upload header values must be strings."); + if ( + [ + "authorization", + "cookie", + "host", + "content-length", + "proxy-authorization", + ].includes(key.toLowerCase()) + ) { + throw new Error(`The upload header ${key} is not allowed.`); + } + headers[key] = headerValue; + } + } + const parsedUploadUrl = parseHttpUrl(uploadUrl, "uploadUrl"); + const parsedShareUrl = parseHttpUrl(shareUrl, "shareUrl"); + let finalizeUrl: string | undefined; + if (typeof input.finalizeUrl === "string") { + finalizeUrl = parseHttpUrl(input.finalizeUrl, "finalizeUrl"); + } else if (typeof input.shareCode === "string" && /^[a-z0-9]+$/.test(input.shareCode)) { + // Voom marks a recording ready after metadata is posted. + finalizeUrl = new URL(`/api/metadata/${input.shareCode}`, parsedShareUrl).toString(); + } + return { + uploadUrl: parsedUploadUrl, + shareUrl: parsedShareUrl, + shareCode, + method, + headers, + finalizeUrl, + }; +} diff --git a/electron/ipc/register/cloudShare.test.ts b/electron/ipc/register/cloudShare.test.ts new file mode 100644 index 00000000..bfaa1198 --- /dev/null +++ b/electron/ipc/register/cloudShare.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { normalizeCloudEndpoint, parseCloudShareTicket } from "../cloudShareContract"; + +describe("cloud share contract", () => { + it("accepts only Recordly production and local development endpoints", () => { + expect(normalizeCloudEndpoint("https://videos.recordly.dev/api/upload#ignored")).toBe( + "https://videos.recordly.dev/api/upload", + ); + expect(normalizeCloudEndpoint("http://localhost:8787/api/upload")).toBe( + "http://localhost:8787/api/upload", + ); + }); + + it("normalizes a Voom upload ticket and derives its metadata endpoint", () => { + expect( + parseCloudShareTicket({ + shareCode: "abc123", + uploadURL: "http://localhost:8787/api/upload-data/abc123", + shareURL: "http://localhost:8787/s/abc123", + }), + ).toEqual({ + uploadUrl: "http://localhost:8787/api/upload-data/abc123", + shareUrl: "http://localhost:8787/s/abc123", + shareCode: "abc123", + method: "PUT", + headers: {}, + finalizeUrl: "http://localhost:8787/api/metadata/abc123", + }); + }); + + it("rejects insecure and untrusted remote endpoints", () => { + expect(() => normalizeCloudEndpoint("http://share.example.com/api/shares")).toThrow( + "only allowed through the Recordly service", + ); + expect(() => normalizeCloudEndpoint("https://share.example.com/api/upload")).toThrow( + "only allowed through the Recordly service", + ); + expect(() => normalizeCloudEndpoint("https://videos.recordly.dev/api/other")).toThrow( + "endpoint is invalid", + ); + }); + + it("normalizes a presigned PUT ticket", () => { + expect( + parseCloudShareTicket({ + uploadUrl: "https://storage.example.com/upload", + shareUrl: "https://share.example.com/s/123", + headers: { "x-test": "value" }, + }), + ).toEqual({ + uploadUrl: "https://storage.example.com/upload", + shareUrl: "https://share.example.com/s/123", + shareCode: undefined, + method: "PUT", + headers: { "x-test": "value" }, + finalizeUrl: undefined, + }); + }); + + it("rejects unsupported methods and malformed headers", () => { + expect(() => + parseCloudShareTicket({ + uploadUrl: "https://storage.example.com/upload", + shareUrl: "https://share.example.com/s/123", + method: "POST", + }), + ).toThrow("presigned PUT"); + expect(() => + parseCloudShareTicket({ + uploadUrl: "https://storage.example.com/upload", + shareUrl: "https://share.example.com/s/123", + headers: { invalid: 42 }, + }), + ).toThrow("must be strings"); + expect(() => + parseCloudShareTicket({ + uploadUrl: "https://storage.example.com/upload", + shareUrl: "https://share.example.com/s/123", + headers: { authorization: "Bearer exfiltrate-me" }, + }), + ).toThrow("not allowed"); + }); + + it("rejects an insecure remote upload URL", () => { + expect(() => + parseCloudShareTicket({ + uploadUrl: "http://storage.example.com/upload", + shareUrl: "https://share.example.com/s/123", + }), + ).toThrow("uploadUrl must use HTTPS"); + }); +}); diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts new file mode 100644 index 00000000..e23fab3b --- /dev/null +++ b/electron/ipc/register/cloudShare.ts @@ -0,0 +1,433 @@ +import { createReadStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Transform } from "node:stream"; +import { ipcMain } from "electron"; +import { normalizeCloudEndpoint, parseCloudShareTicket } from "../cloudShareContract"; +import { isOwnedExportPath } from "../export/exportStream"; +import { isAllowedLocalReadPath } from "../project/manager"; + +const MAX_CONTROL_RESPONSE_BYTES = 1024 * 1024; +const MAX_AUTH_TOKEN_BYTES = 8192; +const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; +const MULTIPART_PART_BYTES = 25 * 1024 * 1024; +const MULTIPART_PART_ATTEMPTS = 3; +const activeUploads = new Map(); + +type UploadedPart = { partNumber: number; etag: string }; + +class NonRetryableUploadError extends Error {} + +function contentTypeFor(filePath: string) { + switch (path.extname(filePath).toLowerCase()) { + case ".mp4": + return "video/mp4"; + case ".webm": + return "video/webm"; + case ".mov": + return "video/quicktime"; + case ".gif": + return "image/gif"; + default: + return "application/octet-stream"; + } +} + +async function readJsonResponse(response: Response) { + const text = await response.text(); + if (Buffer.byteLength(text) > MAX_CONTROL_RESPONSE_BYTES) { + throw new Error("The cloud share server response was too large."); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error(`The cloud share server returned invalid JSON (${response.status}).`); + } +} + +async function responseError(response: Response, fallback: string) { + try { + const body = await readJsonResponse(response); + if (body && typeof body === "object" && "error" in body) { + return String((body as { error: unknown }).error); + } + } catch { + // Cloudflare may return an HTML/plain-text edge error instead of Worker JSON. + } + return `${fallback} (${response.status}).`; +} + +function waitForRetry(delayMs: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason ?? new Error("Upload aborted")); + return; + } + const handleAbort = () => { + clearTimeout(timeout); + reject(signal.reason ?? new Error("Upload aborted")); + }; + const timeout = setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(); + }, delayMs); + signal.addEventListener("abort", handleAbort, { once: true }); + }); +} + +function isRetryableUploadStatus(status: number) { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +async function uploadMultipart(options: { + endpoint: string; + shareCode: string; + filePath: string; + fileSize: number; + contentType: string; + token: string; + signal: AbortSignal; + onProgress: (uploadedBytes: number) => void; +}) { + const endpointOrigin = new URL(options.endpoint).origin; + const authHeaders = { authorization: `Bearer ${options.token}` }; + const startResponse = await fetch( + new URL(`/api/upload-multipart/${options.shareCode}`, endpointOrigin), + { + method: "POST", + headers: authHeaders, + redirect: "error", + signal: options.signal, + }, + ); + if (!startResponse.ok) { + throw new Error(await responseError(startResponse, "Could not start video upload")); + } + const startBody = await readJsonResponse(startResponse); + const multipartUploadId = + startBody && + typeof startBody === "object" && + "uploadId" in startBody && + typeof (startBody as { uploadId: unknown }).uploadId === "string" + ? (startBody as { uploadId: string }).uploadId + : ""; + if (!multipartUploadId || Buffer.byteLength(multipartUploadId) > 2048) { + throw new Error("The cloud share server returned an invalid multipart upload ID."); + } + + const encodedUploadId = encodeURIComponent(multipartUploadId); + const parts: UploadedPart[] = []; + let confirmedBytes = 0; + let completed = false; + + try { + const partCount = Math.ceil(options.fileSize / MULTIPART_PART_BYTES); + for (let partIndex = 0; partIndex < partCount; partIndex += 1) { + const partNumber = partIndex + 1; + const start = partIndex * MULTIPART_PART_BYTES; + const end = Math.min(options.fileSize, start + MULTIPART_PART_BYTES) - 1; + const partSize = end - start + 1; + let uploadedPart: UploadedPart | undefined; + + for (let attempt = 1; attempt <= MULTIPART_PART_ATTEMPTS; attempt += 1) { + let attemptBytes = 0; + const progress = new Transform({ + transform(chunk, _encoding, callback) { + attemptBytes += chunk.length; + options.onProgress( + Math.min(options.fileSize, confirmedBytes + attemptBytes), + ); + callback(null, chunk); + }, + }); + const body = createReadStream(options.filePath, { start, end }).pipe(progress); + try { + const partResponse = await fetch( + new URL( + `/api/upload-part/${options.shareCode}/${encodedUploadId}/${partNumber}`, + endpointOrigin, + ), + { + method: "PUT", + headers: { + ...authHeaders, + "content-type": options.contentType, + "content-length": String(partSize), + }, + body: body as unknown as BodyInit, + duplex: "half", + redirect: "error", + signal: options.signal, + } as RequestInit & { duplex: "half" }, + ); + if (!partResponse.ok) { + const message = await responseError( + partResponse, + `Video part ${partNumber} failed`, + ); + if (!isRetryableUploadStatus(partResponse.status)) { + throw new NonRetryableUploadError(message); + } + throw new Error(message); + } else { + const partBody = await readJsonResponse(partResponse); + const etag = + partBody && typeof partBody === "object" && "etag" in partBody + ? (partBody as { etag: unknown }).etag + : undefined; + const returnedPartNumber = + partBody && typeof partBody === "object" && "partNumber" in partBody + ? (partBody as { partNumber: unknown }).partNumber + : undefined; + if (typeof etag !== "string" || returnedPartNumber !== partNumber) { + throw new NonRetryableUploadError( + `The cloud share server returned invalid data for video part ${partNumber}.`, + ); + } + uploadedPart = { partNumber, etag }; + break; + } + } catch (error) { + if ( + options.signal.aborted || + error instanceof NonRetryableUploadError || + attempt === MULTIPART_PART_ATTEMPTS + ) { + throw error; + } + } finally { + body.destroy(); + } + + options.onProgress(confirmedBytes); + await waitForRetry(300 * 3 ** (attempt - 1), options.signal); + } + + if (!uploadedPart) throw new Error(`Video part ${partNumber} could not be uploaded.`); + parts.push(uploadedPart); + confirmedBytes += partSize; + options.onProgress(confirmedBytes); + } + + const completeResponse = await fetch( + new URL(`/api/upload-complete/${options.shareCode}/${encodedUploadId}`, endpointOrigin), + { + method: "POST", + headers: { ...authHeaders, "content-type": "application/json" }, + body: JSON.stringify({ parts }), + redirect: "error", + signal: options.signal, + }, + ); + if (!completeResponse.ok) { + throw new Error(await responseError(completeResponse, "Could not finish video upload")); + } + completed = true; + } finally { + if (!completed) { + try { + await fetch( + new URL( + `/api/upload-abort/${options.shareCode}/${encodedUploadId}`, + endpointOrigin, + ), + { + method: "POST", + headers: authHeaders, + redirect: "error", + signal: AbortSignal.timeout(5000), + }, + ); + } catch { + // R2 also reaps abandoned multipart uploads, so cleanup is best-effort. + } + } + } +} + +export function registerCloudShareHandlers() { + ipcMain.handle( + "cloud-share-upload", + async ( + event, + input: { + filePath?: unknown; + endpoint?: unknown; + token?: unknown; + title?: unknown; + notes?: unknown; + uploadId?: unknown; + }, + ) => { + let uploadId: string | undefined; + try { + const endpoint = normalizeCloudEndpoint(input?.endpoint); + if (typeof input?.filePath !== "string" || input.filePath.trim().length === 0) { + throw new Error("Export a video before sharing it."); + } + uploadId = + typeof input.uploadId === "string" && /^[a-f0-9-]{36}$/i.test(input.uploadId) + ? input.uploadId + : crypto.randomUUID(); + const controller = new AbortController(); + activeUploads.set(uploadId, controller); + const requestedPath = path.resolve(input.filePath); + const resolvedPath = await fs.realpath(requestedPath); + const isRecordlyExport = + isOwnedExportPath(requestedPath) || isOwnedExportPath(resolvedPath); + if (!isRecordlyExport && !isAllowedLocalReadPath(resolvedPath)) { + throw new Error("This file is outside Recordly's approved media locations."); + } + const stat = await fs.stat(resolvedPath); + if (!stat.isFile()) throw new Error("The exported video could not be found."); + const title = + (typeof input.title === "string" ? input.title.trim().slice(0, 200) : "") || + path.basename(resolvedPath, path.extname(resolvedPath)); + const notes = + typeof input.notes === "string" ? input.notes.trim().slice(0, 2000) : ""; + + const token = typeof input.token === "string" ? input.token.trim() : ""; + if (!token || Buffer.byteLength(token) > MAX_AUTH_TOKEN_BYTES) { + throw new Error("Sign in to Recordly before creating a shared link."); + } + const controlHeaders: Record = { + "content-type": "application/json", + }; + if (token) controlHeaders.authorization = `Bearer ${token}`; + const ticketResponse = await fetch(endpoint, { + method: "POST", + headers: controlHeaders, + body: JSON.stringify({ + name: path.basename(resolvedPath), + title, + fileSize: stat.size, + notes, + size: stat.size, + contentType: contentTypeFor(resolvedPath), + }), + redirect: "error", + signal: controller.signal, + }); + const ticketBody = await readJsonResponse(ticketResponse); + if (!ticketResponse.ok) { + const message = + ticketBody && typeof ticketBody === "object" && "error" in ticketBody + ? String((ticketBody as { error: unknown }).error) + : `Cloud share request failed (${ticketResponse.status}).`; + throw new Error(message); + } + const ticket = parseCloudShareTicket(ticketBody); + const endpointOrigin = new URL(endpoint).origin; + if (new URL(ticket.shareUrl).origin !== endpointOrigin) { + throw new Error("The share service returned an untrusted viewing URL."); + } + if (ticket.finalizeUrl && new URL(ticket.finalizeUrl).origin !== endpointOrigin) { + throw new Error("The share service returned an untrusted finalization URL."); + } + const contentType = contentTypeFor(resolvedPath); + const sendProgress = (uploadedBytes: number) => { + event.sender.send("cloud-share-progress", { + uploadId, + uploadedBytes, + totalBytes: stat.size, + }); + }; + const canUseMultipart = + stat.size > MULTIPART_THRESHOLD_BYTES && + ticket.shareCode !== undefined && + new URL(ticket.uploadUrl).origin === endpointOrigin; + + if (canUseMultipart) { + await uploadMultipart({ + endpoint, + shareCode: ticket.shareCode as string, + filePath: resolvedPath, + fileSize: stat.size, + contentType, + token, + signal: controller.signal, + onProgress: sendProgress, + }); + } else { + let uploaded = 0; + const progress = new Transform({ + transform(chunk, _encoding, callback) { + uploaded += chunk.length; + sendProgress(uploaded); + callback(null, chunk); + }, + }); + const body = createReadStream(resolvedPath).pipe(progress); + const uploadHeaders: Record = { + "content-type": contentType, + "content-length": String(stat.size), + ...ticket.headers, + }; + if (new URL(ticket.uploadUrl).origin === endpointOrigin) { + uploadHeaders.authorization = `Bearer ${token}`; + } + try { + const uploadResponse = await fetch(ticket.uploadUrl, { + method: "PUT", + headers: uploadHeaders, + body: body as unknown as BodyInit, + duplex: "half", + redirect: "error", + signal: controller.signal, + } as RequestInit & { duplex: "half" }); + if (!uploadResponse.ok) { + throw new Error( + await responseError(uploadResponse, "Video upload failed"), + ); + } + } finally { + body.destroy(); + } + } + + if (ticket.finalizeUrl) { + const finalizeHeaders: Record = { + "content-type": "application/json", + }; + if (token && new URL(ticket.finalizeUrl).origin === new URL(endpoint).origin) { + finalizeHeaders.authorization = `Bearer ${token}`; + } + const finalizeResponse = await fetch(ticket.finalizeUrl, { + method: "POST", + headers: finalizeHeaders, + body: JSON.stringify({ + title, + summary: notes, + }), + redirect: "error", + signal: controller.signal, + }); + if (!finalizeResponse.ok) { + throw new Error( + `Cloud share finalization failed (${finalizeResponse.status}).`, + ); + } + } + return { success: true, uploadId, shareUrl: ticket.shareUrl }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + uploadId, + canceled: message.includes("aborted"), + error: message, + }; + } finally { + if (uploadId) activeUploads.delete(uploadId); + } + }, + ); + + ipcMain.handle("cloud-share-cancel", (_event, uploadId: unknown) => { + if (typeof uploadId !== "string") return { success: false }; + const controller = activeUploads.get(uploadId); + if (!controller) return { success: false }; + controller.abort(); + return { success: true }; + }); +} diff --git a/package-lock.json b/package-lock.json index 1053d5df..6dac10a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "@supabase/supabase-js": "^2.116.0", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", @@ -44,6 +45,7 @@ "pixi.js": "^8.14.0", "postcss": "^8.5.6", "react": "^19.3.0", + "react-aria": "3.52.1", "react-dom": "^19.3.0", "react-icons": "^5.5.0", "react-resizable-panels": "^3.0.6", @@ -2436,6 +2438,98 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@supabase/auth-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.116.0.tgz", + "integrity": "sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.116.0.tgz", + "integrity": "sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.116.0.tgz", + "integrity": "sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.116.0.tgz", + "integrity": "sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.116.0.tgz", + "integrity": "sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.116.0.tgz", + "integrity": "sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.116.0", + "@supabase/functions-js": "2.116.0", + "@supabase/postgrest-js": "2.116.0", + "@supabase/realtime-js": "2.116.0", + "@supabase/storage-js": "2.116.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -5590,6 +5684,15 @@ "node": ">=10.17.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -7988,7 +8091,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tw-animate-css": { diff --git a/package.json b/package.json index 13663ca5..e0a6c11b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "@supabase/supabase-js": "^2.116.0", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", @@ -84,6 +85,7 @@ "pixi.js": "^8.14.0", "postcss": "^8.5.6", "react": "^19.3.0", + "react-aria": "3.52.1", "react-dom": "^19.3.0", "react-icons": "^5.5.0", "react-resizable-panels": "^3.0.6", diff --git a/src/components/auth/RecordlySignInDialog.tsx b/src/components/auth/RecordlySignInDialog.tsx new file mode 100644 index 00000000..fbe3d12e --- /dev/null +++ b/src/components/auth/RecordlySignInDialog.tsx @@ -0,0 +1,244 @@ +import { GoogleLogo, SignOut, XLogo } from "@phosphor-icons/react"; +import type { User } from "@supabase/supabase-js"; +import { type FormEvent, useEffect, useState } from "react"; +import { + Modal, + Button, + Form, + TextField, + Input, + Label, + Description, + FieldError, + Separator, + Alert, +} from "@heroui/react"; +import { + sendPasswordReset, + signInWithEmail, + signInWithSocial, + signOutRecordly, +} from "@/lib/auth/recordlyAuth"; + +export type SignInReason = "account" | "share"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + reason?: SignInReason; + user: User | null; + configured: boolean; + callbackError?: string; + onAuthenticated: () => void; +}; + +function friendlyAuthError(error: unknown, action: string): string { + const message = error instanceof Error ? error.message : String(error); + if (/unsupported provider|provider is not enabled/i.test(message)) { + if (action === "google") { + return "Google sign-in isn't enabled yet. Use email for now, or ask your Recordly administrator to connect Google."; + } + if (action === "x") { + return "X sign-in isn't enabled yet. Use email for now, or ask your Recordly administrator to connect X."; + } + return "This sign-in method isn't enabled for Recordly yet. Use email for now."; + } + return message; +} + +export function RecordlySignInDialog({ + open, + onOpenChange, + reason = "account", + user, + configured, + callbackError, + onAuthenticated, +}: Props) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(); + const [message, setMessage] = useState(); + + useEffect(() => { + if (!open) { + setPassword(""); + setBusy(undefined); + setMessage(undefined); + } + }, [open]); + + useEffect(() => { + if (open && user && reason === "share") onAuthenticated(); + }, [onAuthenticated, open, reason, user]); + + const run = async (label: string, action: () => Promise) => { + setBusy(label); + setMessage(undefined); + try { + await action(); + } catch (error) { + setMessage(friendlyAuthError(error, label)); + } finally { + setBusy(undefined); + } + }; + + const submitEmail = (event: FormEvent) => { + event.preventDefault(); + if (!configured || busy) return; + void run("email", async () => { + await signInWithEmail(email.trim(), password); + onAuthenticated(); + }); + }; + + const forgotPassword = () => { + if (!email.trim()) { + setMessage("Enter your email address first."); + return; + } + void run("reset", async () => { + await sendPasswordReset(email.trim()); + setMessage("Password reset email sent."); + }); + }; + + const disabled = !configured || Boolean(busy); + return ( + + + + + + + + {user ? "Your Recordly account" : "Sign into Recordly"} + + + {user + ? user.email + : reason === "share" + ? "Sign in to publish this video and manage its shared link." + : "Access your recordings and shared links."} + + + + {user ? ( + + ) : ( + <> +
+ + +
+
+ + or + +
+
+ + + + + + + + + + + + +
+ + )} + {message || callbackError ? ( + + + + + {message || callbackError} + + + + ) : null} +
+ {!configured && !user && ( + + + Cloud sign-in isn’t available in this build yet. You can still + save videos to your computer. + + + )} +
+
+
+
+ ); +} diff --git a/src/components/auth/useRecordlyAuth.ts b/src/components/auth/useRecordlyAuth.ts new file mode 100644 index 00000000..8a87ebea --- /dev/null +++ b/src/components/auth/useRecordlyAuth.ts @@ -0,0 +1,65 @@ +import type { User } from "@supabase/supabase-js"; +import { useEffect, useState } from "react"; +import { + completeAuthCallback, + recordlyAuth, + recordlyAuthConfigured, +} from "@/lib/auth/recordlyAuth"; + +export function useRecordlyAuth() { + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(); + const [loading, setLoading] = useState(recordlyAuthConfigured); + const [callbackError, setCallbackError] = useState(); + + useEffect(() => { + if (!recordlyAuth) { + setLoading(false); + return; + } + + let mounted = true; + void recordlyAuth.auth + .getSession() + .then(({ data }) => { + if (mounted) { + setUser(data.session?.user ?? null); + setAccessToken(data.session?.access_token); + setLoading(false); + } + }) + .catch((error) => { + if (mounted) { + setCallbackError(error instanceof Error ? error.message : String(error)); + setLoading(false); + } + }); + const { data: listener } = recordlyAuth.auth.onAuthStateChange((_event, session) => { + if (mounted) { + setUser(session?.user ?? null); + setAccessToken(session?.access_token); + } + }); + + const handleCallback = async (url: string) => { + try { + setCallbackError(undefined); + await completeAuthCallback(url); + } catch (error) { + setCallbackError(error instanceof Error ? error.message : String(error)); + } + }; + const unsubscribe = window.electronAPI.onAuthCallbackUrl((url) => void handleCallback(url)); + void window.electronAPI.getPendingAuthCallbackUrl().then((url) => { + if (url) void handleCallback(url); + }); + + return () => { + mounted = false; + listener.subscription.unsubscribe(); + unsubscribe(); + }; + }, []); + + return { user, accessToken, loading, configured: recordlyAuthConfigured, callbackError }; +} diff --git a/src/lib/auth/recordlyAuth.ts b/src/lib/auth/recordlyAuth.ts new file mode 100644 index 00000000..41d5c153 --- /dev/null +++ b/src/lib/auth/recordlyAuth.ts @@ -0,0 +1,83 @@ +import { createClient, type Provider, type User } from "@supabase/supabase-js"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim(); +const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim(); +const callbackUrl = import.meta.env.DEV + ? "http://127.0.0.1:43821/auth/callback" + : "recordly://auth/callback"; + +export const recordlyAuthConfigured = Boolean(supabaseUrl && supabasePublishableKey); + +export const recordlyAuth = recordlyAuthConfigured + ? createClient(supabaseUrl!, supabasePublishableKey!, { + auth: { + flowType: "pkce", + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: false, + }, + }) + : null; + +function requireAuth() { + if (!recordlyAuth) { + throw new Error("Recordly Auth is not configured. Add the Supabase URL and publishable key."); + } + return recordlyAuth; +} + +export async function signInWithEmail(email: string, password: string): Promise { + const client = requireAuth(); + const { data, error } = await client.auth.signInWithPassword({ email, password }); + if (error) throw error; + if (!data.user) throw new Error("No user was returned after sign-in."); + return data.user; +} + +export async function sendPasswordReset(email: string): Promise { + const client = requireAuth(); + const { error } = await client.auth.resetPasswordForEmail(email, { redirectTo: callbackUrl }); + if (error) throw error; +} + +async function openAuthUrl(url: string | null) { + if (!url) throw new Error("The authentication provider did not return a sign-in URL."); + const result = await window.electronAPI.openExternalUrl(url); + if (!result.success) throw new Error(result.error || "Could not open the sign-in page."); +} + +export async function signInWithSocial(provider: "google" | "twitter"): Promise { + const client = requireAuth(); + const { data, error } = await client.auth.signInWithOAuth({ + provider: provider as Provider, + options: { redirectTo: callbackUrl, skipBrowserRedirect: true }, + }); + if (error) throw error; + await openAuthUrl(data.url); +} + +export async function signInWithSaml(email: string): Promise { + const client = requireAuth(); + const domain = email.trim().split("@")[1]; + if (!domain) throw new Error("Enter a valid work email address."); + const { data, error } = await client.auth.signInWithSSO({ + domain, + options: { redirectTo: callbackUrl, skipBrowserRedirect: true }, + }); + if (error) throw error; + await openAuthUrl(data.url); +} + +export async function completeAuthCallback(url: string): Promise { + const code = new URL(url).searchParams.get("code"); + if (!code) throw new Error("The sign-in callback did not include an authorization code."); + const client = requireAuth(); + const { error } = await client.auth.exchangeCodeForSession(code); + if (error) throw error; +} + +export async function signOutRecordly(): Promise { + const client = requireAuth(); + const { error } = await client.auth.signOut(); + if (error) throw error; +}