feat(auth): add desktop sign-in and cloud upload foundations

This commit is contained in:
webadderall
2026-09-20 18:52:26 +10:00
parent 2b81e4c0c5
commit fd919fabfc
12 changed files with 1304 additions and 1 deletions
+3
View File
@@ -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
+52
View File
@@ -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.
+6
View File
@@ -11,6 +11,12 @@
"electron/native/bin/**"
],
"productName": "Recordly",
"protocols": [
{
"name": "Recordly authentication",
"schemes": ["recordly"]
}
],
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "normal",
+121
View File
@@ -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(
'<!doctype html><meta charset="utf-8"><title>Signed into Recordly</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#08090a;color:#ededef;font:16px "Helvetica Neue",Helvetica,Arial,sans-serif}.card{max-width:420px;padding:32px;text-align:center}h1{font-size:22px;font-weight:500}p{color:#8b8b8e;line-height:1.6}</style><main class="card"><h1>Signed into Recordly</h1><p>You can close this tab and return to the app.</p></main>',
);
});
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 };
}
+100
View File
@@ -0,0 +1,100 @@
export type CloudShareTicket = {
uploadUrl: string;
shareUrl: string;
shareCode?: string;
method: "PUT";
headers: Record<string, string>;
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<string, unknown>;
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<string, string> = {};
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,
};
}
+92
View File
@@ -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");
});
});
+433
View File
@@ -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<string, AbortController>();
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<void>((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<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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 };
});
}
+103 -1
View File
@@ -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": {
+2
View File
@@ -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",
@@ -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<string>();
const [message, setMessage] = useState<string>();
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<unknown>) => {
setBusy(label);
setMessage(undefined);
try {
await action();
} catch (error) {
setMessage(friendlyAuthError(error, label));
} finally {
setBusy(undefined);
}
};
const submitEmail = (event: FormEvent<HTMLFormElement>) => {
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 (
<Modal isOpen={open} onOpenChange={onOpenChange}>
<Modal.Backdrop>
<Modal.Container size="sm" placement="center">
<Modal.Dialog>
<Modal.CloseTrigger aria-label="Close" />
<Modal.Header>
<Modal.Heading>
{user ? "Your Recordly account" : "Sign into Recordly"}
</Modal.Heading>
<Description>
{user
? user.email
: reason === "share"
? "Sign in to publish this video and manage its shared link."
: "Access your recordings and shared links."}
</Description>
</Modal.Header>
<Modal.Body className="flex flex-col gap-4">
{user ? (
<Button
variant="secondary"
className="w-full"
isDisabled={Boolean(busy)}
onPress={() => void run("signout", signOutRecordly)}
>
<SignOut className="size-4" />
{busy === "signout" ? "Signing out…" : "Sign out"}
</Button>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<Button
variant="secondary"
className="w-full"
isDisabled={disabled}
onPress={() =>
void run("google", () => signInWithSocial("google"))
}
>
<GoogleLogo className="size-4" />
Google
</Button>
<Button
variant="secondary"
className="w-full"
isDisabled={disabled}
onPress={() =>
void run("x", () => signInWithSocial("twitter"))
}
>
<XLogo className="size-4" />X
</Button>
</div>
<div className="my-1 flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-xs text-muted">or</span>
<Separator className="flex-1" />
</div>
<Form className="flex flex-col gap-4" onSubmit={submitEmail}>
<TextField
name="email"
type="email"
value={email}
onChange={setEmail}
isRequired
isDisabled={Boolean(busy)}
>
<Label>Email</Label>
<Input
placeholder="you@example.com"
autoComplete="email"
/>
<FieldError />
</TextField>
<TextField
name="password"
type="password"
value={password}
onChange={setPassword}
isRequired
isDisabled={Boolean(busy)}
>
<Label>Password</Label>
<Input autoComplete="current-password" />
<FieldError />
</TextField>
<Button
variant="ghost"
size="sm"
className="-mt-2 self-end"
isDisabled={disabled}
onPress={forgotPassword}
>
Forgot password?
</Button>
<Button
type="submit"
className="w-full"
isDisabled={disabled}
>
{busy === "email" ? "Signing in…" : "Sign in"}
</Button>
</Form>
</>
)}
{message || callbackError ? (
<Alert
status={
message === "Password reset email sent."
? "success"
: "danger"
}
>
<Alert.Indicator />
<Alert.Content>
<Alert.Description>
{message || callbackError}
</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Modal.Body>
{!configured && !user && (
<Modal.Footer>
<Description role="status" className="min-w-0 flex-1">
Cloud sign-in isnt available in this build yet. You can still
save videos to your computer.
</Description>
</Modal.Footer>
)}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}
+65
View File
@@ -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<User | null>(null);
const [accessToken, setAccessToken] = useState<string>();
const [loading, setLoading] = useState(recordlyAuthConfigured);
const [callbackError, setCallbackError] = useState<string>();
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 };
}
+83
View File
@@ -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<User> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const client = requireAuth();
const { error } = await client.auth.signOut();
if (error) throw error;
}