Add coturn support

pull/162/head
Alex Kern 12 months ago
parent 1c1cc16e60
commit 9e7d78c755
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -1,4 +1,5 @@
.DS_Store
.next
node_modules
dist
dist
.env

1
.gitignore vendored

@ -3,3 +3,4 @@
node_modules
dist
tsconfig.tsbuildinfo
.env

@ -7,6 +7,20 @@ services:
- filepizza
volumes:
- redis_data:/data
coturn:
image: coturn/coturn
ports:
- 3478:3478
- 3478:3478/udp
- 5349:5349
- 5349:5349/udp
- 49152-65535:49152-65535/udp
environment:
- DETECT_EXTERNAL_IP=yes
- DETECT_RELAY_IP=yes
command: -n --log-file=stdout --redis-userdb="ip=redis connect_timeout=30"
networks:
- filepizza
filepizza:
build: .
image: kern/filepizza:latest
@ -15,10 +29,13 @@ services:
environment:
- PORT=80
- REDIS_URL=redis://redis:6379
- COTURN_ENABLED=true
networks:
- filepizza
depends_on:
- redis
env_file:
- .env
networks:
filepizza:

@ -7,6 +7,21 @@ services:
- filepizza
volumes:
- redis_data:/data
coturn:
image: coturn/coturn
ports:
- 3478:3478
- 3478:3478/udp
- 5349:5349
- 5349:5349/udp
# Relay Ports
# - 49152-65535:49152-65535/udp
environment:
- DETECT_EXTERNAL_IP=yes
- DETECT_RELAY_IP=yes
command: -n --log-file=stdout --redis-userdb="ip=redis connect_timeout=30"
networks:
- filepizza
filepizza:
build: .
image: kern/filepizza:latest

@ -7,7 +7,7 @@
"homepage": "https://github.com/kern/filepizza",
"scripts": {
"dev": "next",
"dev:redis": "docker compose up redis -d && REDIS_URL=redis://localhost:6379 next",
"dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next",
"build": "next build",
"start": "next start",
"start:peerjs": "./bin/peerjs.js",

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import crypto from 'crypto'
import { setTurnCredentials } from '../../../coturn'
const turnHost = process.env.TURN_HOST || '127.0.0.1'
export async function POST(): Promise<NextResponse> {
if (!process.env.COTURN_ENABLED) {
return NextResponse.json({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
})
}
// Generate ephemeral credentials
const username = crypto.randomBytes(8).toString('hex')
const password = crypto.randomBytes(8).toString('hex')
const ttl = 86400 // 24 hours
// Store credentials in Redis
await setTurnCredentials(username, password, ttl)
return NextResponse.json({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: [
`turn:${turnHost}:3478`,
`turns:${turnHost}:5349`
],
username,
credential: password
}
]
})
}

