Skip to content

Commit

Permalink
Add coturn support
Browse files Browse the repository at this point in the history
  • Loading branch information
kern committed Dec 31, 2024
1 parent 1c1cc16 commit 9e7d78c
Show file tree
Hide file tree
Showing 15 changed files with 210 additions and 30 deletions.
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
.next
node_modules
dist
dist
.env
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
node_modules
dist
tsconfig.tsbuildinfo
.env
17 changes: 17 additions & 0 deletions docker-compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions src/app/api/ice/route.ts
Original file line number Diff line number Diff line change
@@ -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
}
]
})
}
3 changes: 0 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
8 changes: 4 additions & 4 deletions src/channel.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 15 additions & 6 deletions src/components/Downloader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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." />
Expand Down
22 changes: 20 additions & 2 deletions src/components/WebRTCProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -56,20 +68,26 @@ 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)
setIsStopped(true)
}, [])

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 <></>
}
Expand Down
30 changes: 30 additions & 0 deletions src/coturn.ts
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 16 additions & 6 deletions src/hooks/useDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -90,29 +92,30 @@ 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)
setIsDownloading(false)
}

const handleError = (err: Error) => {
console.error(err)
console.error('[Downloader] connection error:', err)
setErrorMessage(cleanErrorMessage(err.message))
if (conn.open) conn.close()
else handleClose()
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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<
Expand Down Expand Up @@ -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,
Expand All @@ -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()
}
Expand All @@ -217,19 +225,21 @@ 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])

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()
}
Expand Down
Loading

0 comments on commit 9e7d78c

Please sign in to comment.