Repository: kern/filepizza Branch: main Commit: 3258673e7901 Files: 122 Total size: 295.7 KB Directory structure: gitextract_c85d7kb8/ ├── .claude/ │ └── settings.local.json ├── .cursorrules ├── .dockerignore ├── .github/ │ └── workflows/ │ ├── main.yml │ └── tests.yml ├── .gitignore ├── .prettierrc.js ├── CLAUDE.md ├── Dockerfile ├── LICENSE ├── README.md ├── bin/ │ └── peerjs.js ├── docker-compose.production.yml ├── docker-compose.yml ├── docs/ │ └── file-transfer-protocol.md ├── eslint.config.mjs ├── next-env.d.ts ├── next.config.js ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── public/ │ ├── robots.txt │ ├── stream.html │ └── sw.js ├── renovate.json ├── scripts/ │ └── pull-and-run.sh ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ ├── create/ │ │ │ │ └── route.ts │ │ │ ├── destroy/ │ │ │ │ └── route.ts │ │ │ ├── ice/ │ │ │ │ └── route.ts │ │ │ └── renew/ │ │ │ └── route.ts │ │ ├── download/ │ │ │ └── [...slug]/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ └── reported/ │ │ └── page.tsx │ ├── channel.ts │ ├── components/ │ │ ├── AddFilesButton.tsx │ │ ├── CancelButton.tsx │ │ ├── ConnectionListItem.tsx │ │ ├── CopyableInput.tsx │ │ ├── DownloadButton.tsx │ │ ├── Downloader.tsx │ │ ├── DropZone.tsx │ │ ├── ErrorMessage.tsx │ │ ├── Footer.tsx │ │ ├── InputLabel.tsx │ │ ├── Loading.tsx │ │ ├── ModeToggle.tsx │ │ ├── PasswordField.tsx │ │ ├── ProgressBar.tsx │ │ ├── QueryClientProvider.tsx │ │ ├── ReportTermsViolationButton.tsx │ │ ├── ReturnHome.tsx │ │ ├── Spinner.tsx │ │ ├── StartButton.tsx │ │ ├── StopButton.tsx │ │ ├── SubtitleText.tsx │ │ ├── TermsAcceptance.tsx │ │ ├── ThemeProvider.tsx │ │ ├── TitleText.tsx │ │ ├── TypeBadge.tsx │ │ ├── UnlockButton.tsx │ │ ├── UploadFileList.tsx │ │ ├── Uploader.tsx │ │ ├── WebRTCProvider.tsx │ │ └── Wordmark.tsx │ ├── config.ts │ ├── coturn.ts │ ├── fs.ts │ ├── hooks/ │ │ ├── useClipboard.ts │ │ ├── useDownloader.ts │ │ ├── useRotatingSpinner.ts │ │ ├── useUploaderChannel.ts │ │ └── useUploaderConnections.ts │ ├── log.ts │ ├── messages.ts │ ├── redisClient.ts │ ├── routes.ts │ ├── slugs.ts │ ├── styles.css │ ├── toppings.ts │ ├── types.ts │ ├── utils/ │ │ ├── download.ts │ │ └── pluralize.ts │ └── zip-stream.ts ├── tests/ │ ├── e2e/ │ │ ├── add-files.test.ts │ │ ├── basic.test.ts │ │ ├── file-transfer.test.ts │ │ └── helpers.ts │ └── unit/ │ ├── CancelButton.test.tsx │ ├── ConnectionListItem.test.tsx │ ├── CopyableInput.test.tsx │ ├── DownloadButton.test.tsx │ ├── Downloader.subcomponents.test.tsx │ ├── DropZone.test.tsx │ ├── ErrorMessage.test.tsx │ ├── Footer.test.tsx │ ├── InputLabel.test.tsx │ ├── Loading.test.tsx │ ├── ModeToggle.test.tsx │ ├── PasswordField.test.tsx │ ├── ProgressBar.test.tsx │ ├── QueryClientProvider.test.tsx │ ├── ReportTermsViolationButton.test.tsx │ ├── ReturnHome.test.tsx │ ├── Spinner.test.tsx │ ├── StartButton.test.tsx │ ├── StopButton.test.tsx │ ├── TermsAcceptance.test.tsx │ ├── ThemeProvider.test.tsx │ ├── TitleText.test.tsx │ ├── TypeBadge.test.tsx │ ├── UnlockButton.test.tsx │ ├── UploadFileList.test.tsx │ ├── Uploader.test.tsx │ ├── WebRTCProvider.test.tsx │ ├── Wordmark.test.tsx │ ├── isFinalChunk.test.ts │ └── useRotatingSpinner.test.ts ├── tsconfig.json ├── vitest.config.ts └── vitest.setup.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.local.json ================================================ { "permissions": { "allow": [ "Bash(pnpm build:*)", "Bash(pnpm test:*)", "Bash(npm test:*)" ], "deny": [] } } ================================================ FILE: .cursorrules ================================================ - Use TypeScript. - Use function syntax for defining React components. Define the prop types inline. - If a value is exported, it should be exported on the same line as its definition. - Always define the return type of a function or component. - Use Tailwind CSS for styling. - Don't use trailing semicolons. ================================================ FILE: .dockerignore ================================================ .DS_Store .next node_modules dist .env ================================================ FILE: .github/workflows/main.yml ================================================ name: main on: push: branches: - main jobs: build: name: Docker build, tag, and push runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Docker build, tag, and push uses: pangzineng/Github-Action-One-Click-Docker@master env: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} ================================================ FILE: .github/workflows/tests.yml ================================================ name: tests on: [push] jobs: tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm exec playwright install --with-deps - run: pnpm lint:check - run: pnpm format:check - run: pnpm type:check - run: pnpm test - run: pnpm build - run: pnpm test:e2e ================================================ FILE: .gitignore ================================================ .DS_Store .next node_modules dist tsconfig.tsbuildinfo .env ================================================ FILE: .prettierrc.js ================================================ 'use strict'; module.exports = { semi: false, trailingComma: 'all', singleQuote: true, printWidth: 80, tabWidth: 2, }; ================================================ FILE: CLAUDE.md ================================================ # FilePizza Development Guide A peer-to-peer file transfer application built with modern web technologies. ## Prerequisites - [Node.js](https://nodejs.org/) (v18+) - [pnpm](https://pnpm.io/) (preferred package manager) ## Quick Start ```bash git clone https://github.com/kern/filepizza.git cd filepizza pnpm install pnpm dev ``` ## Available Commands ### Development - `pnpm dev` - Start development server - `pnpm dev:full` - Start with Redis and COTURN for full WebRTC testing ### Building & Testing - `pnpm build` - Build for production - `pnpm test` - Run unit tests with Vitest - `pnpm test:watch` - Run tests in watch mode - `pnpm test:e2e` - Run E2E tests with Playwright ### Code Quality - `pnpm lint:check` - Check ESLint rules - `pnpm lint:fix` - Fix ESLint issues - `pnpm format` - Format code with Prettier - `pnpm format:check` - Check code formatting - `pnpm type:check` - TypeScript type checking ### Docker - `pnpm docker:build` - Build Docker image - `pnpm docker:up` - Start containers - `pnpm docker:down` - Stop containers ### CI Pipeline - `pnpm ci` - Run full CI pipeline (lint, format, type-check, test, build, e2e, docker) ## Tech Stack - **Framework**: Next.js 15 with App Router - **UI**: React 19 + Tailwind CSS v4 - **Language**: TypeScript - **Testing**: Vitest (unit) + Playwright (E2E) - **WebRTC**: PeerJS - **State Management**: TanStack Query - **Themes**: next-themes with View Transitions - **Storage**: Redis (optional) ## Project Structure ``` src/ ├── app/ # Next.js App Router pages ├── components/ # React components ├── hooks/ # Custom React hooks ├── utils/ # Utility functions └── types.ts # TypeScript definitions ``` ## Development Tips ### Using pnpm This project uses pnpm as the package manager. Benefits include: - Faster installs and smaller disk usage - Strict dependency resolution - Built-in workspace support Always use `pnpm` instead of `npm` or `yarn`: ```bash pnpm install package-name pnpm remove package-name pnpm update ``` ### Code Style - ESLint + TypeScript ESLint for linting - Prettier for formatting - Husky + lint-staged for pre-commit hooks - Prefer TypeScript over JavaScript - Use kebab-case for files, PascalCase for components ### Testing Strategy - Unit tests for components and utilities (`tests/unit/`) - E2E tests for critical user flows (`tests/e2e/`) - Test files follow `*.test.ts[x]` naming convention ### WebRTC Development For full WebRTC testing with TURN/STUN: ```bash pnpm dev:full ``` This starts Redis and COTURN containers for testing peer connections behind NAT. ## Key Dependencies - `next` - React framework - `tailwindcss` - CSS framework - `@tanstack/react-query` - Server state management - `peerjs` - WebRTC abstraction - `next-themes` - Theme switching - `zod` - Schema validation - `vitest` - Testing framework - `playwright` - E2E testing Run `pnpm ci` before submitting PRs to ensure all checks pass. ================================================ FILE: Dockerfile ================================================ # Stage 1: Dependencies FROM node:lts-alpine AS deps RUN apk add --no-cache pnpm WORKDIR /app COPY package.json pnpm-lock.yaml ./ # Need all dependencies for build RUN pnpm install --frozen-lockfile # Stage 2: Builder FROM node:lts-alpine AS builder RUN apk add --no-cache pnpm WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Builds standalone output RUN pnpm build # Stage 3: Runner FROM node:lts-alpine AS runner WORKDIR /app ENV NODE_ENV production ENV PORT 3000 # Only copy standalone output - no need for node_modules COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static USER node EXPOSE 3000 # Uses standalone server CMD ["node", "server.js"] ================================================ FILE: LICENSE ================================================ Copyright (c) 2015, Alex Kern All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## SIL OPEN FONT LICENSE for fonts in static/fonts Version 1.1 - 26 February 2007 PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: README.md ================================================ XKCD 949 FilePizza wordmark