@ -34,9 +34,6 @@ export default function RootLayout({
return (
<ViewTransitions>
<html lang="en" suppressHydrationWarning>
<head>
<meta name="monetization" content="$twitter.xrptipbot.com/kernio" />
</head>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<FilePizzaQueryClientProvider>

@ -1,6 +1,6 @@
import 'server-only'
import config from './config'
import Redis from 'ioredis'
import { Redis, getRedisClient } from './redisClient'
import { generateShortSlug, generateLongSlug } from './slugs'
import crypto from 'crypto'
import { z } from 'zod'
@ -211,8 +211,8 @@ export class MemoryChannelRepo implements ChannelRepo {
export class RedisChannelRepo implements ChannelRepo {
client: Redis.Redis
constructor(redisURL: string) {
this.client = new Redis(redisURL)
constructor() {
this.client = getRedisClient()
}
async createChannel(
@ -289,7 +289,7 @@ let _channelRepo: ChannelRepo | null = null
export function getOrCreateChannelRepo(): ChannelRepo {
if (!_channelRepo) {
if (process.env.REDIS_URL) {
_channelRepo = new RedisChannelRepo(process.env.REDIS_URL)
_channelRepo = new RedisChannelRepo()
console.log('[ChannelRepo] Using Redis storage')
} else {
_channelRepo = new MemoryChannelRepo()

@ -231,8 +231,19 @@ export default function Downloader({
)
}
if (!isConnected) {
return <ConnectingToUploader />
if (isPasswordRequired) {
return (
<PasswordEntry errorMessage={errorMessage} onSubmit={submitPassword} />
)
}
if (errorMessage) {
return (
<>
<ErrorMessage message={errorMessage} />
<ReturnHome />
</>
)
}
if (isDownloading && filesInfo) {
@ -250,10 +261,8 @@ export default function Downloader({
return <ReadyToDownload filesInfo={filesInfo} onStart={startDownload} />
}
if (isPasswordRequired) {
return (
<PasswordEntry errorMessage={errorMessage} onSubmit={submitPassword} />
)
if (!isConnected) {
return <ConnectingToUploader />
}
return <Loading text="Uh oh... Something went wrong." />

@ -10,6 +10,7 @@ import React, {
} from 'react'
import Loading from './Loading'
import Peer from 'peerjs'
import { ErrorMessage } from './ErrorMessage'
export type WebRTCPeerValue = {
peer: Peer
@ -30,7 +31,18 @@ let globalPeer: Peer | null = null
async function getOrCreateGlobalPeer(): Promise<Peer> {
if (!globalPeer) {
globalPeer = new Peer()
const response = await fetch('/api/ice', {
method: 'POST'
})
const { iceServers } = await response.json()
console.log('[WebRTCProvider] ICE servers:', iceServers)
globalPeer = new Peer({
debug: 3,
config: {
iceServers
}
})
}
if (globalPeer.id) {
@ -56,8 +68,10 @@ export default function WebRTCPeerProvider({
}): JSX.Element {
const [peerValue, setPeerValue] = useState<Peer | null>(globalPeer)
const [isStopped, setIsStopped] = useState(false)
const [error, setError] = useState<Error | null>(null)
const stop = useCallback(() => {
console.log('[WebRTCProvider] Stopping peer')
globalPeer?.destroy()
globalPeer = null
setPeerValue(null)
@ -65,11 +79,15 @@ export default function WebRTCPeerProvider({
}, [])
useEffect(() => {
getOrCreateGlobalPeer().then(setPeerValue)
getOrCreateGlobalPeer().then(setPeerValue).catch(setError)
}, [])
const value = useMemo(() => ({ peer: peerValue!, stop }), [peerValue, stop])
if (error) {
return <ErrorMessage message={error.message} />
}
if (isStopped) {
return <></>
}

@ -0,0 +1,30 @@
import crypto from 'crypto'
import { getRedisClient } from './redisClient'
function generateHMACKey(username: string, realm: string, password: string): string {
const str = `${username}:${realm}:${password}`
return crypto.createHash('md5').update(str).digest('hex')
}
export async function setTurnCredentials(
username: string,
password: string,
ttl: number
): Promise<void> {
if (!process.env.COTURN_ENABLED) {
return
}
const realm = process.env.TURN_REALM || 'file.pizza'
if (!realm) {
throw new Error('TURN_REALM environment variable not set')
}
const redis = getRedisClient()
const hmacKey = generateHMACKey(username, realm, password)
const key = `turn/realm/${realm}/user/${username}/key`
await redis.setex(key, ttl, hmacKey)
}

@ -62,6 +62,7 @@ export function useDownloader(uploaderPeerID: string): {
setDataConnection(conn)
const handleOpen = () => {
console.log('[Downloader] connection opened')
setIsConnected(true)
conn.send({
type: MessageType.RequestInfo,
@ -77,6 +78,7 @@ export function useDownloader(uploaderPeerID: string): {
const handleData = (data: unknown) => {
try {
const message = decodeMessage(data)
console.log('[Downloader] received message', message.type)
switch (message.type) {
case MessageType.PasswordRequired:
setIsPasswordRequired(true)
@ -90,21 +92,22 @@ export function useDownloader(uploaderPeerID: string): {
setRotating(true)
break
case MessageType.Error:
console.error(message.error)
console.error('[Downloader] received error message:', message.error)
setErrorMessage(message.error)
conn.close()
break
case MessageType.Report:
// Hard-redirect downloader to reported page
console.log('[Downloader] received report message, redirecting')
window.location.href = '/reported'
break
}
} catch (err) {
console.error(err)
console.error('[Downloader] error handling message:', err)
}
}
const handleClose = () => {
console.log('[Downloader] connection closed')
setRotating(false)
setDataConnection(null)
setIsConnected(false)
@ -112,7 +115,7 @@ export function useDownloader(uploaderPeerID: string): {
}
const handleError = (err: Error) => {
console.error(err)
console.error('[Downloader] connection error:', err)
setErrorMessage(cleanErrorMessage(err.message))
if (conn.open) conn.close()
else handleClose()
@ -125,6 +128,7 @@ export function useDownloader(uploaderPeerID: string): {
peer.on('error', handleError)
return () => {
console.log('[Downloader] cleaning up connection')
if (conn.open) {
conn.close()
} else {
@ -144,6 +148,7 @@ export function useDownloader(uploaderPeerID: string): {
const submitPassword = useCallback(
(pass: string) => {
if (!dataConnection) return
console.log('[Downloader] submitting password')
dataConnection.send({
type: MessageType.UsePassword,
password: pass,
@ -154,6 +159,7 @@ export function useDownloader(uploaderPeerID: string): {
const startDownload = useCallback(() => {
if (!filesInfo || !dataConnection) return
console.log('[Downloader] starting download')
setIsDownloading(true)
const fileStreamByPath: Record<
@ -182,6 +188,7 @@ export function useDownloader(uploaderPeerID: string): {
let nextFileIndex = 0
const startNextFileOrFinish = () => {
if (nextFileIndex >= filesInfo.length) return
console.log('[Downloader] starting next file:', filesInfo[nextFileIndex].fileName)
dataConnection.send({
type: MessageType.Start,
fileName: filesInfo[nextFileIndex].fileName,
@ -193,12 +200,13 @@ export function useDownloader(uploaderPeerID: string): {
processChunk.current = (message: z.infer<typeof ChunkMessage>) => {
const fileStream = fileStreamByPath[message.fileName]
if (!fileStream) {
console.error('no stream found for ' + message.fileName)
console.error('[Downloader] no stream found for', message.fileName)
return
}
setBytesDownloaded((bd) => bd + (message.bytes as ArrayBuffer).byteLength)
fileStream.enqueue(new Uint8Array(message.bytes as ArrayBuffer))
if (message.final) {
console.log('[Downloader] finished receiving', message.fileName)
fileStream.close()
startNextFileOrFinish()
}
@ -217,12 +225,13 @@ export function useDownloader(uploaderPeerID: string): {
downloadPromise
.then(() => {
console.log('[Downloader] all files downloaded')
dataConnection.send({ type: MessageType.Done } as z.infer<
typeof Message
>)
setDone(true)
})
.catch(console.error)
.catch((err) => console.error('[Downloader] download error:', err))
startNextFileOrFinish()
}, [dataConnection, filesInfo])
@ -230,6 +239,7 @@ export function useDownloader(uploaderPeerID: string): {
const stopDownload = useCallback(() => {
// TODO(@kern): Continue here with stop / pause logic
if (dataConnection) {
console.log('[Downloader] pausing download')
dataConnection.send({ type: MessageType.Pause })
dataConnection.close()
}

@ -24,15 +24,22 @@ export function useUploaderChannel(
const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID],
queryFn: async () => {
console.log('[UploaderChannel] creating new channel for peer', uploaderPeerID)
const response = await fetch('/api/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploaderPeerID }),
})
if (!response.ok) {
console.error('[UploaderChannel] failed to create channel:', response.status)
throw new Error('Network response was not ok')
}
return response.json()
const data = await response.json()
console.log('[UploaderChannel] channel created successfully:', {
longSlug: data.longSlug,
shortSlug: data.shortSlug
})
return data
},
refetchOnWindowFocus: false,
refetchOnMount: false,
@ -48,15 +55,19 @@ export function useUploaderChannel(
const renewMutation = useMutation({
mutationFn: async ({ secret: s }: { secret: string }) => {
console.log('[UploaderChannel] renewing channel for slug', shortSlug)
const response = await fetch('/api/renew', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: shortSlug, secret: s }),
})
if (!response.ok) {
console.error('[UploaderChannel] failed to renew channel', response.status)
throw new Error('Network response was not ok')
}
return response.json()
const data = await response.json()
console.log('[UploaderChannel] channel renewed successfully')
return data
},
})
@ -67,6 +78,7 @@ export function useUploaderChannel(
const run = (): void => {
timeout = setTimeout(() => {
console.log('[UploaderChannel] scheduling channel renewal in', renewInterval, 'ms')
renewMutation.mutate({ secret })
run()
}, renewInterval)
@ -75,7 +87,10 @@ export function useUploaderChannel(
run()
return () => {
if (timeout) clearTimeout(timeout)
if (timeout) {
console.log('[UploaderChannel] clearing renewal timeout')
clearTimeout(timeout)
}
}
}, [secret, shortSlug, renewMutation, renewInterval])
@ -83,16 +98,15 @@ export function useUploaderChannel(
if (!shortSlug || !secret) return
const handleUnload = (): void => {
console.log('[UploaderChannel] destroying channel on page unload')
// Using sendBeacon for best-effort delivery during page unload
navigator.sendBeacon('/api/destroy', JSON.stringify({ slug: shortSlug }))
}
window.addEventListener('beforeunload', handleUnload)
window.addEventListener('unload', handleUnload)
return () => {
window.removeEventListener('beforeunload', handleUnload)
window.removeEventListener('unload', handleUnload)
}
}, [shortSlug, secret])

@ -34,11 +34,14 @@ export function useUploaderConnections(
const [connections, setConnections] = useState<Array<UploaderConnection>>([])
useEffect(() => {
console.log('[UploaderConnections] initializing with', files.length, 'files')
const cleanupHandlers: Array<() => void> = []
const listener = (conn: DataConnection) => {
console.log('[UploaderConnections] new connection from peer', conn.peer)
// If the connection is a report, we need to hard-redirect the uploader to the reported page to prevent them from uploading more files.
if (conn.metadata?.type === 'report') {
console.log('[UploaderConnections] received report connection, redirecting')
// Broadcast report message to all connections
connections.forEach((c) => {
c.dataConnection.send({
@ -76,8 +79,14 @@ export function useUploaderConnections(
const onData = (data: any): void => {
try {
const message = decodeMessage(data)
console.log('[UploaderConnections] received message:', message.type)
switch (message.type) {
case MessageType.RequestInfo: {
console.log('[UploaderConnections] client info:', {
browser: `${message.browserName} ${message.browserVersion}`,
os: `${message.osName} ${message.osVersion}`,
mobile: message.mobileVendor ? `${message.mobileVendor} ${message.mobileModel}` : 'N/A'
})
const newConnectionState = {
browserName: message.browserName,
browserVersion: message.browserVersion,
@ -88,6 +97,7 @@ export function useUploaderConnections(
}
if (password) {
console.log('[UploaderConnections] password required, requesting authentication')
const request: Message = {
type: MessageType.PasswordRequired,
}
@ -128,6 +138,7 @@ export function useUploaderConnections(
}
})
console.log('[UploaderConnections] sending file info:', fileInfo)
const request: Message = {
type: MessageType.Info,
files: fileInfo,
@ -138,8 +149,10 @@ export function useUploaderConnections(
}
case MessageType.UsePassword: {
console.log('[UploaderConnections] password attempt received')
const { password: submittedPassword } = message
if (submittedPassword === password) {
console.log('[UploaderConnections] password correct')
updateConnection((draft) => {
if (
draft.status !== UploaderConnectionStatus.Authenticating &&
@ -167,6 +180,7 @@ export function useUploaderConnections(
conn.send(request)
} else {
console.log('[UploaderConnections] password incorrect')
updateConnection((draft) => {
if (
draft.status !== UploaderConnectionStatus.Authenticating
@ -192,6 +206,7 @@ export function useUploaderConnections(
case MessageType.Start: {
const fileName = message.fileName
let offset = message.offset
console.log('[UploaderConnections] starting transfer of', fileName, 'from offset', offset)
const file = validateOffset(files, fileName, offset)
const sendNextChunkAsync = () => {
@ -211,7 +226,7 @@ export function useUploaderConnections(
updateConnection((draft) => {
offset = end
if (final) {
console.log('final chunk', draft.completedFiles + 1)
console.log('[UploaderConnections] completed file', fileName, '- file', draft.completedFiles + 1, 'of', draft.totalFiles)
return {
...draft,
status: UploaderConnectionStatus.Ready,
@ -253,6 +268,7 @@ export function useUploaderConnections(
}
case MessageType.Pause: {
console.log('[UploaderConnections] transfer paused')
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Uploading) {
return draft
@ -272,6 +288,7 @@ export function useUploaderConnections(
}
case MessageType.Done: {
console.log('[UploaderConnections] transfer completed successfully')
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Ready) {
return draft
@ -287,11 +304,12 @@ export function useUploaderConnections(
}
}
} catch (err) {
console.error(err)
console.error('[UploaderConnections] error handling message:', err)
}
}
const onClose = (): void => {
console.log('[UploaderConnections] connection closed')
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
}
@ -326,6 +344,7 @@ export function useUploaderConnections(
peer.on('connection', listener)
return () => {
console.log('[UploaderConnections] cleaning up connections')
peer.off('connection', listener)
cleanupHandlers.forEach((fn) => fn())
}

@ -0,0 +1,12 @@
import Redis from 'ioredis'
export { Redis }
let redisClient: Redis.Redis | null = null
export function getRedisClient(): Redis.Redis {
if (!redisClient) {
redisClient = new Redis(process.env.REDIS_URL)
}
return redisClient
}
Loading…
Cancel
Save