p2pcf
Version:
WebRTC signalling using Cloudflare Workers
632 lines (505 loc) • 17.4 kB
JavaScript
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,HEAD,POST,DELETE,OPTIONS',
'Access-Control-Max-Age': '86400'
}
const IPV4_REGEX = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/
const IPV6_REGEX = /(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/
const RATE_LIMITING_SAMPLING_RATE = 100.0
function validatePayload (headers, payload) {
if (
!payload.r ||
payload.r.length < 4 ||
payload.r.length > 64 ||
!payload.r.match(/^[A-Za-z0-9_-]+$/)
) {
return new Response('Bad room id' + payload.r, { status: 400, headers })
}
if (payload.d) {
if (
!payload.x ||
typeof payload.x !== 'number' ||
payload.x > 24 * 60 * 60 * 1000
) {
return new Response('Bad expiration', { status: 400, headers })
}
// Validate timestamp - note date is of last I/O in worker
if (
!payload.t ||
typeof payload.t !== 'number' ||
Math.abs(payload.t - new Date().getTime()) > 10 * 60 * 1000
) {
return new Response('Bad timestamp', { status: 400, headers })
}
if (!payload.k || payload.k.length > 64) {
return new Response('Bad context id', { status: 400, headers })
}
// Registering an entry
const d = payload.d
if (d.length !== 6) {
return new Response('Bad data length', { status: 400, headers })
}
// Validate session id + client id + context id
if (!d[0] || d[0].length > 64) {
return new Response('Bad session id', { status: 400, headers })
}
if (!d[1] || d[1].length > 64) {
return new Response('Bad client id', { status: 400, headers })
}
if (typeof d[2] !== 'boolean') {
return new Response('Bad is symmetric', { status: 400, headers })
}
if (!d[3] || d[3].length !== 44) {
return new Response('Bad dtls', { status: 400, headers })
}
if (!d[4] || typeof d[4] !== 'number') {
return new Response('Bad joined at timestamp', { status: 400, headers })
}
if (
!d[5] ||
typeof d[5] !== 'object' ||
d[5].find(ip => !ip.match(IPV4_REGEX) && !ip.match(IPV6_REGEX))
) {
return new Response('Bad reflexive IPs', { status: 400, headers })
}
try {
atob(d[3])
} catch (e) {
return new Response('Bad base64 encoding', { status: 400, headers })
}
}
}
function getRandomString (length) {
let result = ''
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}
function b64toBlob (b64Data, contentType = '', sliceSize = 512) {
const byteCharacters = atob(b64Data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
const blob = new Blob(byteArrays, { type: contentType })
return blob
}
function getEntryDeleteKey (entry) {
return entry[entry.length - 1]
}
function getEntryContextId (entry) {
return entry[entry.length - 2]
}
function getEntryPackages (entry) {
return entry[entry.length - 3]
}
function getEntryTimestamp (entry) {
return entry[entry.length - 4]
}
function getEntrySessionId (entry) {
return entry[0]
}
function getEntryPayloadLength (entry) {
return entry.length - 3
}
async function handleGet (request, env) {
const hasStore = !!getStore(env)
return new Response(
`<html><body style="font-size: 24px; padding: 18px; font-family: Arial, sans-serif"">Hello from P2PCF<br/><div style=\"line-height: 28px; margin-top: 8px; font-size: 0.8em\">${
hasStore
? '👍 R2 bucket is configured properly, ready to serve.'
: '❌ Couldn\'t find a configured R2 bucket.<br/>Make sure you <a href="https://github.com/gfodor/p2pcf/blob/master/INSTALL.md#set-up-the-r2-bucket" target="_blank">created a bucket</a> and <a href="https://github.com/gfodor/p2pcf/blob/master/INSTALL.md#bind-the-worker-to-r2" target="_blank">connected the worker to it</a>.'
}</div></body></html>`,
{
headers: {
'Content-Type': 'text/html'
}
}
)
}
async function handleOptions (request, env) {
const headers = request.headers
if (
headers.get('Origin') !== null &&
headers.get('Access-Control-Request-Method') !== null &&
headers.get('Access-Control-Request-Headers') !== null
) {
const respHeaders = {
...corsHeaders,
'Access-Control-Allow-Headers': request.headers.get(
'Access-Control-Request-Headers'
)
}
return new Response(null, {
headers: respHeaders
})
} else {
// Handle standard OPTIONS request.
// If you want to allow other HTTP Methods, you can do that here.
return new Response(null, {
headers: {
Allow: 'GET, POST, DELETE, OPTIONS'
}
})
}
}
async function lookupEntries (roomId, store) {
let maxIndex = -1
const maxIndexEntry = await store.get(`rooms/${roomId}/max_index`)
if (maxIndexEntry) {
maxIndex = parseInt(await maxIndexEntry.text())
}
const entries = []
const ps = []
// read a few ahead of max index just to reduce latency during small groups joining on KV
// read entries as promises first to parallelize reads. if the entries are empty then don't do this.
for (let i = 0; i <= maxIndex; i++) {
const p = store
.get(`rooms/${roomId}/entries:${i}`)
.then(v => (v ? v.json() : v))
entries.push(p)
ps.push(p)
// Connection limit
if (ps.length >= 2) {
await Promise.all(ps)
ps.length = 0
}
}
await Promise.all(ps)
return [entries, maxIndex]
}
function getStore (env) {
let store = null
for (const obj of Object.values(env)) {
if (obj.get && obj.put && obj.delete && obj.list) {
store = obj
break
}
}
return store
}
async function handleDelete (request, env, context) {
const headers = { ...corsHeaders, Vary: 'Origin' }
const payload = await request.json()
if (!payload.dk) {
return new Response('Missing delete key', { status: 400, headers })
}
const errorResponse = validatePayload(headers, payload)
if (errorResponse) return errorResponse
const roomId = payload.r
const store = getStore(env)
const [entries, maxIndex] = await lookupEntries(roomId, store)
for (let i = 0; i < entries.length; i++) {
const value = await entries[i]
if (value) {
entries[i] = value
} else {
entries[i] = null
}
}
for (let i = 0; i < entries.length; i++) {
if (entries[i] === null) continue
const entry = entries[i]
const entrySessionId = getEntrySessionId(entry)
const entryContextId = getEntryContextId(entry)
const entryDeleteKey = getEntryDeleteKey(entry)
if (
payload.k === entryContextId &&
payload.d[0] === entrySessionId &&
payload.dk === entryDeleteKey
) {
context.waitUntil(store.delete(`rooms/${roomId}/entries:${i}`))
if (maxIndex === i) {
const now = new Date().getTime()
context.waitUntil(
store.put(`rooms/${roomId}/max_index`, `${i - 1}`, {
customMetadata: { expireAt: now + 8 * 60 * 60 * 1000 }
})
)
}
return new Response('{}', { status: 200, headers })
}
}
return new Response('No delete key', { status: 404, headers })
}
async function handlePost (request, env, context) {
const headers = { ...corsHeaders, Vary: 'Origin' }
const payload = await request.json()
const errorResponse = validatePayload(headers, payload)
if (errorResponse) return errorResponse
const store = getStore(env)
const roomId = payload.r
const now = new Date().getTime()
// R2 needs vacuum
const nextVacuumEntry = store.get(`rooms/${roomId}/next_vacuum`)
const [entries, maxIndex] = await lookupEntries(roomId, store)
for (let i = 0; i < entries.length; i++) {
const value = await entries[i]
if (value) {
entries[i] = value
} else {
entries[i] = null
}
}
const contextId = payload.k
let deleteKeyForEntry = null
if (payload.d && payload.p) {
// This is the timestamp on the session side of this data set if this changes, we write to the store
const timestamp = payload.t
const packages = payload.p
deleteKeyForEntry = getRandomString(24)
let shouldSave = true
// Need to save the entry if it doesn't exist already.
for (let i = 0; i < entries.length; i++) {
if (entries[i] === null) continue
const entry = entries[i]
const entryContextId = getEntryContextId(entry)
const entryTimestamp = getEntryTimestamp(entry)
const entryDeleteKey = getEntryDeleteKey(entry)
if (contextId === entryContextId) {
deleteKeyForEntry = entryDeleteKey
if (entryTimestamp === timestamp) {
shouldSave = false
}
break
}
}
if (shouldSave) {
let saved = false
// Entry is the payload plus additional data that isn't directly returned to the session.
const newEntry = [
...payload.d,
timestamp,
packages,
contextId,
deleteKeyForEntry
]
// Cap expiration to 15 minutes
const expireIn = Math.min(15 * 60 * 1000, payload.x)
const putOptions = { customMetadata: { expireAt: now + expireIn } }
// First search for an exisitng one
for (let i = 0; i < entries.length; i++) {
if (entries[i] === null) continue
const entry = entries[i]
const entryContextId = getEntryContextId(entry)
if (entryContextId !== contextId) continue
if (saved) {
// Duplicate, weird
context.waitUntil(store.delete(`rooms/${roomId}/entries:${i}`))
entries[i] = null
} else {
context.waitUntil(
store.put(
`rooms/${roomId}/entries:${i}`,
JSON.stringify(newEntry),
putOptions
)
)
entries[i] = newEntry
saved = true
}
}
// Could not find an existing slot to replace, so look for an empty slot or add to the end.
if (!saved) {
// Look for a null slot
for (let i = 0; i < entries.length; i++) {
if (entries[i] !== null) continue
context.waitUntil(
store.put(
`rooms/${roomId}/entries:${i}`,
JSON.stringify(newEntry),
putOptions
)
)
entries[i] = newEntry
saved = true
break
}
// Otherwise push a new entry
if (!saved) {
entries.push(newEntry)
context.waitUntil(
store.put(
`rooms/${roomId}/entries:${entries.length - 1}`,
JSON.stringify(newEntry),
putOptions
)
)
}
}
}
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i] === null) continue
// max index always increases, rely on expiration to lower watermark
if (maxIndex < i) {
context.waitUntil(
store.put(`rooms/${roomId}/max_index`, `${i}`, {
customMetadata: { expireAt: now + 8 * 60 * 60 * 1000 }
})
)
break
}
}
}
// Build the peer payload, list and the packages.
const map = new Map()
const packages = []
for (let i = 0; i < entries.length; i++) {
if (entries[i] === null) continue
const entry = entries[i]
const entryContextId = getEntryContextId(entry)
if (contextId === entryContextId) continue
const timestamp = getEntryTimestamp(entry)
// Get the earliest entry for a given context id.
if (!map.has(entryContextId)) {
map.set(entryContextId, entry.slice(0, getEntryPayloadLength(entry)))
} else {
const existing = map.get(entryContextId)
if (existing[existing.length - 1] < timestamp) {
map.set(entryContextId, entry.slice(0, getEntryPayloadLength(entry)))
}
}
// Add to the packages due to this session.
if (payload.d) {
const sessionId = payload.d[0]
const entryPackages = getEntryPackages(entry)
for (let j = 0; j < entryPackages.length; j++) {
// Package was meant for this session
if (entryPackages[j][0] === sessionId) {
packages.push(entryPackages[j])
}
}
}
}
const peers = [...map.values()]
const responseData = { ps: peers, pk: packages }
if (deleteKeyForEntry) {
responseData.dk = deleteKeyForEntry
}
// Check for vacuum
const nextVacuumEntryValue = await nextVacuumEntry
if (
!nextVacuumEntryValue ||
now > parseInt(await nextVacuumEntryValue.text())
) {
// Add a random delay and re-check to avoid stampede.
context.waitUntil(
new Promise(res => {
setTimeout(async () => {
const now = new Date().getTime()
const nextVacuumEntry = await store.get(`rooms/${roomId}/next_vacuum`)
if (
!nextVacuumEntry ||
now > parseInt(await nextVacuumEntry.text())
) {
let removed = 0
// Vacuum
await store.put(`rooms/${roomId}/next_vacuum`, `${now + 30 * 1000}`) // One mintue room vacuum interval
const list = await store.list({
include: ['customMetadata'],
prefix: `rooms/${roomId}/`
})
const removePromises = []
for (const {
key,
customMetadata: { expireAt }
} of list.objects) {
if (!expireAt || now < expireAt) continue
removePromises.push(store.delete(key))
removed++
if (removePromises.length >= 5) {
await Promise.all(removePromises)
removePromises.length = 0
}
}
await Promise.all(removePromises)
console.log(
'Vacuumed room ' +
roomId +
'. Removed ' +
(removed + 1) +
' keys.'
)
}
res()
}, Math.floor(Math.random() * 10 * 1000))
})
)
}
return new Response(JSON.stringify(responseData), { status: 200, headers })
}
async function getResponseIfDisallowed (request, env) {
// No CORS header, so can't do anything
const origin = request.headers.get('origin')
if (!origin) return null
let originQuota = env.ORIGIN_QUOTA ? parseInt(env.ORIGIN_QUOTA) : 10000
if (env.ALLOWED_ORIGINS) {
if (!env.ORIGIN_QUOTA) {
originQuota = 0
}
if (env.ALLOWED_ORIGINS.split(',').includes(origin)) {
return null
}
}
if (originQuota === 0) {
return new Response('Unauthorized', { status: 401 })
}
const store = getStore(env)
const d = new Date()
const currentCountKey = `join-counts/${d.getYear()}-${d.getMonth()}/${encodeURIComponent(
origin
)}`
const currentCountEntry = await store.get(currentCountKey)
let currentCount = 0
if (currentCountEntry) {
currentCount = parseInt(await currentCountEntry.text())
}
if (currentCount >= originQuota) {
return new Response('Over quota', { status: 429 })
}
// Do 1 out of RATE_LIMITING_SAMPLING_RATE sampling
if (Math.random() < 1.0 / RATE_LIMITING_SAMPLING_RATE) {
await store.put(
currentCountKey,
(currentCount + Math.floor(RATE_LIMITING_SAMPLING_RATE)).toString()
)
}
}
export default {
async fetch (request, env, context) {
const disallowedResponse = await getResponseIfDisallowed(request, env)
if (disallowedResponse) {
return disallowedResponse
}
if (request.method === 'GET') {
return handleGet(request, env, context)
}
if (request.method === 'OPTIONS') {
return handleOptions(request, env, context)
}
if (request.headers.get('content-type') !== 'application/json')
return new Response('Expected content-type application/json', {
status: 400
})
if (
request.headers.get('x-worker-method') === 'DELETE' ||
request.method === 'DELETE'
) {
return await handleDelete(request, env, context)
}
if (request.method === 'POST') {
return await handlePost(request, env, context)
}
return new Response('Method not allowed', { status: 405 })
}
}