Peer-to-peer file transfers in your browser

*Cooked up by [Alex Kern](https://kern.io) & [Neeraj Baid](https://github.com/neerajbaid) while eating Sliver @ UC Berkeley.* Using [WebRTC](http://www.webrtc.org), FilePizza eliminates the initial upload step required by other web-based file sharing services. Because data is never stored in an intermediary server, the transfer is fast, private, and secure. A hosted instance of FilePizza is available at [file.pizza](https://file.pizza). ## What's new with FilePizza v2 * A new UI with dark mode support, now built on modern browser technologies. * Works on most mobile browsers, including Mobile Safari. * Transfers are now directly from the uploader to the downloader's browser (WebRTC without WebTorrent) with faster handshakes. * Uploaders can monitor the progress of the transfer and stop it if they want. * Better security and safety measures with password protection and reporting. * Support for uploading multiple files at once, which downloaders receive as a zip file. * Streaming downloads with a Service Worker. * Out-of-process storage of server state using Redis. ## Development ``` $ git clone https://github.com/kern/filepizza.git $ pnpm install $ pnpm dev $ pnpm build $ pnpm start ``` ## Running with Docker ``` $ pnpm docker:build $ pnpm docker:up $ pnpm docker:down ``` ## Stack * Next.js * Tailwind * TypeScript * React * PeerJS for WebRTC * View Transitions * Redis (optional) ## Configuration The server can be customized with the following environment variables: - `REDIS_URL` – Connection string for a Redis instance used to store channel metadata. If not set, FilePizza falls back to in-memory storage. - `COTURN_ENABLED` – When set to `true`, enables TURN support for connecting peers behind NAT. - `TURN_HOST` – Hostname or IP address of the TURN server. Defaults to `127.0.0.1`. - `TURN_REALM` – Realm used when generating TURN credentials. Defaults to `file.pizza`. - `STUN_SERVER` – STUN server URL to use when `COTURN_ENABLED` is disabled. Defaults to `stun:stun.l.google.com:19302`. - `PEERJS_HOST` – Hostname or IP address to the self-hosted PeerJS server. Defaults to `0.peerjs.com`. - `PEERJS_PATH` – Path to self-hosted PeerJS server. Defaults to `/`. ## FAQ **How are my files sent?** Your files are sent directly from your browser to the downloader's browser. They never pass through our servers. FilePizza uses WebRTC to send files. This requires that the uploader leave their browser window open until the transfer is complete. **Can multiple people download my file at once?** Yes! Just send them your short or long URL. **How big can my files be?** As big as your browser can handle. **What happens when I close my browser?** The URLs for your files will no longer work. If a downloader has completed the transfer, that downloader will continue to seed to incomplete downloaders, but no new downloads may be initiated. **Are my files encrypted?** Yes, all WebRTC communications are automatically encrypted using public-key cryptography because of DTLS. You can add an optional password to your upload for an extra layer of security. ## License & Acknowledgements FilePizza is released under the [BSD 3-Clause license](https://github.com/kern/filepizza/blob/main/LICENSE). A huge thanks to [iblowyourdesign](https://dribbble.com/iblowyourdesign) for the pizza illustration. ================================================ FILE: bin/peerjs.js ================================================ #!/usr/bin/env node const express = require('express') const { ExpressPeerServer } = require('peer') const app = express(); const server = app.listen(9000); const peerServer = ExpressPeerServer(server, { path: '/filepizza' }) app.use('/peerjs', peerServer) ================================================ FILE: docker-compose.production.yml ================================================ services: redis: image: redis:latest ports: - 127.0.0.1:6379:6379 networks: - filepizza volumes: - redis_data:/data coturn: image: coturn/coturn ports: - 3478:3478 - 3478:3478/udp - 5349:5349 - 5349:5349/udp - 60000-60128:60000-60128/udp environment: - DETECT_EXTERNAL_IP=yes - DETECT_RELAY_IP=yes command: -n --log-file=stdout --redis-userdb="ip=redis connect_timeout=30" --min-port=60000 --max-port=60128 networks: - filepizza filepizza: build: . image: kern/filepizza:latest ports: - 0.0.0.0:80:80 environment: - PORT=80 - REDIS_URL=redis://redis:6379 - COTURN_ENABLED=true networks: - filepizza depends_on: - redis env_file: - .env networks: filepizza: driver: bridge volumes: redis_data: ================================================ FILE: docker-compose.yml ================================================ services: redis: image: redis:latest ports: - 6379:6379 networks: - 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 ports: - 8080:8080 environment: - PORT=8080 - REDIS_URL=redis://redis:6379 networks: - filepizza depends_on: - redis networks: filepizza: driver: bridge volumes: redis_data: ================================================ FILE: docs/file-transfer-protocol.md ================================================ # FilePizza File Transfer Protocol This document explains the message-based protocol that FilePizza uses to transfer files directly between browsers over a WebRTC data channel. It covers the complete conversation required to build either an uploader or a downloader and includes examples for common scenarios. ## Architecture Overview ```mermaid flowchart LR Uploader -- WebRTC / PeerJS --> Downloader Uploader -- REST --> Server[(FilePizza Server)] Downloader -- REST --> Server Server -- signalling / slug --> Uploader Server -- signalling / slug --> Downloader ``` 1. The uploader creates a channel with the server and receives a slug that encodes its PeerJS identifier. 2. The downloader resolves the slug via the server to obtain the uploader's PeerJS identifier. 3. All subsequent messages travel directly between peers over a reliable WebRTC data channel. ## Message Types Every message is a JSON object with a `type` field that matches one of the values in the table below. Fields marked with `?` are optional. ```mermaid classDiagram class RequestInfo { +"RequestInfo" type +string browserName +string browserVersion +string osName +string osVersion +string mobileVendor +string mobileModel } class Info { +"Info" type +FileInfo[] files } class FileInfo { +string fileName +number size +string type } class Start { +"Start" type +string fileName +number offset } class Chunk { +"Chunk" type +string fileName +number offset +ArrayBuffer bytes +boolean final } class ChunkAck { +"ChunkAck" type +string fileName +number offset +number bytesReceived } class Pause { +"Pause" type } class Done { +"Done" type } class Error { +"Error" type +string error } class PasswordRequired { +"PasswordRequired" type +string errorMessage? } class UsePassword { +"UsePassword" type +string password } class Report { +"Report" type } ``` Chunks are sent in pieces of at most 256 KiB (`MAX_CHUNK_SIZE`). The `final` flag in a `Chunk` message marks the last piece of a file. ## Normal Transfer Sequence The following diagram shows the exchange for downloading multiple files without a password. ```mermaid sequenceDiagram participant D as Downloader participant U as Uploader D->>U: RequestInfo U-->>D: Info(files) loop For each file D->>U: Start(fileName, offset=0) loop For each chunk U-->>D: Chunk(offset, bytes, final=false) D->>U: ChunkAck(offset, bytesReceived) end U-->>D: Chunk(offset, bytes, final=true) D->>U: ChunkAck(offset, bytesReceived) end D->>U: Done U-->>D: close connection ``` ## Password‑Protected Transfers If the uploader specified a password when creating the channel, the conversation includes an authentication step. ```mermaid sequenceDiagram participant D as Downloader participant U as Uploader D->>U: RequestInfo U-->>D: PasswordRequired(errorMessage?) D->>U: UsePassword(password) U-->>D: Info(files) or PasswordRequired("Invalid password") Note over D,U: Continue with normal transfer sequence on success ``` ## Pause and Resume A downloader may pause an in‑progress transfer. To resume, it reconnects and requests the remainder of the file starting at the last acknowledged offset. ```mermaid sequenceDiagram participant D as Downloader participant U as Uploader D->>U: Start(fileName, offset=0) U-->>D: Chunk(...) D->>U: ChunkAck(...) D->>U: Pause Note over D,U: Connection closed or kept idle D->>U: Start(fileName, offset=previouslyAcked) Note over D,U: Transfer resumes from offset ``` ## Reporting A special PeerJS connection with metadata `{ type: "report" }` causes the uploader to broadcast a `Report` message to all connected downloaders and to redirect its own UI to a reported page. Downloaders receiving this message should abort the transfer. ```mermaid sequenceDiagram participant Reporter participant U as Uploader participant D as Downloader Reporter->>U: Peer connection(type="report") U-->>D: Report U-->>Reporter: redirect to /reported ``` ## Example Conversations ### Single file without password ``` RequestInfo Info [{ fileName: "photo.jpg", size: 1048576, type: "image/jpeg" }] Start { fileName: "photo.jpg", offset: 0 } Chunk { offset: 0, bytes: <256 KB>, final: false } ChunkAck { offset: 0, bytesReceived: 262144 } ... Chunk { offset: 1048576, bytes: <0>, final: true } ChunkAck { offset: 1048576, bytesReceived: 0 } Done ``` ### Password‑protected download ``` RequestInfo PasswordRequired UsePassword { password: "secret" } Info [...] ... ``` ### Resuming after interruption ``` RequestInfo Info [...] Start { fileName: "video.mp4", offset: 0 } Chunk/ChunkAck exchanges... Start { fileName: "video.mp4", offset: 1048576 } Chunk/ChunkAck exchanges... Done ``` --- With these message definitions and sequences you can implement a compatible uploader or downloader for FilePizza or adapt the protocol for other applications. ================================================ FILE: eslint.config.mjs ================================================ // @ts-check import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config({ extends: [ eslint.configs.recommended, tseslint.configs.recommended, ], rules: { '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' }, ], '@typescript-eslint/no-use-before-define': [ 'error', { variables: false }, ], '@typescript-eslint/promise-function-async': 'off', '@typescript-eslint/require-await': 'off', '@typescript-eslint/no-explicit-any': 'warn', 'import/no-unused-modules': 'off', 'import/group-exports': 'off', 'import/no-extraneous-dependencies': 'off', 'new-cap': 'off', 'no-inline-comments': 'off', 'no-shadow': 'warn', 'no-use-before-define': 'off', }, files: ['src/**/*.ts[x]'], ignores: ['legacy', 'node_modules', '.next'], }); ================================================ FILE: next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. ================================================ FILE: next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { // Disable strict mode to avoid calling useEffect twice in development. // The uploader and downloader are both using useEffect to listen for peerjs events // which causes the connection to be created twice. reactStrictMode: false, output: 'standalone' } module.exports = nextConfig ================================================ FILE: package.json ================================================ { "name": "filepizza", "version": "2.0.0", "description": "Free peer-to-peer file transfers in your browser.", "author": "Alex Kern (http://kern.io)", "license": "BSD-3-Clause", "homepage": "https://github.com/kern/filepizza", "scripts": { "dev": "next", "dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next", "build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/", "start": "next start", "start:peerjs": "./bin/peerjs.js", "lint:check": "eslint 'src/**/*.ts[x]'", "lint:fix": "eslint 'src/**/*.ts[x]' --fix", "docker:build": "docker compose build", "docker:up": "docker compose up -d", "docker:down": "docker compose down", "docker:logs": "docker compose logs -f", "docker:ps": "docker compose ps", "docker:restart": "docker compose restart", "docker:clean": "docker compose down -v --rmi all", "format": "prettier --write \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "type:check": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", "ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm test && pnpm build && pnpm test:e2e && pnpm docker:build" }, "repository": { "type": "git", "url": "git@github.com:kern/filepizza.git" }, "bugs": { "url": "https://github.com/kern/filepizza/issues" }, "dependencies": { "@tailwindcss/postcss": "^4.1.11", "@tanstack/react-query": "^5.55.2", "autoprefixer": "^10.4.20", "debug": "^4.3.6", "express": "^5.0.0", "ioredis": "^5.4.2", "next": "~15.5.11", "next-themes": "^0.4.4", "next-view-transitions": "^0.3.4", "nodemon": "^3.0.0", "peer": "^1.0.0", "peerjs": "^1.5.4", "postcss": "^8.4.44", "react": "~19.2.3", "react-device-detect": "^2.0.0", "react-dom": "~19.2.3", "react-qr-code": "^2.0.15", "streamsaver": "^2.0.6", "tailwindcss": "^4.1.11", "web-streams-polyfill": "^4.0.0", "webrtcsupport": "^2.2.0", "zod": "^4.0.0" }, "devDependencies": { "@eslint/js": "^9.30.0", "@playwright/test": "^1.53.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/debug": "^4.1.12", "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "@vitejs/plugin-react": "^4.6.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.17.0", "eslint-config-next": "~15.5.11", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react": "^7.37.3", "husky": "^9.0.0", "jsdom": "^26.1.0", "lint-staged": "^16.0.0", "playwright": "^1.53.2", "prettier": "^3.0.0", "typescript": "^5.0.0", "typescript-eslint": "^8.18.2", "vitest": "^3.2.4" }, "husky": { "hooks": { "pre-commit": "lint-staged" } }, "lint-staged": { "*.{ts,tsx}": [ "eslint --fix", "prettier --write", "git add" ] } } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig } from '@playwright/test' export default defineConfig({ testDir: './tests/e2e', workers: 1, // Run tests serially to avoid WebRTC port conflicts webServer: { command: 'node .next/standalone/server.js', url: 'http://localhost:3000', timeout: 120 * 1000, reuseExistingServer: true, }, use: { baseURL: 'http://localhost:3000', }, }) ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { '@tailwindcss/postcss': {}, autoprefixer: {}, }, } ================================================ FILE: public/robots.txt ================================================ User-agent: * Disallow: ================================================ FILE: public/stream.html ================================================ ================================================ FILE: public/sw.js ================================================ // https://github.com/jimmywarting/StreamSaver.js/blob/master/sw.js /* global self ReadableStream Response */ self.addEventListener('install', () => { self.skipWaiting() }) self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()) }) const map = new Map() // This should be called once per download // Each event has a dataChannel that the data will be piped through self.onmessage = (event) => { // We send a heartbeat every x secound to keep the // service worker alive if a transferable stream is not sent if (event.data === 'ping') { return } const data = event.data const downloadUrl = data.url || self.registration.scope + Math.random() + '/' + (typeof data === 'string' ? data : data.filename) const port = event.ports[0] const metadata = new Array(3) // [stream, data, port] metadata[1] = data metadata[2] = port // Note to self: // old streamsaver v1.2.0 might still use `readableStream`... // but v2.0.0 will always transfer the stream throught MessageChannel #94 if (event.data.readableStream) { metadata[0] = event.data.readableStream } else if (event.data.transferringReadable) { port.onmessage = (evt) => { port.onmessage = null metadata[0] = evt.data.readableStream } } else { metadata[0] = createStream(port) } map.set(downloadUrl, metadata) port.postMessage({ download: downloadUrl }) } function createStream(port) { // ReadableStream is only supported by chrome 52 return new ReadableStream({ start(controller) { // When we receive data on the messageChannel, we write port.onmessage = ({ data }) => { if (data === 'end') { return controller.close() } if (data === 'abort') { controller.error('Aborted the download') return } controller.enqueue(data) } }, cancel() { console.log('user aborted') }, }) } self.onfetch = (event) => { const url = event.request.url // this only works for Firefox if (url.endsWith('/ping')) { return event.respondWith(new Response('pong')) } const hijacke = map.get(url) if (!hijacke) return null const [stream, data, port] = hijacke map.delete(url) // Not comfortable letting any user control all headers // so we only copy over the length & disposition const responseHeaders = new Headers({ 'Content-Type': 'application/octet-stream; charset=utf-8', // To be on the safe side, The link can be opened in a iframe. // but octet-stream should stop it. 'Content-Security-Policy': "default-src 'none'", 'X-Content-Security-Policy': "default-src 'none'", 'X-WebKit-CSP': "default-src 'none'", 'X-XSS-Protection': '1; mode=block', }) let headers = new Headers(data.headers || {}) if (headers.has('Content-Length')) { responseHeaders.set('Content-Length', headers.get('Content-Length')) } if (headers.has('Content-Disposition')) { responseHeaders.set( 'Content-Disposition', headers.get('Content-Disposition'), ) } // data, data.filename and size should not be used anymore if (data.size) { console.warn('Depricated') responseHeaders.set('Content-Length', data.size) } let fileName = typeof data === 'string' ? data : data.filename if (fileName) { console.warn('Depricated') // Make filename RFC5987 compatible fileName = encodeURIComponent(fileName) .replace(/['()]/g, escape) .replace(/\*/g, '%2A') responseHeaders.set( 'Content-Disposition', "attachment; filename*=UTF-8''" + fileName, ) } event.respondWith(new Response(stream, { headers: responseHeaders })) port.postMessage({ debug: 'Download started' }) } ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" ] } ================================================ FILE: scripts/pull-and-run.sh ================================================ #!/bin/bash set -euo pipefail git pull origin main sudo docker pull kern/filepizza:latest sudo docker compose -f docker-compose.production.yml up -d sudo docker compose logs -f ================================================ FILE: src/app/api/create/route.ts ================================================ import { NextResponse } from 'next/server' import { getOrCreateChannelRepo } from '../../../channel' export async function POST(request: Request): Promise { const { uploaderPeerID } = await request.json() if (!uploaderPeerID) { return NextResponse.json( { error: 'Uploader peer ID is required' }, { status: 400 }, ) } const channel = await getOrCreateChannelRepo().createChannel(uploaderPeerID) return NextResponse.json(channel) } ================================================ FILE: src/app/api/destroy/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server' import { getOrCreateChannelRepo } from '../../../channel' export async function POST(request: NextRequest): Promise { const { slug } = await request.json() if (!slug) { return NextResponse.json({ error: 'Slug 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 getOrCreateChannelRepo().destroyChannel(slug) return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { console.error(error) return NextResponse.json( { error: 'Failed to destroy channel' }, { status: 500 }, ) } } ================================================ FILE: src/app/api/ice/route.ts ================================================ import { NextResponse } from 'next/server' import crypto from 'crypto' import { setTurnCredentials } from '../../../coturn' const turnHost = process.env.TURN_HOST || '127.0.0.1' const stunServer = process.env.STUN_SERVER || 'stun:stun.l.google.com:19302' const peerjsHost = process.env.PEERJS_HOST || '0.peerjs.com' const peerjsPath = process.env.PEERJS_PATH || '/' export async function POST(): Promise { if (!process.env.COTURN_ENABLED) { return NextResponse.json({ host: peerjsHost, path: peerjsPath, iceServers: [{ urls: stunServer }], }) } // 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({ host: peerjsHost, path: peerjsPath, iceServers: [ { urls: stunServer }, { urls: [`turn:${turnHost}:3478`, `turns:${turnHost}:5349`], username, credential: password, }, ], }) } ================================================ FILE: src/app/api/renew/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server' import { getOrCreateChannelRepo } from '../../../channel' export async function POST(request: NextRequest): Promise { 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 }) } const success = await getOrCreateChannelRepo().renewChannel(slug, secret) return NextResponse.json({ success }) } ================================================ FILE: src/app/download/[...slug]/page.tsx ================================================ import { JSX } from 'react' import { notFound } from 'next/navigation' import { getOrCreateChannelRepo } from '../../../channel' import Spinner from '../../../components/Spinner' import Wordmark from '../../../components/Wordmark' import Downloader from '../../../components/Downloader' import WebRTCPeerProvider from '../../../components/WebRTCProvider' import ReportTermsViolationButton from '../../../components/ReportTermsViolationButton' const normalizeSlug = (rawSlug: string | string[]): string => { if (typeof rawSlug === 'string') { return rawSlug } else { return rawSlug.join('/') } } export default async function DownloadPage({ params, }: { params: Promise<{ slug: string[] }> }): Promise { const { slug: slugRaw } = await params const slug = normalizeSlug(slugRaw) const channel = await getOrCreateChannelRepo().fetchChannel(slug) if (!channel) { notFound() } return (
) } ================================================ FILE: src/app/layout.tsx ================================================ import React from 'react' import Footer from '../components/Footer' import '../styles.css' import { ThemeProvider } from '../components/ThemeProvider' import { ModeToggle } from '../components/ModeToggle' import FilePizzaQueryClientProvider from '../components/QueryClientProvider' import { Viewport } from 'next' import { ViewTransitions } from 'next-view-transitions' export const metadata = { title: 'FilePizza • Your files, delivered.', description: 'Peer-to-peer file transfers in your web browser.', charSet: 'utf-8', openGraph: { url: 'https://file.pizza', title: 'FilePizza • Your files, delivered.', description: 'Peer-to-peer file transfers in your web browser.', images: [{ url: 'https://file.pizza/images/fb.png' }], }, } export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 1, userScalable: false, } export default function RootLayout({ children, }: { children: React.ReactNode }): React.ReactElement { return (
{children}