canvas-website/worker/worker.ts

538 lines
17 KiB
TypeScript

import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
import { RecordingProcessor } from './RecordingProcessor'
// At the top with other exports
export { RecordingProcessor } from './RecordingProcessor'
// make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from "./TldrawDurableObject"
// Define security headers
const securityHeaders = {
"Content-Security-Policy":
"default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; 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/*",
]
// 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
if (
origin.match(
/^http:\/\/(localhost|127\.0\.0\.1|192\.168\.|169\.254\.|10\.255\.255\.254|10\.)/,
)
) {
return origin
}
return undefined
},
allowMethods: ["GET", "POST", "OPTIONS", "UPGRADE"],
allowHeaders: [
"Content-Type",
"Authorization",
"Upgrade",
"Connection",
"Sec-WebSocket-Key",
"Sec-WebSocket-Version",
"Sec-WebSocket-Extensions",
"Sec-WebSocket-Protocol",
],
maxAge: 86400,
credentials: true,
})
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight],
finally: [
(response) => {
// Add security headers to all responses except WebSocket upgrades
if (response.status !== 101) {
Object.entries(securityHeaders).forEach(([key, value]) => {
response.headers.set(key, value)
})
}
return corsify(response)
},
],
catch: (e: Error) => {
// Silently handle WebSocket errors, but log other errors
if (e.message?.includes("WebSocket")) {
console.debug("WebSocket error:", e)
return new Response(null, { status: 400 })
}
console.error(e)
return error(e)
},
})
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get("/connect/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
})
// 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", handleUnfurlRequest)
.get("/room/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_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.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
method: "POST",
body: request.body,
})
})
.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 {
const response = await fetch('https://api.daily.co/v1/rooms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
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' }
})
}
})
// Update the recording start endpoint to use room name in the URL
.post("/daily/recordings/:roomName/start", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
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 { layout?: { preset: string } };
const requestBody = {
layout: body.layout
};
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/recordings/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
// Return the response with the same status code
return new Response(JSON.stringify(data), {
status: response.status,
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/:roomName/stop", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
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}/recordings/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
const data = await response.json()
return new Response(JSON.stringify(data), {
status: response.status,
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 endpoint to list recordings for a room
.get("/daily/recordings/:roomName", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
// Add query parameters to get recent recordings
const timeframeStart = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); // Last 24 hours
const response = await fetch(`https://api.daily.co/v1/recordings?room_name=${roomName}&timeframe_start=${timeframeStart}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to fetch recordings: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Fetching recordings failed:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
});
interface DailyWebhookPayload {
event: string;
recording: {
download_url: string;
id: string;
};
room: {
name: string;
};
}
// Add a new webhook endpoint for Daily.co
router
.post("/webhooks/daily", async (request: IRequest, env: Environment) => {
// 1. Verify webhook signature
const signature = request.headers.get('X-Webhook-Signature');
const timestamp = request.headers.get('X-Webhook-Timestamp');
if (!signature || !timestamp) {
return new Response('Missing webhook signature headers', { status: 401 });
}
const payload = await request.json() as DailyWebhookPayload;
// Only process recording-ready events
if (payload.event !== 'recording-ready') {
return new Response('Ignored non-recording event', { status: 200 });
}
try {
const processor = new RecordingProcessor();
const response = await processor.fetch(new Request(payload.recording.download_url), env);
const result = await response.json() as { location: string };
return Response.json({
success: true,
location: result.location
});
} catch (error) {
console.error('Failed to process recording:', error);
return Response.json({
success: false,
error: (error as Error).message
}, { status: 500 });
}
})
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
await env.BOARD_BACKUPS_BUCKET.put(backupKey, jsonData)
console.log(`Backed up ${room.key} to ${backupKey}`)
} catch (error) {
console.error(`Failed to backup room ${room.key}:`, error)
}
}
// Clean up old backups (keep last 30 days)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const oldBackups = await env.BOARD_BACKUPS_BUCKET.list({
prefix: thirtyDaysAgo.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("/backup", async (_, env) => {
const result = await backupAllBoards(env)
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' }
})
})
// Start transcription
router
.post('/daily/transcription/:room/start', async (req, _env) => {
const { room } = req.params;
const apiKey = req.headers.get('Authorization')?.split(' ')[1];
if (!apiKey) {
return new Response('API key required', { status: 401 });
}
try {
const response = await fetch(`https://api.daily.co/v1/rooms/${room}/transcription/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(req.body)
});
const data = await response.json();
if (!response.ok) {
throw new Error((data as { error?: string })?.error || 'Failed to start transcription');
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Error starting transcription:', error);
return new Response(JSON.stringify({ error: 'Failed to start transcription' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
})
// Stop transcription
router
.post('/daily/transcription/:room/stop', async (req, _env) => {
const { room } = req.params;
const apiKey = req.headers.get('Authorization')?.split(' ')[1];
if (!apiKey) {
return new Response('API key required', { status: 401 });
}
try {
const response = await fetch(`https://api.daily.co/v1/rooms/${room}/transcription/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!response.ok) {
throw new Error((data as { error?: string })?.error || 'Failed to stop transcription');
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Error stopping transcription:', error);
return new Response(JSON.stringify({ error: 'Failed to stop transcription' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
})
// Add new endpoint to list transcripts for a room
router
.get("/daily/transcription/:roomName", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
// Use the correct query parameter name: room_name instead of roomId
const response = await fetch(`https://api.daily.co/v1/transcript?room_name=${roomName}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to fetch transcripts: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Fetching transcripts failed:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
});
// Add new endpoint to get transcript access link
router.get("/daily/transcription/:id/access-link", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1];
const transcriptId = req.params.id;
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 errorData = await response.json();
throw new Error(`Failed to get transcript link: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Getting transcript link failed:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
});
// export our router for cloudflare
export default router