diff --git a/.dockerignore b/.dockerignore index 2022f33..0957690 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .DS_Store .next node_modules -dist \ No newline at end of file +dist +.env \ No newline at end of file diff --git a/.gitignore b/.gitignore index b1ead94..1a6c88d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist tsconfig.tsbuildinfo +.env \ No newline at end of file diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 3f1e20e..36b2a88 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 59caa9e..0d59499 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json index 486e5ed..141d23c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/ice/route.ts b/src/app/api/ice/route.ts new file mode 100644 index 0000000..41e9e99 --- /dev/null +++ b/src/app/api/ice/route.ts @@ -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 { + 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 + } + ] + }) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 62405e0..c415e33 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -34,9 +34,6 @@ export default function RootLayout({ return ( - - - diff --git a/src/channel.ts b/src/channel.ts index 4ef60f9..8d7568c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -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() diff --git a/src/components/Downloader.tsx b/src/components/Downloader.tsx index a0c050f..a8e2632 100644 --- a/src/components/Downloader.tsx +++ b/src/components/Downloader.tsx @@ -231,8 +231,19 @@ export default function Downloader({ ) } - if (!isConnected) { - return + if (isPasswordRequired) { + return ( + + ) + } + + if (errorMessage) { + return ( + <> + + + + ) } if (isDownloading && filesInfo) { @@ -250,10 +261,8 @@ export default function Downloader({ return } - if (isPasswordRequired) { - return ( - - ) + if (!isConnected) { + return } return diff --git a/src/components/WebRTCProvider.tsx b/src/components/WebRTCProvider.tsx index 8c50c09..64b8637 100644 --- a/src/components/WebRTCProvider.tsx +++ b/src/components/WebRTCProvider.tsx @@ -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 { 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(globalPeer) const [isStopped, setIsStopped] = useState(false) + const [error, setError] = useState(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 + } + if (isStopped) { return <> } diff --git a/src/coturn.ts b/src/coturn.ts new file mode 100644 index 0000000..3b89452 --- /dev/null +++ b/src/coturn.ts @@ -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 { + 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) +} diff --git a/src/hooks/useDownloader.ts b/src/hooks/useDownloader.ts index 82c352e..6deae44 100644 --- a/src/hooks/useDownloader.ts +++ b/src/hooks/useDownloader.ts @@ -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) => { 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() } diff --git a/src/hooks/useUploaderChannel.ts b/src/hooks/useUploaderChannel.ts index 5c0d7e0..85603f4 100644 --- a/src/hooks/useUploaderChannel.ts +++ b/src/hooks/useUploaderChannel.ts @@ -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]) diff --git a/src/hooks/useUploaderConnections.ts b/src/hooks/useUploaderConnections.ts index 1cc778f..0f1c8bc 100644 --- a/src/hooks/useUploaderConnections.ts +++ b/src/hooks/useUploaderConnections.ts @@ -34,11 +34,14 @@ export function useUploaderConnections( const [connections, setConnections] = useState>([]) 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()) } diff --git a/src/redisClient.ts b/src/redisClient.ts new file mode 100644 index 0000000..ee7afcd --- /dev/null +++ b/src/redisClient.ts @@ -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 +} \ No newline at end of file