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
MAINTAINER Alexander Kern <filepizza@kern.io>
FROM node:lts-alpine
RUN apk add --no-cache pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . ./
RUN npm install && npm run build
RUN pnpm build
ENV NODE_ENV production
EXPOSE 80

@ -10,7 +10,10 @@
"build": "next build",
"start": "next start",
"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": {
"type": "git",
@ -37,7 +40,6 @@
"react-qr-code": "^2.0.15",
"streamsaver": "^2.0.6",
"tailwindcss": "^3.4.10",
"twilio": "^2.11.1",
"web-streams-polyfill": "^3.3.3",
"webrtcsupport": "^2.2.0",
"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'
export async function POST(request: NextRequest): Promise<NextResponse> {
const { uploaderPeerID } = await request.json()
if (!uploaderPeerID) {
return NextResponse.json(
{ error: 'uploaderPeerID is required' },
{ status: 400 },
)
}
const channel: Channel = await channelRepo.create(uploaderPeerID)
export async function POST(): Promise<NextResponse> {
const channel: Channel = await channelRepo.create()
return NextResponse.json(channel)
}

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

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

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

@ -2,18 +2,30 @@ import 'server-only'
import config from './config'
import Redis from 'ioredis'
import { generateShortSlug, generateLongSlug } from './slugs'
import crypto from 'crypto'
import { z } from 'zod'
export type Channel = {
uploaderPeerID: string
secret?: string
longSlug: string
shortSlug: string
}
const ChannelSchema = z.object({
secret: z.string().optional(),
longSlug: z.string(),
shortSlug: z.string(),
})
export interface ChannelRepo {
create(uploaderPeerID: string, ttl?: number): Promise<Channel>
create(ttl?: number): Promise<Channel>
fetch(slug: string): Promise<Channel | null>
renew(slug: string, ttl: number): Promise<void>
destroy(slug: string): Promise<void>
renew(
slug: string,
secret: string,
ttl: number,
): Promise<RTCSessionDescriptionInit[]>
destroy(slug: string, secret: string): Promise<void>
}
export class RedisChannelRepo implements ChannelRepo {
@ -23,15 +35,12 @@ export class RedisChannelRepo implements ChannelRepo {
this.client = new Redis(redisURL)
}
async create(
uploaderPeerID: string,
ttl: number = config.channel.ttl,
): Promise<Channel> {
async create(ttl: number = config.channel.ttl): Promise<Channel> {
const shortSlug = await this.generateShortSlug()
const longSlug = await this.generateLongSlug()
const channel: Channel = {
uploaderPeerID,
secret: crypto.randomUUID(),
longSlug,
shortSlug,
}
@ -43,38 +52,70 @@ export class RedisChannelRepo implements ChannelRepo {
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))
if (shortChannelStr) {
return this.deserializeChannel(shortChannelStr)
return this.deserializeChannel(shortChannelStr, scrubSecret)
}
const longChannelStr = await this.client.get(this.getLongSlugKey(slug))
if (longChannelStr) {
return this.deserializeChannel(longChannelStr)
return this.deserializeChannel(longChannelStr, scrubSecret)
}
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)
if (!channel) {
return
if (!channel || channel.secret !== secret) {
return []
}
await this.client.expire(this.getShortSlugKey(channel.shortSlug), 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)
if (!channel) {
return
}
await this.client.del(channel.longSlug)
await this.client.del(channel.shortSlug)
const offerKey = this.getOfferKey(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> {
@ -109,12 +150,22 @@ export class RedisChannelRepo implements ChannelRepo {
return `long:${longSlug}`
}
private getOfferKey(shortSlug: string): string {
return `offers:${shortSlug}`
}
private serializeChannel(channel: Channel): string {
return JSON.stringify(channel)
}
private deserializeChannel(str: string): Channel {
return JSON.parse(str) as Channel
private deserializeChannel(str: string, scrubSecret = false): 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({
uploaderPeerID,
}: {
uploaderPeerID: string
}): JSX.Element {
export default function Downloader({ slug }: { slug: string }): JSX.Element {
const {
filesInfo,
isConnected,
@ -160,7 +156,7 @@ export default function Downloader({
stopDownload,
totalSize,
bytesDownloaded,
} = useDownloader(uploaderPeerID)
} = useDownloader(slug)
if (isDone && filesInfo) {
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'
import React from 'react'
import React, { useRef } from 'react'
import { UploadedFile, UploaderConnectionStatus } from '../types'
import { useWebRTC } from './WebRTCProvider'
import QRCode from 'react-qr-code'
import Loading from './Loading'
import { useUploaderChannelRenewal } from '../hooks/useUploaderChannelRenewal'
import StopButton from './StopButton'
import { useUploaderChannel } from '../hooks/useUploaderChannel'
import { useUploaderConnections } from '../hooks/useUploaderConnections'
@ -17,20 +16,19 @@ const QR_CODE_SIZE = 128
export default function Uploader({
files,
password,
renewInterval = 5000,
onStop,
}: {
files: UploadedFile[]
password: string
renewInterval?: number
onStop: () => void
}): JSX.Element {
const peer = useWebRTC()
const { longSlug, shortSlug, longURL, shortURL } = useUploaderChannel(peer.id)
useUploaderChannelRenewal(shortSlug, renewInterval)
const uploadID = useRef(crypto.randomUUID())
const { isLoading, error, longSlug, shortSlug, longURL, shortURL } =
useUploaderChannel(uploadID.current)
const connections = useUploaderConnections(peer, files, password)
if (!longSlug || !shortSlug) {
if (isLoading || !longSlug || !shortSlug) {
return <Loading text="Creating channel" />
}

@ -1,20 +1,24 @@
'use client'
import React, { useState, useEffect, useRef, useContext } from 'react'
import type { default as PeerType } from 'peerjs'
import React, { useState, useEffect, useContext } from 'react'
import Loading from './Loading'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Peer = typeof window !== 'undefined' ? require('peerjs').default : null
export type WebRTCValue = PeerType
export type WebRTCValue = {
createOffer: () => Promise<RTCSessionDescriptionInit>
createAnswer: (
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 = {
iceServers: [
{
urls: 'stun:stun.l.google.com:19302',
},
],
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}
const WebRTCContext = React.createContext<WebRTCValue | null>(null)
@ -24,7 +28,6 @@ export const useWebRTC = (): WebRTCValue => {
if (value === null) {
throw new Error('useWebRTC must be used within a WebRTCProvider')
}
return value
}
@ -35,43 +38,50 @@ export function WebRTCProvider({
servers?: RTCConfiguration
children?: React.ReactNode
}): JSX.Element {
const [peerConnection, setPeerConnection] =
useState<RTCPeerConnection | null>(null)
const [loaded, setLoaded] = useState(false)
const peer = useRef<WebRTCValue | null>(null)
useEffect(() => {
const effect = async () => {
const peerConfig: {
host?: string
port?: string
path?: string
config: RTCConfiguration
} = {
config: servers,
}
const pc = new RTCPeerConnection(servers)
setPeerConnection(pc)
setLoaded(true)
if (process.env.NEXT_PUBLIC_PEERJS_HOST) {
peerConfig.host = process.env.NEXT_PUBLIC_PEERJS_HOST
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)
})
return () => {
pc.close()
}
effect()
}, [servers])
if (!loaded || !peer.current) {
if (!loaded || !peerConnection) {
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 (
<WebRTCContext.Provider value={peer.current}>
<WebRTCContext.Provider value={webRTCValue}>
{children}
</WebRTCContext.Provider>
)

@ -15,7 +15,7 @@ import {
mobileVendor,
mobileModel,
} from 'react-device-detect'
import { useQuery } from '@tanstack/react-query'
const cleanErrorMessage = (errorMessage: string): string =>
errorMessage.startsWith('Could not connect to peer')
? '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`
export function useDownloader(uploaderPeerID: string): {
export function useDownloader(slug: string): {
filesInfo: Array<{ fileName: string; size: number; type: string }> | null
isConnected: boolean
isPasswordRequired: boolean
@ -55,9 +55,35 @@ export function useDownloader(uploaderPeerID: string): {
const [bytesDownloaded, setBytesDownloaded] = useState(0)
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(() => {
const conn = peer.connect(uploaderPeerID, { reliable: true })
setDataConnection(conn)
return
// const conn = peer.connect(slug, { reliable: true })
// setDataConnection(conn)
const handleOpen = () => {
setIsConnected(true)
@ -124,7 +150,7 @@ export function useDownloader(uploaderPeerID: string): {
conn.off('close', handleClose)
peer.off('error', handleError)
}
}, [peer, uploaderPeerID])
}, [peer])
const submitPassword = useCallback(
(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 {
const hostPrefix =
@ -11,21 +13,24 @@ function generateURL(slug: string): string {
return `${hostPrefix}/download/${slug}`
}
export function useUploaderChannel(uploaderPeerID: string): {
loading: boolean
export function useUploaderChannel(
uploadID: string,
renewInterval = 5000,
): {
isLoading: boolean
error: Error | null
longSlug: string | undefined
shortSlug: string | undefined
longURL: string | undefined
shortURL: string | undefined
} {
const peer = useWebRTC()
const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID],
queryKey: ['uploaderChannel', uploadID],
queryFn: async () => {
const response = await fetch('/api/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploaderPeerID }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
@ -38,14 +43,67 @@ export function useUploaderChannel(uploaderPeerID: string): {
staleTime: Infinity,
})
const longURL = data?.longSlug ? generateURL(data.longSlug) : undefined
const shortURL = data?.shortSlug ? generateURL(data.shortSlug) : undefined
const secret = data?.secret
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 {
loading: isLoading,
error: error as Error | null,
longSlug: data?.longSlug,
shortSlug: data?.shortSlug,
isLoading,
error,
longSlug,
shortSlug,
longURL,
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 () => {
peer.off('connection')
// peer.off('connection')
}
}, [peer, files, password])

Loading…
Cancel
Save