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, }; }