pull/134/head
Alex Kern 12 months ago
parent a0d7d64442
commit 8ac4373157
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -2,18 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
const { slug, secret } = await request.json()
const { slug } = 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 })
}
// Anyone can destroy a channel if they know the slug. This enables a terms violation reporter to destroy the channel after they report it.
try {
await channelRepo.destroyChannel(slug, secret)
await channelRepo.destroyChannel(slug)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
return NextResponse.json(

@ -3,7 +3,8 @@ import { channelRepo } from '../../../channel'
import Spinner from '../../../components/Spinner'
import Wordmark from '../../../components/Wordmark'
import Downloader from '../../../components/Downloader'
import WebRTCProvider from '../../../components/WebRTCProvider'
import WebRTCPeerProvider from '../../../components/WebRTCProvider'
import ReportTermsViolationButton from '../../../components/ReportTermsViolationButton'
const normalizeSlug = (rawSlug: string | string[]): string => {
if (typeof rawSlug === 'string') {
@ -29,9 +30,10 @@ export default async function DownloadPage({
<div className="flex flex-col items-center space-y-5 py-10 max-w-2xl mx-auto">
<Spinner direction="down" />
<Wordmark />
<WebRTCProvider>
<WebRTCPeerProvider>
<Downloader uploaderPeerID={channel.uploaderPeerID} />
</WebRTCProvider>
<ReportTermsViolationButton uploaderPeerID={channel.uploaderPeerID} slug={slug} />
</WebRTCPeerProvider>
</div>
)
}

@ -4,12 +4,12 @@ import '../styles.css'
import { ThemeProvider } from '../components/ThemeProvider'
import { ModeToggle } from '../components/ModeToggle'
import FilePizzaQueryClientProvider from '../components/QueryClientProvider'
import { Viewport } from 'next'
export const metadata = {
title: 'FilePizza • Your files, delivered.',
description: 'Peer-to-peer file transfers in your web browser.',
charSet: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
openGraph: {
url: 'https://file.pizza',
title: 'FilePizza • Your files, delivered.',
@ -18,6 +18,13 @@ export const metadata = {
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false
}
export default function RootLayout({
children,
}: {

@ -1,7 +1,7 @@
'use client'
import React, { useCallback, useState } from 'react'
import WebRTCProvider from '../components/WebRTCProvider'
import WebRTCPeerProvider from '../components/WebRTCProvider'
import DropZone from '../components/DropZone'
import UploadFileList from '../components/UploadFileList'
import Uploader from '../components/Uploader'
@ -14,8 +14,8 @@ import CancelButton from '../components/CancelButton'
import { useMemo } from 'react'
import { getFileName } from '../fs'
import TitleText from '../components/TitleText'
import SubtitleText from '../components/SubtitleText'
import { pluralize } from '../utils/pluralize'
import TermsAcceptance from '../components/TermsAcceptance'
function PageWrapper({
children,
@ -42,11 +42,9 @@ function InitialState({
<PageWrapper>
<div className="flex flex-col items-center space-y-1 max-w-md">
<TitleText>Peer-to-peer file transfers in your browser.</TitleText>
<SubtitleText>
We never store anything. Files only served fresh.
</SubtitleText>
</div>
<DropZone onDrop={onDrop} />
<TermsAcceptance />
</PageWrapper>
)
}
@ -108,9 +106,9 @@ function UploadingState({
You are uploading {pluralize(uploadedFiles.length, 'file', 'files')}.
</TitleText>
<UploadFileList files={fileListData} />
<WebRTCProvider>
<WebRTCPeerProvider>
<Uploader files={uploadedFiles} password={password} onStop={onStop} />
</WebRTCProvider>
</WebRTCPeerProvider>
</PageWrapper>
)
}

@ -0,0 +1,28 @@
import Spinner from '../../components/Spinner'
import Wordmark from '../../components/Wordmark'
import TitleText from '../../components/TitleText'
import ReturnHome from '../../components/ReturnHome'
export default function ReportedPage(): JSX.Element {
return (
<div className="flex flex-col items-center space-y-5 py-10 max-w-md mx-auto">
<Spinner direction="down" />
<Wordmark />
<TitleText>This delivery has been halted.</TitleText>
<div className="px-8 py-6 bg-stone-100 dark:bg-stone-800 rounded-lg border border-stone-200 dark:border-stone-700">
<h3 className="text-lg font-medium text-stone-800 dark:text-stone-200 mb-4">Message from the management</h3>
<p className="text-sm text-stone-600 dark:text-stone-300 leading-relaxed mb-6">
Just like a pizza with questionable toppings, we've had to put this delivery on hold for potential violations of our terms of service.
Our delivery quality team is looking into it to ensure we maintain our high standards.
</p>
<div className="text-sm text-stone-500 dark:text-stone-400 italic">
- The FilePizza Team
</div>
</div>
<ReturnHome />
</div>
)
}

@ -87,9 +87,9 @@ export class RedisChannelRepo implements ChannelRepo {
return true
}
async destroyChannel(slug: string, secret: string): Promise<void> {
async destroyChannel(slug: string): Promise<void> {
const channel = await this.fetchChannel(slug)
if (!channel || channel.secret !== secret) {
if (!channel) {
return
}

@ -1,6 +1,6 @@
'use client'
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useEffect } from 'react'
import { useDownloader } from '../hooks/useDownloader'
import PasswordField from './PasswordField'
import UnlockButton from './UnlockButton'
@ -20,6 +20,68 @@ interface FileInfo {
type: string
}
export function ConnectingToUploader({ showTroubleshootingAfter = 3000 }: { showTroubleshootingAfter: number }): JSX.Element {
const [showTroubleshooting, setShowTroubleshooting] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setShowTroubleshooting(true)
}, showTroubleshootingAfter)
return () => clearTimeout(timer)
}, [showTroubleshootingAfter])
if (!showTroubleshooting) {
return <Loading text="Connecting to uploader..." />
}
return (
<>
<Loading text="Connecting to uploader..." />
<div
className="bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-700 rounded-lg p-8 max-w-md w-full"
>
<h2
className="text-xl font-bold mb-4 text-stone-900 dark:text-stone-50"
>
Having trouble connecting?
</h2>
<div className="space-y-4 text-stone-700 dark:text-stone-300">
<p>FilePizza uses direct peer-to-peer connections, but sometimes the connection can get stuck. Here are some possible reasons this can happen:</p>
<ul className="list-none space-y-3">
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">🚪</span>
<span className="text-sm">The uploader may have closed their browser. FilePizza requires the uploader to stay online continuously because files are transferred directly between b.</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">🔒</span>
<span className="text-sm">Your network might have strict firewalls or NAT settings, such as having UPnP disabled</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">🌐</span>
<span className="text-sm">Some corporate or school networks block peer-to-peer connections</span>
</li>
</ul>
<p className="text-sm text-stone-500 dark:text-stone-400 italic">
Note: FilePizza is designed for direct transfers between known parties and doesn't use{" "}
<a
href="https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
TURN
</a> relay servers. This means it may not work on all networks.
</p>
</div>
</div>
</>
)
}
export function DownloadComplete({
filesInfo,
bytesDownloaded,
@ -167,7 +229,7 @@ export default function Downloader({
}
if (!isConnected) {
return <Loading text="Connecting to uploader..." />
return <ConnectingToUploader />
}
if (isDownloading && filesInfo) {

@ -36,7 +36,7 @@ export function Footer(): JSX.Element {
<div className="flex flex-col items-center space-y-1 px-4 sm:px-6 md:px-8">
<div className="flex items-center space-x-2">
<p className="text-stone-600 dark:text-stone-400">
<strong>Like FilePizza?</strong> Support its development!{' '}
<strong>Like FilePizza v2?</strong> Support its development!{' '}
</p>
<button
className="px-1.5 py-0.5 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors duration-200 font-medium text-[10px]"

@ -0,0 +1,128 @@
'use client'
import { useWebRTCPeer } from './WebRTCProvider'
import { useCallback, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
export default function ReportTermsViolationButton({ uploaderPeerID, slug }: { uploaderPeerID: string, slug: string }): JSX.Element {
const { peer } = useWebRTCPeer()
const [showModal, setShowModal] = useState(false)
const [isReporting, setIsReporting] = useState(false)
const reportMutation = useMutation({
mutationFn: async () => {
const response = await fetch(`/api/destroy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug }),
})
if (!response.ok) {
throw new Error('Failed to report violation')
}
return response.json()
}
})
const handleReport = useCallback(() => {
try {
// Destroy the channel so no further downloads can be made.
setIsReporting(true)
reportMutation.mutate()
// Send a report message to the uploader to hard-redirect them to the reported page.
// The uploader will broadcast a report message to all connections, which will hard-redirect all downloaders to the reported page.
const conn = peer.connect(uploaderPeerID, { metadata: { type: 'report' } })
// Set a timeout to redirect after 2 seconds even if connection doesn't open
const timeout = setTimeout(() => {
conn.close()
window.location.href = '/reported'
}, 2000)
conn.on('open', () => {
clearTimeout(timeout)
conn.close()
window.location.href = '/reported'
})
} catch (error) {
console.error('Failed to report violation', error)
setIsReporting(false)
}
}, [peer, uploaderPeerID])
return (
<>
<div className="flex justify-center">
<button
onClick={() => setShowModal(true)}
className="text-sm text-red-600 dark:text-red-400 hover:underline transition-colors duration-200"
aria-label="Report terms violation"
>
Report suspicious pizza delivery
</button>
</div>
{showModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={() => setShowModal(false)}
>
<div
className="bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-700 rounded-lg p-8 max-w-md w-full shadow-lg"
onClick={e => e.stopPropagation()}
>
<h2
id="modal-title"
className="text-xl font-bold mb-4 text-stone-900 dark:text-stone-50"
>
Found a suspicious delivery?
</h2>
<div className="space-y-4 text-stone-700 dark:text-stone-300">
<p>Before reporting this delivery, please note our FilePizza terms:</p>
<ul className="list-none space-y-3">
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base"></span>
<span className="text-sm">Only upload files you have the right to share</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">🔒</span>
<span className="text-sm">Share download links only with known recipients</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base"></span>
<span className="text-sm">No illegal or harmful content allowed</span>
</li>
</ul>
<p>If you've spotted a violation of these terms, click Report to halt its delivery.</p>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
disabled={isReporting}
onClick={() => setShowModal(false)}
className="px-4 py-2 text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200 transition-colors"
aria-label="Cancel report"
>
Cancel
</button>
<button
disabled={isReporting}
onClick={handleReport}
className={`px-4 py-2 bg-gradient-to-b from-red-500 to-red-600 text-white rounded-md border border-red-600 shadow-sm text-shadow disabled:opacity-50 disabled:cursor-not-allowed enabled:hover:from-red-500 enabled:hover:to-red-700 enabled:hover:shadow-md transition-all duration-200`}
aria-label="Confirm report"
>
{isReporting ? 'Reporting...' : 'Report'}
</button>
</div>
</div>
</div>
)}
</>
)
}

@ -1,15 +0,0 @@
import React from 'react'
interface SubtitleTextProps {
children: React.ReactNode
}
const SubtitleText: React.FC<SubtitleTextProps> = ({ children }) => {
return (
<p className="text-sm text-center text-stone-600 dark:text-stone-400">
{children}
</p>
)
}
export default SubtitleText

@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
export default function TermsAcceptance(): JSX.Element {
const [showModal, setShowModal] = useState(false)
return (
<>
<div className="flex justify-center">
<span className="text-xs text-stone-600 dark:text-stone-400">
By selecting a file, you agree to{' '}
<button
onClick={() => setShowModal(true)}
className="underline hover:text-stone-900 dark:hover:text-stone-200 transition-colors duration-200"
aria-label="View upload terms"
>
our terms
</button>.
</span>
</div>
{showModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={() => setShowModal(false)}
>
<div
className="bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-700 rounded-lg p-8 max-w-md w-full shadow-lg"
onClick={e => e.stopPropagation()}
>
<h2
id="modal-title"
className="text-xl font-bold mb-4 text-stone-900 dark:text-stone-50"
>
FilePizza Terms
</h2>
<div className="space-y-4 text-stone-700 dark:text-stone-300">
<ul className="list-none space-y-3">
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base"></span>
<span className="text-sm">Only upload files you have the right to share</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">🔒</span>
<span className="text-sm">Share download links only with known recipients</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base"></span>
<span className="text-sm">No illegal or harmful content allowed</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">📤</span>
<span className="text-sm">Files are shared directly between browsers - no server storage</span>
</li>
</ul>
<p className="text-sm italic">By uploading a file, you confirm that you understand and agree to these terms.</p>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 bg-stone-200 dark:bg-stone-700 text-stone-700 dark:text-stone-300 rounded-md hover:bg-stone-300 dark:hover:bg-stone-600 transition-all duration-200"
aria-label="Close terms"
>
Got it!
</button>
</div>
</div>
</div>
)}
</>
)
}

@ -1,8 +1,8 @@
'use client'
import React from 'react'
import React, { useCallback } from 'react'
import { UploadedFile, UploaderConnectionStatus } from '../types'
import { useWebRTC } from './WebRTCProvider'
import { useWebRTCPeer } from './WebRTCProvider'
import QRCode from 'react-qr-code'
import Loading from './Loading'
import StopButton from './StopButton'
@ -23,13 +23,18 @@ export default function Uploader({
password: string
onStop: () => void
}): JSX.Element {
const peer = useWebRTC()
const { peer, stop } = useWebRTCPeer()
const { isLoading, error, longSlug, shortSlug, longURL, shortURL } =
useUploaderChannel(peer.id)
const connections = useUploaderConnections(peer, files, password)
const handleStop = useCallback(() => {
stop()
onStop()
}, [stop, onStop])
if (isLoading || !longSlug || !shortSlug) {
return <Loading text="Creating channel" />
return <Loading text="Creating channel..." />
}
const activeDownloaders = connections.filter(
@ -56,7 +61,7 @@ export default function Uploader({
<h2 className="text-lg font-semibold text-stone-400 dark:text-stone-200">
{activeDownloaders} Downloading, {connections.length} Total
</h2>
<StopButton onClick={onStop} />
<StopButton onClick={handleStop} />
</div>
{connections.map((conn, i) => (
<ConnectionListItem key={i} conn={conn} />

@ -1,6 +1,6 @@
'use client'
import React, { useState, useEffect, useContext } from 'react'
import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'
import Loading from './Loading'
import Peer from 'peerjs'
@ -13,11 +13,14 @@ const ICE_SERVERS: RTCConfiguration = {
],
}
export type WebRTCValue = Peer
export type WebRTCPeerValue = {
peer: Peer
stop: () => void
}
const WebRTCContext = React.createContext<WebRTCValue | null>(null)
const WebRTCContext = React.createContext<WebRTCPeerValue | null>(null)
export const useWebRTC = (): WebRTCValue => {
export const useWebRTCPeer = (): WebRTCPeerValue => {
const value = useContext(WebRTCContext)
if (value === null) {
throw new Error('useWebRTC must be used within a WebRTCProvider')
@ -27,7 +30,7 @@ export const useWebRTC = (): WebRTCValue => {
let globalPeer: Peer | null = null
async function getPeer(): Promise<Peer> {
async function getOrCreateGlobalPeer(): Promise<Peer> {
if (!globalPeer) {
globalPeer = new Peer({
config: ICE_SERVERS,
@ -39,42 +42,54 @@ async function getPeer(): Promise<Peer> {
}
await new Promise<void>((resolve) => {
globalPeer?.on('open', (id) => {
const listener = (id: string) => {
console.log('[WebRTCProvider] Peer ID:', id)
globalPeer?.off('open', listener)
resolve()
})
}
globalPeer?.on('open', listener)
})
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return globalPeer!
}
export function WebRTCProvider({
export default function WebRTCPeerProvider({
children,
}: {
children?: React.ReactNode
}): JSX.Element {
const [peerConnection, setPeerConnection] = useState<Peer | null>(globalPeer)
const [peerValue, setPeerValue] = useState<Peer | null>(globalPeer)
const [isStopped, setIsStopped] = useState(false)
const stop = useCallback(() => {
globalPeer?.destroy()
globalPeer = null
setPeerValue(null)
setIsStopped(true)
}, [])
useEffect(() => {
getPeer().then((pc) => {
setPeerConnection(pc)
})
getOrCreateGlobalPeer().then(setPeerValue)
}, [])
return () => {
setPeerConnection(null)
const value = useMemo(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
() => ({ peer: peerValue!, stop }),
[peerValue, stop]
)
if (isStopped) {
return <></>
}
}, [])
if (!peerConnection) {
return <Loading text="Initializing WebRTC connection..." />
if (!peerValue) {
return <Loading text="Initializing WebRTC peer..." />
}
return (
<WebRTCContext.Provider value={peerConnection}>
<WebRTCContext.Provider value={value}>
{children}
</WebRTCContext.Provider>
)
}
export default WebRTCProvider

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useWebRTC } from '../components/WebRTCProvider'
import { useWebRTCPeer } from '../components/WebRTCProvider'
import { z } from 'zod'
import { ChunkMessage, decodeMessage, Message, MessageType } from '../messages'
import { DataConnection } from 'peerjs'
@ -35,7 +35,7 @@ export function useDownloader(uploaderPeerID: string): {
totalSize: number
bytesDownloaded: number
} {
const peer = useWebRTC()
const { peer } = useWebRTCPeer()
const [dataConnection, setDataConnection] = useState<DataConnection | null>(
null,
)
@ -56,6 +56,7 @@ export function useDownloader(uploaderPeerID: string): {
useEffect(() => {
if (!peer) return
console.log('[Downloader] connecting to uploader', uploaderPeerID)
const conn = peer.connect(uploaderPeerID, { reliable: true })
setDataConnection(conn)
@ -91,6 +92,10 @@ export function useDownloader(uploaderPeerID: string): {
setErrorMessage(message.error)
conn.close()
break
case MessageType.Report:
// Hard-redirect downloader to reported page
window.location.href = '/reported'
break
}
} catch (err) {
console.error(err)

@ -62,26 +62,6 @@ export function useUploaderChannel(
},
})
const answerMutation = useMutation({
mutationFn: async ({
offerID,
answer,
}: {
offerID: string
answer: RTCSessionDescriptionInit
}) => {
const response = await fetch('/api/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: shortSlug, offerID, answer }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
useEffect(() => {
if (!secret || !shortSlug) return
@ -99,7 +79,7 @@ export function useUploaderChannel(
return () => {
if (timeout) clearTimeout(timeout)
}
}, [secret, shortSlug, renewMutation, answerMutation, renewInterval])
}, [secret, shortSlug, renewMutation, renewInterval])
return {
isLoading,

@ -32,8 +32,25 @@ export function useUploaderConnections(
): Array<UploaderConnection> {
const [connections, setConnections] = useState<Array<UploaderConnection>>([])
console.log('connections', connections)
useEffect(() => {
const listener = (conn: DataConnection) => {
// 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') {
// Broadcast report message to all connections
connections.forEach((c) => {
c.dataConnection.send({
type: MessageType.Report,
})
c.dataConnection.close()
})
// Hard-redirect uploader to reported page
window.location.href = '/reported'
return
}
let sendChunkTimeout: NodeJS.Timeout | null = null
const newConn = {
status: UploaderConnectionStatus.Pending,
@ -43,7 +60,15 @@ export function useUploaderConnections(
currentFileProgress: 0,
}
setConnections((conns) => [newConn, ...conns])
setConnections((conns) => {
// Check if connection already exists
const exists = conns.some(conn => conn.dataConnection === newConn.dataConnection)
if (exists) {
console.log('connection already exists!', newConn.dataConnection)
return conns
}
return [newConn, ...conns]
})
const updateConnection = (
fn: (c: UploaderConnection) => UploaderConnection,
) => {

@ -10,6 +10,7 @@ export enum MessageType {
Error = 'Error',
PasswordRequired = 'PasswordRequired',
UsePassword = 'UsePassword',
Report = 'Report',
}
export const RequestInfoMessage = z.object({
@ -70,6 +71,10 @@ export const PauseMessage = z.object({
type: z.literal(MessageType.Pause),
})
export const ReportMessage = z.object({
type: z.literal(MessageType.Report),
})
export const Message = z.discriminatedUnion('type', [
RequestInfoMessage,
InfoMessage,
@ -80,6 +85,7 @@ export const Message = z.discriminatedUnion('type', [
PasswordRequiredMessage,
UsePasswordMessage,
PauseMessage,
ReportMessage,
])
export type Message = z.infer<typeof Message>

Loading…
Cancel
Save