diff --git a/src/app/api/destroy/route.ts b/src/app/api/destroy/route.ts index ed08bbe..c1f1b27 100644 --- a/src/app/api/destroy/route.ts +++ b/src/app/api/destroy/route.ts @@ -2,18 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { channelRepo } from '../../../channel' export async function POST(request: NextRequest): Promise { - 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( diff --git a/src/app/download/[...slug]/page.tsx b/src/app/download/[...slug]/page.tsx index e7f6e9e..b8b78c6 100644 --- a/src/app/download/[...slug]/page.tsx +++ b/src/app/download/[...slug]/page.tsx @@ -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({
- + - + +
) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 66e66cb..2e91f9b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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, }: { diff --git a/src/app/page.tsx b/src/app/page.tsx index a3ff5a5..cb7fb48 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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({
Peer-to-peer file transfers in your browser. - - We never store anything. Files only served fresh. -
+
) } @@ -108,9 +106,9 @@ function UploadingState({ You are uploading {pluralize(uploadedFiles.length, 'file', 'files')}. - + - + ) } diff --git a/src/app/reported/page.tsx b/src/app/reported/page.tsx new file mode 100644 index 0000000..7acd91a --- /dev/null +++ b/src/app/reported/page.tsx @@ -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 ( +
+ + + + This delivery has been halted. +
+

Message from the management

+

+ 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. +

+
+ - The FilePizza Team +
+
+ + +
+ ) +} diff --git a/src/channel.ts b/src/channel.ts index 594d64f..e78e5b4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -87,9 +87,9 @@ export class RedisChannelRepo implements ChannelRepo { return true } - async destroyChannel(slug: string, secret: string): Promise { + async destroyChannel(slug: string): Promise { const channel = await this.fetchChannel(slug) - if (!channel || channel.secret !== secret) { + if (!channel) { return } diff --git a/src/components/Downloader.tsx b/src/components/Downloader.tsx index b51b1f7..f78cff7 100644 --- a/src/components/Downloader.tsx +++ b/src/components/Downloader.tsx @@ -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 + } + + return ( + <> + + +
+

+ Having trouble connecting? +

+ +
+

FilePizza uses direct peer-to-peer connections, but sometimes the connection can get stuck. Here are some possible reasons this can happen:

+ +
    +
  • + 🚪 + The uploader may have closed their browser. FilePizza requires the uploader to stay online continuously because files are transferred directly between b. +
  • +
  • + 🔒 + Your network might have strict firewalls or NAT settings, such as having UPnP disabled +
  • +
  • + 🌐 + Some corporate or school networks block peer-to-peer connections +
  • +
+ +

+ Note: FilePizza is designed for direct transfers between known parties and doesn't use{" "} + + TURN + relay servers. This means it may not work on all networks. +

+
+
+ + ) +} + export function DownloadComplete({ filesInfo, bytesDownloaded, @@ -167,7 +229,7 @@ export default function Downloader({ } if (!isConnected) { - return + return } if (isDownloading && filesInfo) { diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 4f1c482..5bd27e9 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -36,7 +36,7 @@ export function Footer(): JSX.Element {

- Like FilePizza? Support its development!{' '} + Like FilePizza v2? Support its development!{' '}

+
+ + {showModal && ( +
setShowModal(false)} + > +
e.stopPropagation()} + > + + +
+

Before reporting this delivery, please note our FilePizza terms:

+ +
    +
  • + + Only upload files you have the right to share +
  • +
  • + 🔒 + Share download links only with known recipients +
  • +
  • + ⚠️ + No illegal or harmful content allowed +
  • +
+ +

If you've spotted a violation of these terms, click Report to halt its delivery.

+
+ +
+ + +
+
+
+ )} + + ) +} \ No newline at end of file diff --git a/src/components/SubtitleText.tsx b/src/components/SubtitleText.tsx deleted file mode 100644 index 9b137b1..0000000 --- a/src/components/SubtitleText.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' - -interface SubtitleTextProps { - children: React.ReactNode -} - -const SubtitleText: React.FC = ({ children }) => { - return ( -

- {children} -

- ) -} - -export default SubtitleText diff --git a/src/components/TermsAcceptance.tsx b/src/components/TermsAcceptance.tsx new file mode 100644 index 0000000..0a1560e --- /dev/null +++ b/src/components/TermsAcceptance.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useState } from 'react' + +export default function TermsAcceptance(): JSX.Element { + const [showModal, setShowModal] = useState(false) + + return ( + <> +
+ + By selecting a file, you agree to{' '} + . + +
+ + {showModal && ( +
setShowModal(false)} + > +
e.stopPropagation()} + > + + +
+
    +
  • + + Only upload files you have the right to share +
  • +
  • + 🔒 + Share download links only with known recipients +
  • +
  • + ⚠️ + No illegal or harmful content allowed +
  • +
  • + 📤 + Files are shared directly between browsers -— no server storage +
  • +
+ +

By uploading a file, you confirm that you understand and agree to these terms.

+
+ +
+ +
+
+
+ )} + + ) +} diff --git a/src/components/Uploader.tsx b/src/components/Uploader.tsx index 936ab5a..f4165c2 100644 --- a/src/components/Uploader.tsx +++ b/src/components/Uploader.tsx @@ -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 + return } const activeDownloaders = connections.filter( @@ -56,7 +61,7 @@ export default function Uploader({

{activeDownloaders} Downloading, {connections.length} Total

- +
{connections.map((conn, i) => ( diff --git a/src/components/WebRTCProvider.tsx b/src/components/WebRTCProvider.tsx index a087b01..bfa3ee2 100644 --- a/src/components/WebRTCProvider.tsx +++ b/src/components/WebRTCProvider.tsx @@ -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(null) +const WebRTCContext = React.createContext(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 { +async function getOrCreateGlobalPeer(): Promise { if (!globalPeer) { globalPeer = new Peer({ config: ICE_SERVERS, @@ -39,42 +42,54 @@ async function getPeer(): Promise { } await new Promise((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(globalPeer) + const [peerValue, setPeerValue] = useState(globalPeer) + const [isStopped, setIsStopped] = useState(false) + + const stop = useCallback(() => { + globalPeer?.destroy() + globalPeer = null + setPeerValue(null) + setIsStopped(true) + }, []) useEffect(() => { - getPeer().then((pc) => { - setPeerConnection(pc) - }) - - return () => { - setPeerConnection(null) - } + getOrCreateGlobalPeer().then(setPeerValue) }, []) - if (!peerConnection) { - return + const value = useMemo( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + () => ({ peer: peerValue!, stop }), + [peerValue, stop] + ) + + if (isStopped) { + return <> + } + + if (!peerValue) { + return } return ( - + {children} ) -} - -export default WebRTCProvider +} \ No newline at end of file diff --git a/src/hooks/useDownloader.ts b/src/hooks/useDownloader.ts index 88f28ca..fcf7d8b 100644 --- a/src/hooks/useDownloader.ts +++ b/src/hooks/useDownloader.ts @@ -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( 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) diff --git a/src/hooks/useUploaderChannel.ts b/src/hooks/useUploaderChannel.ts index 67204d0..d3dcf77 100644 --- a/src/hooks/useUploaderChannel.ts +++ b/src/hooks/useUploaderChannel.ts @@ -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, diff --git a/src/hooks/useUploaderConnections.ts b/src/hooks/useUploaderConnections.ts index 58fdeca..e110f65 100644 --- a/src/hooks/useUploaderConnections.ts +++ b/src/hooks/useUploaderConnections.ts @@ -32,8 +32,25 @@ export function useUploaderConnections( ): Array { const [connections, setConnections] = useState>([]) + 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, ) => { diff --git a/src/messages.ts b/src/messages.ts index a36d8fd..a9acf2b 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -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