mostly working

pull/134/head
Alex Kern 12 months ago
parent 4e6b8e7c18
commit 96b67dfaac
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -1,8 +1,11 @@
FROM node:alpine FROM node:lts-alpine
MAINTAINER Alexander Kern <filepizza@kern.io>
RUN apk add --no-cache pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . ./ COPY . ./
RUN npm install && npm run build RUN pnpm build
ENV NODE_ENV production ENV NODE_ENV production
EXPOSE 80 EXPOSE 80

@ -10,7 +10,10 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"start:peerjs": "./bin/peerjs.js", "start:peerjs": "./bin/peerjs.js",
"lint": "eslint 'src/**/*.ts[x]'" "lint": "eslint 'src/**/*.ts[x]'",
"docker:build": "docker build -t kern/filepizza .",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -37,7 +40,6 @@
"react-qr-code": "^2.0.15", "react-qr-code": "^2.0.15",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"tailwindcss": "^3.4.10", "tailwindcss": "^3.4.10",
"twilio": "^2.11.1",
"web-streams-polyfill": "^3.3.3", "web-streams-polyfill": "^3.3.3",
"webrtcsupport": "^2.2.0", "webrtcsupport": "^2.2.0",
"zod": "^3.23.8" "zod": "^3.23.8"

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,32 +0,0 @@
/* latin */
@font-face {
font-family: 'Lobster Two';
font-style: normal;
font-weight: 400;
src: local('Lobster Two'), local('LobsterTwo'), url(./LobsterTwo.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
/* latin */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 300;
src: local('Quicksand Light'), local('Quicksand-Light'), url(./QuicksandLight.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
/* latin */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
src: local('Quicksand Regular'), local('Quicksand-Regular'), url(./QuicksandNormal.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
/* latin */
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 700;
src: local('Quicksand Bold'), local('Quicksand-Bold'), url(./QuicksandBold.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}

@ -1,16 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { Channel, channelRepo } from '../../../channel' import { Channel, channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> { export async function POST(): Promise<NextResponse> {
const { uploaderPeerID } = await request.json() const channel: Channel = await channelRepo.create()
if (!uploaderPeerID) {
return NextResponse.json(
{ error: 'uploaderPeerID is required' },
{ status: 400 },
)
}
const channel: Channel = await channelRepo.create(uploaderPeerID)
return NextResponse.json(channel) return NextResponse.json(channel)
} }

@ -2,14 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel' import { channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> { export async function POST(request: NextRequest): Promise<NextResponse> {
const { slug } = await request.json() const { slug, secret } = await request.json()
if (!slug) { if (!slug) {
return NextResponse.json({ error: 'Slug is required' }, { status: 400 }) return NextResponse.json({ error: 'Slug is required' }, { status: 400 })
} }
if (!secret) {
return NextResponse.json({ error: 'Secret is required' }, { status: 400 })
}
try { try {
await channelRepo.destroy(slug) await channelRepo.destroy(slug, secret)
return NextResponse.json({ success: true }, { status: 200 }) return NextResponse.json({ success: true }, { status: 200 })
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(

@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
const { slug, offer } = await request.json()
if (!slug) {
return NextResponse.json({ error: 'Slug is required' }, { status: 400 })
}
if (!offer) {
return NextResponse.json({ error: 'Offer is required' }, { status: 400 })
}
await channelRepo.offer(slug, offer)
return NextResponse.json({ success: true })
}

@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel' import { channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> { export async function POST(request: NextRequest): Promise<NextResponse> {
const { slug } = await request.json() const { slug, secret } = await request.json()
if (!slug) { if (!slug) {
return NextResponse.json({ error: 'Slug is required' }, { status: 400 }) return NextResponse.json({ error: 'Slug is required' }, { status: 400 })
} }
await channelRepo.renew(slug) if (!secret) {
return NextResponse.json({ success: true }) return NextResponse.json({ error: 'Secret is required' }, { status: 400 })
}
const offers = await channelRepo.renew(slug, secret)
return NextResponse.json({ success: true, offers })
} }

@ -30,7 +30,7 @@ export default async function DownloadPage({
<Spinner direction="down" /> <Spinner direction="down" />
<Wordmark /> <Wordmark />
<WebRTCProvider> <WebRTCProvider>
<Downloader uploaderPeerID={channel.uploaderPeerID} /> <Downloader slug={slug} />
</WebRTCProvider> </WebRTCProvider>
</div> </div>
) )

@ -3,6 +3,7 @@ import Footer from '../components/Footer'
import '../styles.css' import '../styles.css'
import { ThemeProvider } from '../components/ThemeProvider' import { ThemeProvider } from '../components/ThemeProvider'
import { ModeToggle } from '../components/ModeToggle' import { ModeToggle } from '../components/ModeToggle'
import FilePizzaQueryClientProvider from '../components/QueryClientProvider'
export const metadata = { export const metadata = {
title: 'FilePizza • Your files, delivered.', title: 'FilePizza • Your files, delivered.',
@ -29,9 +30,11 @@ export default function RootLayout({
</head> </head>
<body> <body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<main>{children}</main> <FilePizzaQueryClientProvider>
<Footer /> <main>{children}</main>
<ModeToggle /> <Footer />
<ModeToggle />
</FilePizzaQueryClientProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

@ -18,8 +18,6 @@ import TitleText from '../components/TitleText'
import SubtitleText from '../components/SubtitleText' import SubtitleText from '../components/SubtitleText'
import { pluralize } from '../utils/pluralize' import { pluralize } from '../utils/pluralize'
const queryClient = new QueryClient()
function PageWrapper({ function PageWrapper({
children, children,
isRotating = false, isRotating = false,
@ -28,13 +26,11 @@ function PageWrapper({
isRotating?: boolean isRotating?: boolean
}): JSX.Element { }): JSX.Element {
return ( return (
<QueryClientProvider client={queryClient}> <div className="flex flex-col items-center space-y-5 py-10 max-w-2xl mx-auto px-4">
<div className="flex flex-col items-center space-y-5 py-10 max-w-2xl mx-auto px-4"> <Spinner direction="up" isRotating={isRotating} />
<Spinner direction="up" isRotating={isRotating} /> <Wordmark />
<Wordmark /> {children}
{children} </div>
</div>
</QueryClientProvider>
) )
} }

@ -2,18 +2,30 @@ import 'server-only'
import config from './config' import config from './config'
import Redis from 'ioredis' import Redis from 'ioredis'
import { generateShortSlug, generateLongSlug } from './slugs' import { generateShortSlug, generateLongSlug } from './slugs'
import crypto from 'crypto'
import { z } from 'zod'
export type Channel = { export type Channel = {
uploaderPeerID: string secret?: string
longSlug: string longSlug: string
shortSlug: string shortSlug: string
} }
const ChannelSchema = z.object({
secret: z.string().optional(),
longSlug: z.string(),
shortSlug: z.string(),
})
export interface ChannelRepo { export interface ChannelRepo {
create(uploaderPeerID: string, ttl?: number): Promise<Channel> create(ttl?: number): Promise<Channel>
fetch(slug: string): Promise<Channel | null> fetch(slug: string): Promise<Channel | null>
renew(slug: string, ttl: number): Promise<void> renew(
destroy(slug: string): Promise<void> slug: string,
secret: string,
ttl: number,
): Promise<RTCSessionDescriptionInit[]>
destroy(slug: string, secret: string): Promise<void>
} }
export class RedisChannelRepo implements ChannelRepo { export class RedisChannelRepo implements ChannelRepo {
@ -23,15 +35,12 @@ export class RedisChannelRepo implements ChannelRepo {
this.client = new Redis(redisURL) this.client = new Redis(redisURL)
} }
async create( async create(ttl: number = config.channel.ttl): Promise<Channel> {
uploaderPeerID: string,
ttl: number = config.channel.ttl,
): Promise<Channel> {
const shortSlug = await this.generateShortSlug() const shortSlug = await this.generateShortSlug()
const longSlug = await this.generateLongSlug() const longSlug = await this.generateLongSlug()
const channel: Channel = { const channel: Channel = {
uploaderPeerID, secret: crypto.randomUUID(),
longSlug, longSlug,
shortSlug, shortSlug,
} }
@ -43,38 +52,70 @@ export class RedisChannelRepo implements ChannelRepo {
return channel return channel
} }
async fetch(slug: string): Promise<Channel | null> { async fetch(slug: string, scrubSecret = false): Promise<Channel | null> {
const shortChannelStr = await this.client.get(this.getShortSlugKey(slug)) const shortChannelStr = await this.client.get(this.getShortSlugKey(slug))
if (shortChannelStr) { if (shortChannelStr) {
return this.deserializeChannel(shortChannelStr) return this.deserializeChannel(shortChannelStr, scrubSecret)
} }
const longChannelStr = await this.client.get(this.getLongSlugKey(slug)) const longChannelStr = await this.client.get(this.getLongSlugKey(slug))
if (longChannelStr) { if (longChannelStr) {
return this.deserializeChannel(longChannelStr) return this.deserializeChannel(longChannelStr, scrubSecret)
} }
return null return null
} }
async renew(slug: string, ttl: number = config.channel.ttl): Promise<void> { async renew(
slug: string,
secret: string,
ttl: number = config.channel.ttl,
): Promise<RTCSessionDescriptionInit[]> {
const channel = await this.fetch(slug) const channel = await this.fetch(slug)
if (!channel) { if (!channel || channel.secret !== secret) {
return return []
} }
await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl)
await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl) await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl)
const offerKey = this.getOfferKey(channel.shortSlug)
const offers = await this.client.lrange(offerKey, 0, -1)
if (offers.length > 0) {
return offers.map((offer) =>
JSON.parse(offer),
) as RTCSessionDescriptionInit[]
}
return []
}
async destroy(slug: string, secret: string): Promise<void> {
const channel = await this.fetch(slug)
if (!channel || channel.secret !== secret) {
return
}
await this.client.del(this.getLongSlugKey(channel.longSlug))
await this.client.del(this.getShortSlugKey(channel.shortSlug))
} }
async destroy(slug: string): Promise<void> { async offer(
slug: string,
offer: RTCSessionDescriptionInit,
ttl: number = config.channel.ttl,
): Promise<void> {
const channel = await this.fetch(slug) const channel = await this.fetch(slug)
if (!channel) { if (!channel) {
return return
} }
await this.client.del(channel.longSlug) const offerKey = this.getOfferKey(channel.shortSlug)
await this.client.del(channel.shortSlug) await this.client.rpush(offerKey, JSON.stringify(offer))
await this.client.expire(offerKey, ttl)
await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl)
} }
private async generateShortSlug(): Promise<string> { private async generateShortSlug(): Promise<string> {
@ -109,12 +150,22 @@ export class RedisChannelRepo implements ChannelRepo {
return `long:${longSlug}` return `long:${longSlug}`
} }
private getOfferKey(shortSlug: string): string {
return `offers:${shortSlug}`
}
private serializeChannel(channel: Channel): string { private serializeChannel(channel: Channel): string {
return JSON.stringify(channel) return JSON.stringify(channel)
} }
private deserializeChannel(str: string): Channel { private deserializeChannel(str: string, scrubSecret = false): Channel {
return JSON.parse(str) as Channel const parsedChannel = JSON.parse(str)
const validatedChannel = ChannelSchema.parse(parsedChannel)
if (scrubSecret) {
return { ...validatedChannel, secret: undefined }
}
return validatedChannel
} }
} }

@ -143,11 +143,7 @@ export function PasswordEntry({
) )
} }
export default function Downloader({ export default function Downloader({ slug }: { slug: string }): JSX.Element {
uploaderPeerID,
}: {
uploaderPeerID: string
}): JSX.Element {
const { const {
filesInfo, filesInfo,
isConnected, isConnected,
@ -160,7 +156,7 @@ export default function Downloader({
stopDownload, stopDownload,
totalSize, totalSize,
bytesDownloaded, bytesDownloaded,
} = useDownloader(uploaderPeerID) } = useDownloader(slug)
if (isDone && filesInfo) { if (isDone && filesInfo) {
return ( return (

@ -0,0 +1,15 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function FilePizzaQueryClientProvider({
children,
}: {
children: React.ReactNode
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

@ -1,11 +1,10 @@
'use client' 'use client'
import React from 'react' import React, { useRef } from 'react'
import { UploadedFile, UploaderConnectionStatus } from '../types' import { UploadedFile, UploaderConnectionStatus } from '../types'
import { useWebRTC } from './WebRTCProvider' import { useWebRTC } from './WebRTCProvider'
import QRCode from 'react-qr-code' import QRCode from 'react-qr-code'
import Loading from './Loading' import Loading from './Loading'
import { useUploaderChannelRenewal } from '../hooks/useUploaderChannelRenewal'
import StopButton from './StopButton' import StopButton from './StopButton'
import { useUploaderChannel } from '../hooks/useUploaderChannel' import { useUploaderChannel } from '../hooks/useUploaderChannel'
import { useUploaderConnections } from '../hooks/useUploaderConnections' import { useUploaderConnections } from '../hooks/useUploaderConnections'
@ -17,20 +16,19 @@ const QR_CODE_SIZE = 128
export default function Uploader({ export default function Uploader({
files, files,
password, password,
renewInterval = 5000,
onStop, onStop,
}: { }: {
files: UploadedFile[] files: UploadedFile[]
password: string password: string
renewInterval?: number
onStop: () => void onStop: () => void
}): JSX.Element { }): JSX.Element {
const peer = useWebRTC() const peer = useWebRTC()
const { longSlug, shortSlug, longURL, shortURL } = useUploaderChannel(peer.id) const uploadID = useRef(crypto.randomUUID())
useUploaderChannelRenewal(shortSlug, renewInterval) const { isLoading, error, longSlug, shortSlug, longURL, shortURL } =
useUploaderChannel(uploadID.current)
const connections = useUploaderConnections(peer, files, password) const connections = useUploaderConnections(peer, files, password)
if (!longSlug || !shortSlug) { if (isLoading || !longSlug || !shortSlug) {
return <Loading text="Creating channel" /> return <Loading text="Creating channel" />
} }

@ -1,20 +1,24 @@
'use client' 'use client'
import React, { useState, useEffect, useRef, useContext } from 'react' import React, { useState, useEffect, useContext } from 'react'
import type { default as PeerType } from 'peerjs'
import Loading from './Loading' import Loading from './Loading'
// eslint-disable-next-line @typescript-eslint/no-var-requires export type WebRTCValue = {
const Peer = typeof window !== 'undefined' ? require('peerjs').default : null createOffer: () => Promise<RTCSessionDescriptionInit>
createAnswer: (
export type WebRTCValue = PeerType offer: RTCSessionDescriptionInit,
) => Promise<RTCSessionDescriptionInit>
setRemoteDescription: (
description: RTCSessionDescriptionInit,
) => Promise<void>
addIceCandidate: (candidate: RTCIceCandidateInit) => Promise<void>
onIceCandidate: (handler: (candidate: RTCIceCandidate | null) => void) => void
onDataChannel: (handler: (channel: RTCDataChannel) => void) => void
createDataChannel: (label: string) => RTCDataChannel
}
const ICE_SERVERS: RTCConfiguration = { const ICE_SERVERS: RTCConfiguration = {
iceServers: [ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
{
urls: 'stun:stun.l.google.com:19302',
},
],
} }
const WebRTCContext = React.createContext<WebRTCValue | null>(null) const WebRTCContext = React.createContext<WebRTCValue | null>(null)
@ -24,7 +28,6 @@ export const useWebRTC = (): WebRTCValue => {
if (value === null) { if (value === null) {
throw new Error('useWebRTC must be used within a WebRTCProvider') throw new Error('useWebRTC must be used within a WebRTCProvider')
} }
return value return value
} }
@ -35,43 +38,50 @@ export function WebRTCProvider({
servers?: RTCConfiguration servers?: RTCConfiguration
children?: React.ReactNode children?: React.ReactNode
}): JSX.Element { }): JSX.Element {
const [peerConnection, setPeerConnection] =
useState<RTCPeerConnection | null>(null)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const peer = useRef<WebRTCValue | null>(null)
useEffect(() => { useEffect(() => {
const effect = async () => { const pc = new RTCPeerConnection(servers)
const peerConfig: { setPeerConnection(pc)
host?: string setLoaded(true)
port?: string
path?: string
config: RTCConfiguration
} = {
config: servers,
}
if (process.env.NEXT_PUBLIC_PEERJS_HOST) { return () => {
peerConfig.host = process.env.NEXT_PUBLIC_PEERJS_HOST pc.close()
peerConfig.port = process.env.NEXT_PUBLIC_PEERJS_PORT || '9000'
peerConfig.path = process.env.NEXT_PUBLIC_PEERJS_PATH || '/'
}
const peerObj = new Peer(undefined, peerConfig)
peerObj.on('open', () => {
peer.current = peerObj
setLoaded(true)
})
} }
effect()
}, [servers]) }, [servers])
if (!loaded || !peer.current) { if (!loaded || !peerConnection) {
return <Loading text="Initializing WebRTC connection..." /> return <Loading text="Initializing WebRTC connection..." />
} }
const webRTCValue: WebRTCValue = {
createOffer: async () => {
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
return offer
},
createAnswer: async (offer) => {
await peerConnection.setRemoteDescription(offer)
const answer = await peerConnection.createAnswer()
await peerConnection.setLocalDescription(answer)
return answer
},
setRemoteDescription: (description) =>
peerConnection.setRemoteDescription(description),
addIceCandidate: (candidate) => peerConnection.addIceCandidate(candidate),
onIceCandidate: (handler) => {
peerConnection.onicecandidate = (event) => handler(event.candidate)
},
onDataChannel: (handler) => {
peerConnection.ondatachannel = (event) => handler(event.channel)
},
createDataChannel: (label) => peerConnection.createDataChannel(label),
}
return ( return (
<WebRTCContext.Provider value={peer.current}> <WebRTCContext.Provider value={webRTCValue}>
{children} {children}
</WebRTCContext.Provider> </WebRTCContext.Provider>
) )

@ -15,7 +15,7 @@ import {
mobileVendor, mobileVendor,
mobileModel, mobileModel,
} from 'react-device-detect' } from 'react-device-detect'
import { useQuery } from '@tanstack/react-query'
const cleanErrorMessage = (errorMessage: string): string => const cleanErrorMessage = (errorMessage: string): string =>
errorMessage.startsWith('Could not connect to peer') errorMessage.startsWith('Could not connect to peer')
? 'Could not connect to the uploader. Did they close their browser?' ? 'Could not connect to the uploader. Did they close their browser?'
@ -23,7 +23,7 @@ const cleanErrorMessage = (errorMessage: string): string =>
const getZipFilename = (): string => `filepizza-download-${Date.now()}.zip` const getZipFilename = (): string => `filepizza-download-${Date.now()}.zip`
export function useDownloader(uploaderPeerID: string): { export function useDownloader(slug: string): {
filesInfo: Array<{ fileName: string; size: number; type: string }> | null filesInfo: Array<{ fileName: string; size: number; type: string }> | null
isConnected: boolean isConnected: boolean
isPasswordRequired: boolean isPasswordRequired: boolean
@ -55,9 +55,35 @@ export function useDownloader(uploaderPeerID: string): {
const [bytesDownloaded, setBytesDownloaded] = useState(0) const [bytesDownloaded, setBytesDownloaded] = useState(0)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const {
isLoading: offerLoading,
error: offerError,
data: offerData,
} = useQuery({
queryKey: ['offer', slug],
queryFn: async () => {
const offer = await peer.createOffer()
const response = await fetch('/api/offer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, offer }),
})
if (!response.ok) {
throw new Error('Could not offer connection to uploader')
}
const data = await response.json()
return data.success
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
})
useEffect(() => { useEffect(() => {
const conn = peer.connect(uploaderPeerID, { reliable: true }) return
setDataConnection(conn) // const conn = peer.connect(slug, { reliable: true })
// setDataConnection(conn)
const handleOpen = () => { const handleOpen = () => {
setIsConnected(true) setIsConnected(true)
@ -124,7 +150,7 @@ export function useDownloader(uploaderPeerID: string): {
conn.off('close', handleClose) conn.off('close', handleClose)
peer.off('error', handleError) peer.off('error', handleError)
} }
}, [peer, uploaderPeerID]) }, [peer])
const submitPassword = useCallback( const submitPassword = useCallback(
(pass: string) => { (pass: string) => {

@ -1,4 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useWebRTC } from '../components/WebRTCProvider'
function generateURL(slug: string): string { function generateURL(slug: string): string {
const hostPrefix = const hostPrefix =
@ -11,21 +13,24 @@ function generateURL(slug: string): string {
return `${hostPrefix}/download/${slug}` return `${hostPrefix}/download/${slug}`
} }
export function useUploaderChannel(uploaderPeerID: string): { export function useUploaderChannel(
loading: boolean uploadID: string,
renewInterval = 5000,
): {
isLoading: boolean
error: Error | null error: Error | null
longSlug: string | undefined longSlug: string | undefined
shortSlug: string | undefined shortSlug: string | undefined
longURL: string | undefined longURL: string | undefined
shortURL: string | undefined shortURL: string | undefined
} { } {
const peer = useWebRTC()
const { isLoading, error, data } = useQuery({ const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID], queryKey: ['uploaderChannel', uploadID],
queryFn: async () => { queryFn: async () => {
const response = await fetch('/api/create', { const response = await fetch('/api/create', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploaderPeerID }),
}) })
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok') throw new Error('Network response was not ok')
@ -38,14 +43,67 @@ export function useUploaderChannel(uploaderPeerID: string): {
staleTime: Infinity, staleTime: Infinity,
}) })
const longURL = data?.longSlug ? generateURL(data.longSlug) : undefined const secret = data?.secret
const shortURL = data?.shortSlug ? generateURL(data.shortSlug) : undefined const longSlug = data?.longSlug
const shortSlug = data?.shortSlug
const longURL = longSlug ? generateURL(longSlug) : undefined
const shortURL = shortSlug ? generateURL(shortSlug) : undefined
const renewMutation = useMutation({
mutationFn: async ({ secret: s }: { secret: string }) => {
const response = await fetch('/api/renew', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: shortSlug, secret: s }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
// TODO(@kern): add a way to post an answer back to the client
useEffect(() => {
if (!secret || !shortSlug) return
let timeout: NodeJS.Timeout | null = null
const run = (): void => {
timeout = setTimeout(() => {
renewMutation.mutate(
{ secret },
{
onSuccess: (d) => {
d.offers.forEach(async (offer) => {
try {
const answer = await peer.createAnswer(offer)
console.log('Created answer:', answer)
// TODO: Send this answer back to the client
} catch (error) {
console.error('Error creating answer:', error)
}
})
},
},
)
run()
}, renewInterval)
}
run()
return () => {
if (timeout) clearTimeout(timeout)
}
}, [secret, shortSlug, renewMutation, renewInterval])
return { return {
loading: isLoading, isLoading,
error: error as Error | null, error,
longSlug: data?.longSlug, longSlug,
shortSlug: data?.shortSlug, shortSlug,
longURL, longURL,
shortURL, shortURL,
} }

@ -1,40 +0,0 @@
import { useEffect } from 'react'
import { useMutation } from '@tanstack/react-query'
export function useUploaderChannelRenewal(
shortSlug: string | undefined,
renewInterval = 5000,
): void {
const mutation = useMutation({
mutationFn: async () => {
const response = await fetch('/api/renew', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: shortSlug }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
useEffect(() => {
if (!shortSlug) return
let timeout: NodeJS.Timeout | null = null
const run = (): void => {
timeout = setTimeout(() => {
mutation.mutate()
run()
}, renewInterval)
}
run()
return () => {
if (timeout) clearTimeout(timeout)
}
}, [shortSlug, mutation, renewInterval])
}

@ -286,10 +286,10 @@ export function useUploaderConnections(
}) })
} }
peer.on('connection', listener) // peer.on('connection', listener)
return () => { return () => {
peer.off('connection') // peer.off('connection')
} }
}, [peer, files, password]) }, [peer, files, password])

Loading…
Cancel
Save