1133 lines
38 KiB
TypeScript
1133 lines
38 KiB
TypeScript
import { AutoRouter, cors, error, IRequest } from "itty-router"
|
|
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
|
|
import { Environment } from "./types"
|
|
import {
|
|
searchUsers,
|
|
getUserProfile,
|
|
updateMyProfile,
|
|
createConnection,
|
|
updateConnectionTrust,
|
|
removeConnection,
|
|
getMyConnections,
|
|
getMyFollowers,
|
|
checkConnection,
|
|
updateEdgeMetadata,
|
|
getEdgeMetadata,
|
|
getNetworkGraph,
|
|
getRoomNetworkGraph,
|
|
getMutualConnections,
|
|
} from "./networkingApi"
|
|
import {
|
|
handleGetPermission,
|
|
handleListPermissions,
|
|
handleGrantPermission,
|
|
handleRevokePermission,
|
|
handleUpdateBoard,
|
|
handleCreateAccessToken,
|
|
handleListAccessTokens,
|
|
handleRevokeAccessToken,
|
|
} from "./boardPermissions"
|
|
import {
|
|
handleSendBackupEmail,
|
|
handleSearchUsers,
|
|
handleListAllUsers,
|
|
handleCheckUsername,
|
|
} from "./cryptidAuth"
|
|
|
|
// make sure our sync durable objects are made available to cloudflare
|
|
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
|
|
|
// Temporary stub for TldrawDurableObject to allow delete-class migration
|
|
// This extends AutomergeDurableObject so existing instances can be handled during migration
|
|
//
|
|
// DATA SAFETY: All document data is stored in R2 at `rooms/${roomId}`, not in Durable Object storage.
|
|
// When TldrawDurableObject instances are deleted, only the Durable Object instances are removed.
|
|
// The R2 data remains safe and accessible by AutomergeDurableObject, which uses the same R2 bucket
|
|
// (TLDRAW_BUCKET) and storage path. The roomId can be re-initialized from the URL path if needed.
|
|
//
|
|
// This will be removed after the migration completes
|
|
import { AutomergeDurableObject as BaseAutomergeDurableObject } from "./AutomergeDurableObject"
|
|
|
|
export class TldrawDurableObject extends BaseAutomergeDurableObject {
|
|
constructor(ctx: DurableObjectState, env: Environment) {
|
|
// Extends AutomergeDurableObject, so it uses the same R2 bucket (env.TLDRAW_BUCKET)
|
|
// and storage path (rooms/${roomId}), ensuring no data loss during migration
|
|
super(ctx, env)
|
|
}
|
|
}
|
|
|
|
// Lazy load heavy dependencies to avoid startup timeouts
|
|
let handleUnfurlRequest: any = null
|
|
|
|
async function getUnfurlHandler() {
|
|
if (!handleUnfurlRequest) {
|
|
const unfurl = await import("cloudflare-workers-unfurl")
|
|
handleUnfurlRequest = unfurl.handleUnfurlRequest
|
|
}
|
|
return handleUnfurlRequest
|
|
}
|
|
|
|
// Define security headers
|
|
const securityHeaders = {
|
|
"Content-Security-Policy":
|
|
"default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https: https://cdn.tldraw.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"X-XSS-Protection": "1; mode=block",
|
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
}
|
|
|
|
// we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because
|
|
// we're hosting the worker separately to the client. you should restrict this to your own domain.
|
|
const { preflight, corsify } = cors({
|
|
origin: (origin) => {
|
|
const allowedOrigins = [
|
|
"https://jeffemmett.com",
|
|
"https://www.jeffemmett.com",
|
|
"https://jeffemmett-canvas.jeffemmett.workers.dev",
|
|
"https://jeffemmett.com/board/*",
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:5173",
|
|
"http://172.22.168.84:5173",
|
|
]
|
|
|
|
// Always allow if no origin (like from a local file)
|
|
if (!origin) return "*"
|
|
|
|
// Check exact matches
|
|
if (allowedOrigins.includes(origin)) {
|
|
return origin
|
|
}
|
|
|
|
// For development - check if it's a localhost or local IP (both http and https)
|
|
if (
|
|
origin.match(
|
|
/^https?:\/\/(localhost|127\.0\.0\.1|192\.168\.|169\.254\.|10\.)/,
|
|
)
|
|
) {
|
|
return origin
|
|
}
|
|
|
|
// If no match found, return * to allow all origins
|
|
return "*"
|
|
},
|
|
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "UPGRADE"],
|
|
allowHeaders: [
|
|
"Content-Type",
|
|
"Authorization",
|
|
"Upgrade",
|
|
"Connection",
|
|
"Sec-WebSocket-Key",
|
|
"Sec-WebSocket-Version",
|
|
"Sec-WebSocket-Extensions",
|
|
"Sec-WebSocket-Protocol",
|
|
"Content-Length",
|
|
"Content-Range",
|
|
"Range",
|
|
"If-None-Match",
|
|
"If-Modified-Since",
|
|
"X-CryptID-PublicKey", // CryptID authentication header
|
|
"X-User-Id", // User ID header for networking API
|
|
"X-Api-Key", // API key header for external services
|
|
"X-Admin-Secret", // Admin secret header for protected endpoints
|
|
"*"
|
|
],
|
|
maxAge: 86400,
|
|
credentials: true,
|
|
})
|
|
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|
before: [
|
|
// Handle WebSocket upgrades before CORS processing
|
|
(request) => {
|
|
const upgradeHeader = request.headers.get("Upgrade")
|
|
if (upgradeHeader === "websocket") {
|
|
// WebSocket upgrade detected, bypassing CORS
|
|
return // Don't process CORS for WebSocket upgrades
|
|
}
|
|
return preflight(request)
|
|
}
|
|
],
|
|
finally: [
|
|
(response) => {
|
|
// Add security headers to all responses except WebSocket upgrades
|
|
if (response.status !== 101) {
|
|
// Create new headers to avoid modifying immutable headers
|
|
const newHeaders = new Headers(response.headers)
|
|
Object.entries(securityHeaders).forEach(([key, value]) => {
|
|
newHeaders.set(key, value)
|
|
})
|
|
|
|
// Create a new response with the updated headers
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: newHeaders
|
|
})
|
|
}
|
|
return corsify(response)
|
|
},
|
|
],
|
|
catch: (e: Error) => {
|
|
// Log all errors for debugging
|
|
console.error("Worker error:", e)
|
|
console.error("Error stack:", e.stack)
|
|
|
|
// Handle WebSocket errors more gracefully
|
|
if (e.message?.includes("WebSocket")) {
|
|
console.error("WebSocket error:", e)
|
|
return new Response(JSON.stringify({ error: "WebSocket connection failed", message: e.message }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
return error(e)
|
|
},
|
|
})
|
|
|
|
// assets can be uploaded to the bucket under /uploads:
|
|
.post("/uploads/:uploadId", handleAssetUpload)
|
|
|
|
// they can be retrieved from the bucket too:
|
|
.get("/uploads/:uploadId", handleAssetDownload)
|
|
|
|
// bookmarks need to extract metadata from pasted URLs:
|
|
.get("/unfurl", async (request, env) => {
|
|
const handler = await getUnfurlHandler()
|
|
return handler(request, env)
|
|
})
|
|
|
|
|
|
// Automerge routes
|
|
.get("/connect/:roomId", (request, env) => {
|
|
console.log(`🔌 Worker: Received request for room ${request.params.roomId}`)
|
|
|
|
// Check if this is a WebSocket upgrade request
|
|
const upgradeHeader = request.headers.get("Upgrade")
|
|
console.log(`🔌 Worker: Upgrade header: ${upgradeHeader}`)
|
|
|
|
if (upgradeHeader === "websocket") {
|
|
console.log(`🔌 Worker: Handling WebSocket upgrade for room ${request.params.roomId}`)
|
|
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
|
|
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
|
|
return room.fetch(request.url, {
|
|
headers: request.headers,
|
|
body: request.body,
|
|
method: request.method,
|
|
})
|
|
}
|
|
|
|
// Handle regular GET requests
|
|
console.log(`🔌 Worker: Handling regular GET request for room ${request.params.roomId}`)
|
|
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
|
|
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
|
|
return room.fetch(request.url, {
|
|
headers: request.headers,
|
|
body: request.body,
|
|
method: request.method,
|
|
})
|
|
})
|
|
|
|
.get("/room/:roomId", (request, env) => {
|
|
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
|
|
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
|
|
return room.fetch(request.url, {
|
|
headers: request.headers,
|
|
body: request.body,
|
|
method: request.method,
|
|
})
|
|
})
|
|
|
|
.post("/room/:roomId", async (request, env) => {
|
|
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
|
|
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
|
|
return room.fetch(request.url, {
|
|
method: "POST",
|
|
body: request.body,
|
|
})
|
|
})
|
|
|
|
// Get the Automerge document ID for a room
|
|
.get("/room/:roomId/documentId", async (request, env) => {
|
|
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
|
|
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
|
|
return room.fetch(request.url, {
|
|
headers: request.headers,
|
|
method: "GET",
|
|
})
|
|
})
|
|
|
|
// Set the Automerge document ID for a room
|
|
.post("/room/:roomId/documentId", async (request, env) => {
|
|
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
|
|
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
|
|
return room.fetch(request.url, {
|
|
method: "POST",
|
|
body: request.body,
|
|
headers: request.headers,
|
|
})
|
|
})
|
|
|
|
.post("/daily/rooms", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
// Get the request body from the client
|
|
const body = await req.json()
|
|
|
|
const response = await fetch('https://api.daily.co/v1/rooms', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`
|
|
},
|
|
body: JSON.stringify(body)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
.post("/daily/tokens", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const body = await req.json() as { room_name: string; properties: any };
|
|
const response = await fetch('https://api.daily.co/v1/meeting-tokens', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`
|
|
},
|
|
body: JSON.stringify({
|
|
room_name: body.room_name,
|
|
properties: body.properties
|
|
})
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Add new transcription endpoints
|
|
.post("/daily/rooms/:roomName/start-transcription", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
const { roomName } = req.params
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/transcription/start`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
.post("/daily/rooms/:roomName/stop-transcription", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
const { roomName } = req.params
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/transcription/stop`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Add endpoint to get transcript access link
|
|
.get("/daily/transcript/:transcriptId/access-link", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
const { transcriptId } = req.params
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`https://api.daily.co/v1/transcript/${transcriptId}/access-link`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Add endpoint to get transcript text
|
|
.get("/daily/transcript/:transcriptId", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
const { transcriptId } = req.params
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`https://api.daily.co/v1/transcripts/${transcriptId}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Recording endpoints
|
|
.post("/daily/recordings/start", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const body = await req.json() as any;
|
|
const response = await fetch('https://api.daily.co/v1/recordings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`
|
|
},
|
|
body: JSON.stringify(body)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
.post("/daily/recordings/:recordingId/stop", async (req) => {
|
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
|
const { recordingId } = req.params
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`https://api.daily.co/v1/recordings/${recordingId}/stop`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
return new Response(JSON.stringify(error), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json()
|
|
return new Response(JSON.stringify(data), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Fathom API endpoints (api.fathom.ai)
|
|
.get("/fathom/meetings", async (req) => {
|
|
console.log('Fathom meetings endpoint called')
|
|
// Support both Authorization: Bearer and X-Api-Key headers for backward compatibility
|
|
let apiKey = req.headers.get('X-Api-Key')
|
|
if (!apiKey) {
|
|
apiKey = req.headers.get('Authorization')?.split('Bearer ')[1] || null
|
|
}
|
|
console.log('API key present:', !!apiKey)
|
|
|
|
if (!apiKey) {
|
|
console.log('No API key provided')
|
|
return new Response(JSON.stringify({ error: 'No API key provided. Use X-Api-Key header or Authorization: Bearer' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
console.log('Making request to Fathom API...')
|
|
|
|
// Build query parameters from URL
|
|
const url = new URL(req.url)
|
|
const params = new URLSearchParams()
|
|
if (url.searchParams.has('cursor')) {
|
|
params.append('cursor', url.searchParams.get('cursor')!)
|
|
}
|
|
if (url.searchParams.has('include_transcript')) {
|
|
params.append('include_transcript', url.searchParams.get('include_transcript')!)
|
|
}
|
|
if (url.searchParams.has('created_after')) {
|
|
params.append('created_after', url.searchParams.get('created_after')!)
|
|
}
|
|
if (url.searchParams.has('created_before')) {
|
|
params.append('created_before', url.searchParams.get('created_before')!)
|
|
}
|
|
|
|
const queryString = params.toString()
|
|
const apiUrl = `https://api.fathom.ai/external/v1/meetings${queryString ? '?' + queryString : ''}`
|
|
|
|
const response = await fetch(apiUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Api-Key': apiKey,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
console.log('Fathom API response status:', response.status)
|
|
|
|
if (!response.ok) {
|
|
console.log('Fathom API error response')
|
|
const contentType = response.headers.get('content-type') || ''
|
|
let errorData: any
|
|
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
errorData = await response.json()
|
|
console.log('Error details:', errorData)
|
|
} catch (e) {
|
|
errorData = { error: `HTTP ${response.status}: ${response.statusText}` }
|
|
}
|
|
} else {
|
|
// Handle HTML or text error responses
|
|
const text = await response.text()
|
|
console.log('Non-JSON error response:', text.substring(0, 200))
|
|
errorData = {
|
|
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
details: text.substring(0, 500) // Include first 500 chars for debugging
|
|
}
|
|
}
|
|
|
|
return new Response(JSON.stringify(errorData), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json() as { items?: any[], limit?: number, next_cursor?: string }
|
|
console.log('Fathom API success, items length:', data?.items?.length || 0)
|
|
// Transform response to match expected format (items -> data for backward compatibility)
|
|
return new Response(JSON.stringify({
|
|
data: data.items || [],
|
|
limit: data.limit,
|
|
next_cursor: data.next_cursor
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
console.error('Fathom API error:', error)
|
|
return new Response(JSON.stringify({
|
|
error: (error as Error).message,
|
|
details: 'Failed to fetch meetings from Fathom API'
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
.get("/fathom/meetings/:meetingId", async (req) => {
|
|
// Support both Authorization: Bearer and X-Api-Key headers
|
|
let apiKey = req.headers.get('X-Api-Key')
|
|
if (!apiKey) {
|
|
apiKey = req.headers.get('Authorization')?.split('Bearer ')[1] || null
|
|
}
|
|
const { meetingId } = req.params
|
|
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: 'No API key provided. Use X-Api-Key header or Authorization: Bearer' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
// Get transcript if requested
|
|
const url = new URL(req.url)
|
|
const includeTranscript = url.searchParams.get('include_transcript') === 'true'
|
|
// Use the meetings endpoint with filters to get a specific meeting by recording_id
|
|
// The API doesn't have a direct /meetings/:id endpoint, so we filter by recording_id
|
|
// Include summary and action items parameters - these are required to get the data
|
|
const apiUrl = `https://api.fathom.ai/external/v1/meetings?recording_id=${meetingId}&include_summary=true&include_action_items=true${includeTranscript ? '&include_transcript=true' : ''}`
|
|
|
|
console.log('Fetching Fathom meeting with URL:', apiUrl)
|
|
|
|
const response = await fetch(apiUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Api-Key': apiKey,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const contentType = response.headers.get('content-type') || ''
|
|
let errorData: any
|
|
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
errorData = await response.json()
|
|
} catch (e) {
|
|
errorData = { error: `HTTP ${response.status}: ${response.statusText}` }
|
|
}
|
|
} else {
|
|
// Handle HTML or text error responses
|
|
const text = await response.text()
|
|
errorData = {
|
|
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
details: text.substring(0, 500) // Include first 500 chars for debugging
|
|
}
|
|
}
|
|
|
|
return new Response(JSON.stringify(errorData), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
const data = await response.json() as { items?: any[] }
|
|
// The API returns an array, so get the first item (should be the matching meeting)
|
|
const meeting = data.items && data.items.length > 0 ? data.items[0] : null
|
|
|
|
if (!meeting) {
|
|
return new Response(JSON.stringify({ error: 'Meeting not found' }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
// Log the meeting structure for debugging
|
|
console.log('Fathom meeting response keys:', Object.keys(meeting))
|
|
console.log('Has default_summary:', !!meeting.default_summary)
|
|
console.log('Has action_items:', !!meeting.action_items)
|
|
if (meeting.default_summary) {
|
|
console.log('default_summary keys:', Object.keys(meeting.default_summary))
|
|
}
|
|
|
|
return new Response(JSON.stringify(meeting), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
console.error('Fathom API error for meeting details:', error)
|
|
return new Response(JSON.stringify({
|
|
error: (error as Error).message,
|
|
details: 'Failed to fetch meeting details from Fathom API'
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
.post("/fathom/webhook", async (req) => {
|
|
try {
|
|
const body = await req.json()
|
|
|
|
// Log the webhook for debugging
|
|
console.log('Fathom webhook received:', JSON.stringify(body, null, 2))
|
|
|
|
// TODO: Verify webhook signature for security
|
|
// For now, we'll accept all webhooks. In production, you should:
|
|
// 1. Get the webhook secret from Fathom
|
|
// 2. Verify the signature using svix or similar library
|
|
// const signature = req.headers.get('svix-signature')
|
|
// const webhookSecret = env.FATHOM_WEBHOOK_SECRET
|
|
// if (!verifyWebhookSignature(body, signature, webhookSecret)) {
|
|
// return new Response(JSON.stringify({ error: 'Invalid signature' }), {
|
|
// status: 401,
|
|
// headers: { 'Content-Type': 'application/json' }
|
|
// })
|
|
// }
|
|
|
|
// Process the meeting data
|
|
const meetingData = body as any
|
|
|
|
// Store meeting data for later retrieval
|
|
// This could be stored in R2 or Durable Object storage
|
|
console.log('Processing meeting:', meetingData.meeting_id)
|
|
|
|
// TODO: Store meeting data in R2 or send to connected clients
|
|
// For now, just log it
|
|
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
console.error('Webhook processing error:', error)
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// =============================================================================
|
|
// Miro Import API
|
|
// =============================================================================
|
|
|
|
// Proxy endpoint for fetching external images (needed for CORS-blocked Miro images)
|
|
.get("/proxy", async (req) => {
|
|
const url = new URL(req.url)
|
|
const targetUrl = url.searchParams.get('url')
|
|
|
|
if (!targetUrl) {
|
|
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(targetUrl, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (compatible; TldrawImporter/1.0)',
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
return new Response(JSON.stringify({ error: `Failed to fetch: ${response.statusText}` }), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
// Get content type and pass through the image
|
|
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
|
const body = await response.arrayBuffer()
|
|
|
|
return new Response(body, {
|
|
headers: {
|
|
'Content-Type': contentType,
|
|
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
|
}
|
|
})
|
|
} catch (error) {
|
|
console.error('Proxy fetch error:', error)
|
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// =============================================================================
|
|
// CryptID Auth API
|
|
// =============================================================================
|
|
|
|
// Check if a username is available for registration
|
|
.get("/api/auth/check-username", handleCheckUsername)
|
|
|
|
// Send backup email for multi-device setup
|
|
.post("/api/auth/send-backup-email", handleSendBackupEmail)
|
|
|
|
// Search for CryptID users by username (for granting permissions)
|
|
.get("/api/auth/users/search", handleSearchUsers)
|
|
|
|
// List all CryptID users (admin only, requires X-Admin-Secret header)
|
|
.get("/admin/users", handleListAllUsers)
|
|
|
|
// =============================================================================
|
|
// User Networking / Social Graph API
|
|
// =============================================================================
|
|
|
|
// User search and profiles
|
|
.get("/api/networking/users/search", searchUsers)
|
|
.get("/api/networking/users/me", (req, env) => getUserProfile({ ...req, params: { userId: req.headers.get('X-User-Id') || '' } } as unknown as IRequest, env))
|
|
.put("/api/networking/users/me", updateMyProfile)
|
|
.get("/api/networking/users/:userId", getUserProfile)
|
|
|
|
// Connection management
|
|
.post("/api/networking/connections", createConnection)
|
|
.get("/api/networking/connections", getMyConnections)
|
|
.get("/api/networking/connections/followers", getMyFollowers)
|
|
.get("/api/networking/connections/check/:userId", checkConnection)
|
|
.get("/api/networking/connections/mutual/:userId", getMutualConnections)
|
|
.put("/api/networking/connections/:connectionId/trust", updateConnectionTrust)
|
|
.delete("/api/networking/connections/:connectionId", removeConnection)
|
|
|
|
// Edge metadata
|
|
.put("/api/networking/connections/:connectionId/metadata", updateEdgeMetadata)
|
|
.get("/api/networking/connections/:connectionId/metadata", getEdgeMetadata)
|
|
|
|
// Network graph
|
|
.get("/api/networking/graph", getNetworkGraph)
|
|
.post("/api/networking/graph/room", getRoomNetworkGraph)
|
|
|
|
// =============================================================================
|
|
// Board Permissions API
|
|
// =============================================================================
|
|
|
|
// Get current user's permission for a board
|
|
.get("/boards/:boardId/permission", (req, env) =>
|
|
handleGetPermission(req.params.boardId, req, env))
|
|
|
|
// List all permissions for a board (admin only)
|
|
.get("/boards/:boardId/permissions", (req, env) =>
|
|
handleListPermissions(req.params.boardId, req, env))
|
|
|
|
// Grant permission to a user (admin only)
|
|
.post("/boards/:boardId/permissions", (req, env) =>
|
|
handleGrantPermission(req.params.boardId, req, env))
|
|
|
|
// Revoke a user's permission (admin only)
|
|
.delete("/boards/:boardId/permissions/:userId", (req, env) =>
|
|
handleRevokePermission(req.params.boardId, req.params.userId, req, env))
|
|
|
|
// Update board settings (admin only)
|
|
.patch("/boards/:boardId", (req, env) =>
|
|
handleUpdateBoard(req.params.boardId, req, env))
|
|
|
|
// Access token endpoints for shareable links
|
|
// Create a new access token (admin only)
|
|
.post("/boards/:boardId/access-tokens", (req, env) =>
|
|
handleCreateAccessToken(req.params.boardId, req, env))
|
|
|
|
// List all access tokens for a board (admin only)
|
|
.get("/boards/:boardId/access-tokens", (req, env) =>
|
|
handleListAccessTokens(req.params.boardId, req, env))
|
|
|
|
// Revoke an access token (admin only)
|
|
.delete("/boards/:boardId/access-tokens/:tokenId", (req, env) =>
|
|
handleRevokeAccessToken(req.params.boardId, req.params.tokenId, req, env))
|
|
|
|
async function backupAllBoards(env: Environment) {
|
|
try {
|
|
// List all room files from TLDRAW_BUCKET
|
|
const roomsList = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/' })
|
|
|
|
const date = new Date().toISOString().split('T')[0]
|
|
|
|
// Process each room
|
|
for (const room of roomsList.objects) {
|
|
try {
|
|
// Get the room data
|
|
const roomData = await env.TLDRAW_BUCKET.get(room.key)
|
|
if (!roomData) continue
|
|
|
|
// Get the data as text since it's already stringified JSON
|
|
const jsonData = await roomData.text()
|
|
|
|
// Create backup key with date only
|
|
const backupKey = `${date}/${room.key}`
|
|
|
|
// Store in backup bucket as JSON with proper content-type
|
|
await env.BOARD_BACKUPS_BUCKET.put(backupKey, jsonData, {
|
|
httpMetadata: {
|
|
contentType: 'application/json'
|
|
}
|
|
})
|
|
|
|
// Backed up successfully
|
|
} catch (error) {
|
|
console.error(`Failed to backup room ${room.key}:`, error)
|
|
}
|
|
}
|
|
|
|
// Clean up old backups (keep last 90 days)
|
|
const ninetyDaysAgo = new Date()
|
|
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
|
|
|
const oldBackups = await env.BOARD_BACKUPS_BUCKET.list({
|
|
prefix: ninetyDaysAgo.toISOString().split('T')[0]
|
|
})
|
|
|
|
for (const backup of oldBackups.objects) {
|
|
await env.BOARD_BACKUPS_BUCKET.delete(backup.key)
|
|
}
|
|
|
|
return { success: true, message: 'Backup completed successfully' }
|
|
} catch (error) {
|
|
console.error('Backup failed:', error)
|
|
return { success: false, message: (error as Error).message }
|
|
}
|
|
}
|
|
|
|
router
|
|
.get("/export/:roomId", async (request, env) => {
|
|
try {
|
|
const roomId = request.params.roomId
|
|
if (!roomId) {
|
|
return new Response(JSON.stringify({ error: 'Room ID is required' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
// Get the room data from R2
|
|
const roomData = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
|
if (!roomData) {
|
|
return new Response(JSON.stringify({ error: 'Room not found' }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
// Get the JSON data
|
|
const jsonData = await roomData.text()
|
|
|
|
// Return as downloadable JSON file
|
|
return new Response(jsonData, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Disposition': `attachment; filename="${roomId}-board.json"`,
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': '*',
|
|
}
|
|
})
|
|
} catch (error) {
|
|
console.error('Export failed:', error)
|
|
return new Response(JSON.stringify({
|
|
error: 'Export failed',
|
|
message: (error as Error).message
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
.get("/backup", async (_, env) => {
|
|
const result = await backupAllBoards(env)
|
|
return new Response(JSON.stringify(result), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
})
|
|
.get("/backup/test", async (_, env) => {
|
|
try {
|
|
// Simple test to check R2 access
|
|
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
|
|
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
message: 'R2 access test successful',
|
|
roomCount: testResult.objects?.length || 0,
|
|
hasMore: testResult.truncated || false
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
console.error('R2 test failed:', error)
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
message: 'R2 access test failed',
|
|
error: (error as Error).message
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Handle scheduled events (cron jobs)
|
|
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
|
// Cron job triggered
|
|
|
|
|
|
try {
|
|
// Run the backup function
|
|
const result = await backupAllBoards(env)
|
|
|
|
// Scheduled backup completed
|
|
|
|
// You can add additional logging or notifications here
|
|
if (!result.success) {
|
|
console.error('Scheduled backup failed:', result.message)
|
|
// In a real scenario, you might want to send alerts here
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
console.error('Scheduled backup error:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// export our router for cloudflare with CORS enabled
|
|
export default {
|
|
fetch: (request: Request, env: Environment, ctx: ExecutionContext) => {
|
|
// Handle WebSocket upgrades directly without CORS processing
|
|
const upgradeHeader = request.headers.get("Upgrade")
|
|
if (upgradeHeader === "websocket") {
|
|
// WebSocket upgrade detected, bypassing router CORS
|
|
return router.fetch(request, env, ctx)
|
|
}
|
|
|
|
// For regular requests, apply CORS
|
|
return router.fetch(request, env, ctx).then(response => corsify(response))
|
|
},
|
|
scheduled
|
|
}
|