FilePizza v2 🍕 (#134)

Ship it.
pull/162/head
Alex Kern 12 months ago committed by GitHub
parent e4fb3431fb
commit e402c807c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,6 @@
- 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.

@ -1,4 +1,4 @@
.DS_Store .DS_Store
.next
node_modules node_modules
dist dist
resources

@ -1,20 +0,0 @@
workflow "Build on push" {
on = "push"
resolves = ["AWS deploy"]
}
action "Docker build, tag, and push" {
uses = "pangzineng/Github-Action-One-Click-Docker@master"
secrets = ["DOCKER_USERNAME", "DOCKER_PASSWORD"]
}
action "AWS deploy" {
uses = "actions/aws/cli@efb074ae4510f2d12c7801e4461b65bf5e8317e6"
needs = ["Docker build, tag, and push"]
secrets = [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
]
args = "deploy --region us-west-2 create-deployment --application-name AppECS-filepizza-filepizza --deployment-config-name CodeDeployDefault.ECSAllAtOnce --deployment-group-name DgpECS-filepizza-filepizza --github-location repository=kern/filepizza,commitId=$GITHUB_REF"
runs = "aws"
}

@ -1,11 +1,14 @@
on: push name: main
name: Build on push on:
push:
branches:
- main
jobs: jobs:
build: build:
name: Docker build, tag, and push name: Docker build, tag, and push
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@v4
- name: Docker build, tag, and push - name: Docker build, tag, and push
uses: pangzineng/Github-Action-One-Click-Docker@master uses: pangzineng/Github-Action-One-Click-Docker@master
env: env:

@ -0,0 +1,19 @@
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 lint:check
- run: pnpm format:check
- run: pnpm type:check
- run: pnpm build

2
.gitignore vendored

@ -1,3 +1,5 @@
.DS_Store .DS_Store
.next
node_modules node_modules
dist dist
tsconfig.tsbuildinfo

@ -1,13 +0,0 @@
# Logs
log/
# Compiled assets
css/index.css
# New Relic
newrelic.js
# FilePizza
resources
log
*.pem

@ -0,0 +1,9 @@
'use strict';
module.exports = {
semi: false,
trailingComma: 'all',
singleQuote: true,
printWidth: 80,
tabWidth: 2,
};

@ -1,9 +1,13 @@
FROM node:alpine FROM node:lts-alpine
MAINTAINER Alexander Kern <filepizza@kern.io>
RUN apk add --no-cache pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . ./ COPY . ./
RUN npm install && npm run build RUN pnpm build
ENV PORT 3000
ENV NODE_ENV production ENV NODE_ENV production
EXPOSE 80 EXPOSE 3000
CMD node ./dist/index.js CMD pnpm start

@ -1,69 +1,51 @@
<a href="https://xkcd.com/949/"><img src="http://imgs.xkcd.com/comics/file_transfer.png" alt="XKCD 949" width="30%" align="right" /></a> <img src="src/static/images/wordmark.png" alt="FilePizza wordmark" width="50%" /> <h3>Peer-to-peer file transfers in your browser</h3> <a href="https://xkcd.com/949/"><img src="http://imgs.xkcd.com/comics/file_transfer.png" alt="XKCD 949" width="30%" align="right" /></a> <img src="public/images/wordmark.png" alt="FilePizza wordmark" width="50%" /> <h3>Peer-to-peer file transfers in your browser</h3>
*Cooked up by [Alex Kern](https://kern.io) & [Neeraj Baid](http://neeraj.io) while eating Sliver @ UC Berkeley.* *Cooked up by [Alex Kern](https://kern.io) & [Neeraj Baid](http://neeraj.io) 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. When senders initialize a transfer, they receive a "tempalink" they can distribute to recipients. Upon visiting this link, recipients' browsers connect directly to the senders browser and may begin downloading the selected file. Because data is never stored in an intermediary server, the transfer is fast, private, and secure. 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). A hosted instance of FilePizza is available at [file.pizza](https://file.pizza).
## Requirements
* node `0.12.x`
* npm `2.x.x`
## Installation
The recommended way to deploy FilePizza is as a [Docker container](https://hub.docker.com/r/kern/filepizza). WebRTC only works over HTTPS, so you'll either have to get a signed HTTPS key/certificate from a CA such as [Let's Encrypt](https://letsencrypt.org/getting-started/) or generate your own [self-signed pair](https://devcenter.heroku.com/articles/ssl-certificate-self) and trust it. Then, to run FilePizza (with HTTPS termination):
$ docker run \
-p 8080:8080 -e PORT=8080 \
-e HTTPS_KEY=/config/server.key \
-e HTTPS_CERT=/config/server.crt \
-v mylocalpath:/config \
-it kern/filepizza:master
You can specify your own ICE STUN/TURN servers for better connectivity behind NATs by passing a JSON encoding of the array via env var `ICE_SERVERS`. Alternatively, if you'd like to use [Twilio's STUN/TURN service](https://www.twilio.com/stun-turn), you can specify your SID and token using the `TWILIO_SID` and `TWILIO_TOKEN` environment variables, respectively.
You can specify your own Webtorrent tracker(s) using the `WEBTORRENT_TRACKERS` environment variable, comma-delimited.
If you want to use [Google Analytics](https://marketingplatform.google.com/about/analytics/), you can specify your UA code using the `GA_ACCESS_TOKEN="UA-00000000-1"` environment variable.
## Development ## Development
```
$ git clone https://github.com/kern/filepizza.git $ git clone https://github.com/kern/filepizza.git
$ npm install $ pnpm install
$ npm run-script build $ pnpm dev
$ npm start $ pnpm build
$ pnpm start
FilePizza is an isomorphic React application which uses the Flux application architecture. ES6 features are used liberally and compiled using Babel. Views are rendered on the server, store data is serialized and sent to the client, which then picks up where the server left off. ```
Both client and server JavaScript files can be found in `lib/`. `lib/server.js` and `lib/client.js` are the server and client entrypoints, respectively. `lib/components/`, `lib/stores/`, and `lib/actions/` contain the corresponding Flux modules, implemented using [alt](https://github.com/goatslacker/alt). `lib/routes.js` serves as the isomorphic routes file using [react-router](https://github.com/rackt/react-router). ## Running with Docker
Client-side JavaScript and CSS are compiled using webpack and are available at `/app.js`. ```
$ pnpm docker:build
$ pnpm docker:up
$ pnpm docker:down
```
## Stack
* Next.js
* Tailwind
* TypeScript
* React
* PeerJS for WebRTC
* View Transitions
* Redis (optional)
## FAQ ## FAQ
**Where are my files sent?** Your files never touch our server. Instead, they are sent directly from the uploader's browser to the downloader's browser using WebTorrent and WebRTC. This requires that the uploader leave their browser window open until the transfer is complete. **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 tempalink.
**How big can my files be?** Chrome has issues supporting files >500 MB. Firefox does not have any issues with large files, however.
**What happens when I close my browser?** The tempalink is invalidated. 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.
**My files are sending slowly!** Transfer speed is dependent on your network connection. **Can multiple people download my file at once?** Yes! Just send them your short or long URL.
## Troubleshooting **How big can my files be?** As big as your browser can handle.
If you receive a `Error: EMFILE, too many open files` error when running `npm **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.
start` on a Mac, this is a result of Browserify's compilation step opening up a
large number of npm modules all at once. You'll have to increase the maximum
number of open files allowed on your system:
$ sysctl -w kern.maxfiles=20480 **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 ## License & Acknowledgements
FilePizza is released under the [BSD 3-Clause license](https://github.com/kern/filepizza/blob/master/LICENSE). A huge thanks to [WebTorrent](https://github.com/feross/webtorrent) which we use for the file transfers under the hood, and to [iblowyourdesign](https://dribbble.com/iblowyourdesign) for the pizza illustration. FilePizza is released under the [BSD 3-Clause license](https://github.com/kern/filepizza/blob/master/LICENSE). A huge thanks to [iblowyourdesign](https://dribbble.com/iblowyourdesign) for the pizza illustration.

@ -0,0 +1,11 @@
#!/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)

@ -1,23 +0,0 @@
version: '3'
services:
filepizza:
image: kern/filepizza:latest
restart: always
build:
context: .
ports:
- 3333:3333
environment:
- PORT=3333
- EXTRA_ICE_SERVERS=turn:localhost:3478
- WEBTORRENT_TRACKERS=ws://localhost:8000
coturn:
image: instrumentisto/coturn:latest
network_mode: host
ports:
- 3478:3478
bittorrent-tracker:
image: henkel/bittorrent-tracker:latest
command: ["npx", "bittorrent-tracker", "--http-hostname", "0.0.0.0", "--ws"]
ports:
- 8000:8000

@ -0,0 +1,24 @@
version: '3'
services:
redis:
image: redis:latest
ports:
- 6379:6379
networks:
- filepizza
filepizza:
build: .
image: kern/filepizza:master
ports:
- 8080:8080
environment:
- PORT=8080
- REDIS_URL=redis://redis:6379
networks:
- filepizza
depends_on:
- redis
networks:
filepizza:
driver: bridge

@ -0,0 +1,33 @@
// @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'],
});

5
next-env.d.ts vendored

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

@ -0,0 +1,9 @@
/** @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,
}
module.exports = nextConfig

8463
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,17 +1,28 @@
{ {
"name": "filepizza", "name": "filepizza",
"version": "1.1.0", "version": "2.0.0",
"description": "Free peer-to-peer file transfers in your browser.", "description": "Free peer-to-peer file transfers in your browser.",
"preferGlobal": "true",
"bin": "./dist/index.js",
"author": "Alex Kern <alex@kern.io> (http://kern.io)", "author": "Alex Kern <alex@kern.io> (http://kern.io)",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"homepage": "https://github.com/kern/filepizza", "homepage": "https://github.com/kern/filepizza",
"scripts": { "scripts": {
"start": "babel-node ./src/index.js", "dev": "next",
"start:prod": "node ./dist/index.js", "build": "next build",
"prepublishOnly": "npm run build", "start": "next start",
"build": "babel src --ignore __tests__,__mocks__ --out-dir dist && webpack -p ./src/client && rm -rf dist/static && cp -R src/static dist/static" "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",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm build && pnpm docker:build"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -20,60 +31,56 @@
"bugs": { "bugs": {
"url": "https://github.com/kern/filepizza/issues" "url": "https://github.com/kern/filepizza/issues"
}, },
"engines": {
"node": ">= 5.0.0",
"npm": ">= 3.3.0"
},
"babel": {
"presets": [
"es2015",
"stage-0"
],
"plugins": [
"add-module-exports",
"transform-react-jsx"
]
},
"dependencies": { "dependencies": {
"alt": "^0.14.4", "@tanstack/react-query": "^5.55.2",
"classnames": "^1.2.0", "autoprefixer": "^10.4.20",
"express": "^4.12.0", "debug": "^4.3.6",
"express-force-ssl": "^0.3.1", "express": "^4.19.2",
"express-winston": "^0.3.1", "ioredis": "^4.28.5",
"filepizza-socket": "^1.0.0", "next": "^15.1.3",
"newrelic": "^1.21.1", "next-themes": "^0.4.4",
"nib": "^1.1.0", "next-view-transitions": "^0.3.4",
"node-uuid": "^1.4.3", "nodemon": "^1.19.4",
"nodemon": "^1.4.1", "peer": "^0.5.3",
"react": "^0.13.0", "peerjs": "^1.5.4",
"react-frozenhead": "^0.3.0", "postcss": "^8.4.44",
"react-google-analytics": "^0.2.0", "react": "^18.2.0",
"react-qr": "0.0.2", "react-device-detect": "^1.17.0",
"react-router": "^0.13.1", "react-dom": "^18.2.0",
"socket.io": "^1.3.5", "react-qr-code": "^2.0.15",
"socket.io-client": "^1.3.5", "streamsaver": "^2.0.6",
"stylus": "^0.52.4", "tailwindcss": "^3.4.10",
"twilio": "^2.9.1", "web-streams-polyfill": "^3.3.3",
"webrtcsupport": "^2.2.0", "webrtcsupport": "^2.2.0",
"winston": "^1.0.1", "zod": "^3.23.8"
"xkcd-password": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.16.0", "@types/debug": "^4.1.12",
"babel-core": "^6.17.0", "@types/ioredis": "^4.28.10",
"babel-loader": "^6.2.5", "@types/node": "^22.10.2",
"babel-plugin-add-module-exports": "^0.2.1", "@types/react": "^19.0.2",
"babel-plugin-transform-react-jsx": "^6.7.4", "@typescript-eslint/eslint-plugin": "^8.18.2",
"babel-polyfill": "^6.16.0", "@typescript-eslint/parser": "^8.18.2",
"babel-preset-es2015": "^6.14.0", "eslint": "^9.17.0",
"babel-preset-stage-0": "^6.16.0", "eslint-config-next": "^15.1.3",
"css-loader": "^0.28.4", "eslint-plugin-import": "^2.31.0",
"json-loader": "^0.5.4", "eslint-plugin-react": "^7.37.3",
"noop-loader": "^1.0.0", "husky": "^4.3.8",
"null-loader": "^0.1.1", "lint-staged": "^10.5.4",
"style-loader": "^0.18.2", "prettier": "^2.8.8",
"stylus-loader": "^3.0.1", "typescript": "^4.9.5",
"webpack": "^1.12.14", "typescript-eslint": "^8.18.2"
"webpack-dev-middleware": "^1.6.1" },
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write",
"git add"
]
} }
} }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@ -0,0 +1,168 @@
<!--
https://github.com/jimmywarting/StreamSaver.js/blob/master/mitm.html
mitm.html is the lite "man in the middle"
This is only meant to signal the opener's messageChannel to
the service worker - when that is done this mitm can be closed
but it's better to keep it alive since this also stops the sw
from restarting
The service worker is capable of intercepting all request and fork their
own "fake" response - wish we are going to craft
when the worker then receives a stream then the worker will tell the opener
to open up a link that will start the download
-->
<script>
// This will prevent the sw from restarting
let keepAlive = () => {
keepAlive = () => {}
var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping'
var interval = setInterval(() => {
if (sw) {
sw.postMessage('ping')
} else {
fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)))
}
}, 10000)
}
// message event is the first thing we need to setup a listner for
// don't want the opener to do a random timeout - instead they can listen for
// the ready event
// but since we need to wait for the Service Worker registration, we store the
// message for later
let messages = []
window.onmessage = evt => messages.push(evt)
let sw = null
let scope = ''
function registerWorker() {
return navigator.serviceWorker.getRegistration('./').then(swReg => {
return swReg || navigator.serviceWorker.register('sw.js', { scope: './' })
}).then(swReg => {
const swRegTmp = swReg.installing || swReg.waiting
scope = swReg.scope
return (sw = swReg.active) || new Promise(resolve => {
swRegTmp.addEventListener('statechange', fn = () => {
if (swRegTmp.state === 'activated') {
swRegTmp.removeEventListener('statechange', fn)
sw = swReg.active
resolve()
}
})
})
})
}
// Now that we have the Service Worker registered we can process messages
function onMessage (event) {
let { data, ports, origin } = event
// It's important to have a messageChannel, don't want to interfere
// with other simultaneous downloads
if (!ports || !ports.length) {
throw new TypeError("[StreamSaver] You didn't send a messageChannel")
}
if (typeof data !== 'object') {
throw new TypeError("[StreamSaver] You didn't send a object")
}
// the default public service worker for StreamSaver is shared among others.
// so all download links needs to be prefixed to avoid any other conflict
data.origin = origin
// if we ever (in some feature versoin of streamsaver) would like to
// redirect back to the page of who initiated a http request
data.referrer = data.referrer || document.referrer || origin
// pass along version for possible backwards compatibility in sw.js
data.streamSaverVersion = new URLSearchParams(location.search).get('version')
if (data.streamSaverVersion === '1.2.0') {
console.warn('[StreamSaver] please update streamsaver')
}
/** @since v2.0.0 */
if (!data.headers) {
console.warn("[StreamSaver] pass `data.headers` that you would like to pass along to the service worker\nit should be a 2D array or a key/val object that fetch's Headers api accepts")
} else {
// test if it's correct
// should thorw a typeError if not
new Headers(data.headers)
}
/** @since v2.0.0 */
if (typeof data.filename === 'string') {
console.warn("[StreamSaver] You shouldn't send `data.filename` anymore. It should be included in the Content-Disposition header option")
// Do what File constructor do with fileNames
data.filename = data.filename.replace(/\//g, ':')
}
/** @since v2.0.0 */
if (data.size) {
console.warn("[StreamSaver] You shouldn't send `data.size` anymore. It should be included in the content-length header option")
}
/** @since v2.0.0 */
if (data.readableStream) {
console.warn("[StreamSaver] You should send the readableStream in the messageChannel, not throught mitm")
}
/** @since v2.0.0 */
if (!data.pathname) {
console.warn("[StreamSaver] Please send `data.pathname` (eg: /pictures/summer.jpg)")
data.pathname = Math.random().toString().slice(-6) + '/' + data.filename
}
// remove all leading slashes
data.pathname = data.pathname.replace(/^\/+/g, '')
// remove protocol
let org = origin.replace(/(^\w+:|^)\/\//, '')
// set the absolute pathname to the download url.
data.url = new URL(`${scope + org}/${data.pathname}`).toString()
if (!data.url.startsWith(`${scope + org}/`)) {
throw new TypeError('[StreamSaver] bad `data.pathname`')
}
// This sends the message data as well as transferring
// messageChannel.port2 to the service worker. The service worker can
// then use the transferred port to reply via postMessage(), which
// will in turn trigger the onmessage handler on messageChannel.port1.
const transferable = data.readableStream
? [ ports[0], data.readableStream ]
: [ ports[0] ]
if (!(data.readableStream || data.transferringReadable)) {
keepAlive()
}
return sw.postMessage(data, transferable)
}
if (window.opener) {
// The opener can't listen to onload event, so we need to help em out!
// (telling them that we are ready to accept postMessage's)
window.opener.postMessage('StreamSaver::loadedPopup', '*')
}
if (navigator.serviceWorker) {
registerWorker().then(() => {
window.onmessage = onMessage
messages.forEach(window.onmessage)
})
} else {
// FF can ping sw with fetch from a secure hidden iframe
// shouldn't really be possible?
keepAlive()
}
</script>

@ -0,0 +1,143 @@
// 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 diff suppressed because one or more lines are too long

@ -1,9 +0,0 @@
import alt from '../alt'
export default alt.createActions(class DownloadActions {
constructor() {
this.generateActions(
'requestDownload'
)
}
})

@ -1,10 +0,0 @@
import alt from '../alt'
export default alt.createActions(class SupportActions {
constructor() {
this.generateActions(
'isChrome',
'noSupport'
)
}
})

@ -1,9 +0,0 @@
import alt from '../alt'
export default alt.createActions(class UploadActions {
constructor() {
this.generateActions(
'uploadFile'
)
}
})

@ -1,2 +0,0 @@
import Alt from 'alt'
export default new Alt()

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: Request): Promise<NextResponse> {
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)
}

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
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 },
)
}
}

@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
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 })
}

@ -0,0 +1,44 @@
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<JSX.Element> {
const { slug: slugRaw } = await params
const slug = normalizeSlug(slugRaw)
const channel = await getOrCreateChannelRepo().fetchChannel(slug)
if (!channel) {
notFound()
}
return (
<div className="flex flex-col items-center space-y-5 py-10 max-w-2xl mx-auto">
<Spinner direction="down" />
<Wordmark />
<WebRTCPeerProvider>
<Downloader uploaderPeerID={channel.uploaderPeerID} />
<ReportTermsViolationButton
uploaderPeerID={channel.uploaderPeerID}
slug={slug}
/>
</WebRTCPeerProvider>
</div>
)
}

@ -0,0 +1,52 @@
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 (
<ViewTransitions>
<html lang="en" suppressHydrationWarning>
<head>
<meta name="monetization" content="$twitter.xrptipbot.com/kernio" />
</head>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<FilePizzaQueryClientProvider>
<main>{children}</main>
<Footer />
<ModeToggle />
</FilePizzaQueryClientProvider>
</ThemeProvider>
</body>
</html>
</ViewTransitions>
)
}

@ -0,0 +1,21 @@
import { JSX } from 'react'
import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark'
import ReturnHome from '../components/ReturnHome'
import TitleText from '../components/TitleText'
export const metadata = {
title: 'FilePizza - 404: Slice Not Found',
description: 'Oops! This slice of FilePizza seems to be missing.',
}
export default async function NotFound(): Promise<JSX.Element> {
return (
<div className="flex flex-col items-center space-y-5 py-10 max-w-2xl mx-auto">
<Spinner direction="down" />
<Wordmark />
<TitleText>404: Looks like this slice of FilePizza got eaten!</TitleText>
<ReturnHome />
</div>
)
}

@ -0,0 +1,170 @@
'use client'
import React, { JSX, useCallback, useState } from 'react'
import WebRTCPeerProvider from '../components/WebRTCProvider'
import DropZone from '../components/DropZone'
import UploadFileList from '../components/UploadFileList'
import Uploader from '../components/Uploader'
import PasswordField from '../components/PasswordField'
import StartButton from '../components/StartButton'
import { UploadedFile } from '../types'
import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark'
import CancelButton from '../components/CancelButton'
import { useMemo } from 'react'
import { getFileName } from '../fs'
import TitleText from '../components/TitleText'
import { pluralize } from '../utils/pluralize'
import TermsAcceptance from '../components/TermsAcceptance'
function PageWrapper({
children,
isRotating = false,
}: {
children: React.ReactNode
isRotating?: boolean
}): JSX.Element {
return (
<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>
)
}
function InitialState({
onDrop,
}: {
onDrop: (files: UploadedFile[]) => void
}): JSX.Element {
return (
<PageWrapper>
<div className="flex flex-col items-center space-y-1 max-w-md">
<TitleText>Peer-to-peer file transfers in your browser.</TitleText>
</div>
<DropZone onDrop={onDrop} />
<TermsAcceptance />
</PageWrapper>
)
}
function useUploaderFileListData(uploadedFiles: UploadedFile[]) {
return useMemo(() => {
return uploadedFiles.map((item) => ({
fileName: getFileName(item),
type: item.type,
}))
}, [uploadedFiles])
}
function ConfirmUploadState({
uploadedFiles,
password,
onChangePassword,
onCancel,
onStart,
onRemoveFile,
}: {
uploadedFiles: UploadedFile[]
password: string
onChangePassword: (pw: string) => void
onCancel: () => void
onStart: () => void
onRemoveFile: (index: number) => void
}): JSX.Element {
const fileListData = useUploaderFileListData(uploadedFiles)
return (
<PageWrapper>
<TitleText>
You are about to start uploading{' '}
{pluralize(uploadedFiles.length, 'file', 'files')}.
</TitleText>
<UploadFileList files={fileListData} onRemove={onRemoveFile} />
<PasswordField value={password} onChange={onChangePassword} />
<div className="flex space-x-4">
<CancelButton onClick={onCancel} />
<StartButton onClick={onStart} />
</div>
</PageWrapper>
)
}
function UploadingState({
uploadedFiles,
password,
onStop,
}: {
uploadedFiles: UploadedFile[]
password: string
onStop: () => void
}): JSX.Element {
const fileListData = useUploaderFileListData(uploadedFiles)
return (
<PageWrapper isRotating={true}>
<TitleText>
You are uploading {pluralize(uploadedFiles.length, 'file', 'files')}.
</TitleText>
<UploadFileList files={fileListData} />
<WebRTCPeerProvider>
<Uploader files={uploadedFiles} password={password} onStop={onStop} />
</WebRTCPeerProvider>
</PageWrapper>
)
}
export default function UploadPage(): JSX.Element {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [password, setPassword] = useState('')
const [uploading, setUploading] = useState(false)
const handleDrop = useCallback((files: UploadedFile[]): void => {
setUploadedFiles(files)
}, [])
const handleChangePassword = useCallback((pw: string) => {
setPassword(pw)
}, [])
const handleStart = useCallback(() => {
setUploading(true)
}, [])
const handleStop = useCallback(() => {
setUploading(false)
}, [])
const handleCancel = useCallback(() => {
setUploadedFiles([])
setUploading(false)
}, [])
const handleRemoveFile = useCallback((index: number) => {
setUploadedFiles((fs) => fs.filter((_, i) => i !== index))
}, [])
if (!uploadedFiles.length) {
return <InitialState onDrop={handleDrop} />
}
if (!uploading) {
return (
<ConfirmUploadState
uploadedFiles={uploadedFiles}
password={password}
onChangePassword={handleChangePassword}
onCancel={handleCancel}
onStart={handleStart}
onRemoveFile={handleRemoveFile}
/>
)
}
return (
<UploadingState
uploadedFiles={uploadedFiles}
password={password}
onStop={handleStop}
/>
)
}

@ -0,0 +1,32 @@
import { JSX } from 'react'
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>
)
}

@ -0,0 +1,300 @@
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 = {
secret?: string
longSlug: string
shortSlug: string
uploaderPeerID: string
}
const ChannelSchema = z.object({
secret: z.string().optional(),
longSlug: z.string(),
shortSlug: z.string(),
uploaderPeerID: z.string(),
})
export interface ChannelRepo {
createChannel(uploaderPeerID: string, ttl?: number): Promise<Channel>
fetchChannel(slug: string): Promise<Channel | null>
renewChannel(slug: string, secret: string, ttl?: number): Promise<boolean>
destroyChannel(slug: string): Promise<void>
}
function getShortSlugKey(shortSlug: string): string {
return `short:${shortSlug}`
}
function getLongSlugKey(longSlug: string): string {
return `long:${longSlug}`
}
async function generateShortSlugUntilUnique(
checkExists: (key: string) => Promise<boolean>,
): Promise<string> {
for (let i = 0; i < config.shortSlug.maxAttempts; i++) {
const slug = generateShortSlug()
const exists = await checkExists(getShortSlugKey(slug))
if (!exists) {
return slug
}
}
throw new Error('max attempts reached generating short slug')
}
async function generateLongSlugUntilUnique(
checkExists: (key: string) => Promise<boolean>,
): Promise<string> {
for (let i = 0; i < config.longSlug.maxAttempts; i++) {
const slug = await generateLongSlug()
const exists = await checkExists(getLongSlugKey(slug))
if (!exists) {
return slug
}
}
throw new Error('max attempts reached generating long slug')
}
function serializeChannel(channel: Channel): string {
return JSON.stringify(channel)
}
function deserializeChannel(str: string, scrubSecret = false): Channel {
const parsedChannel = JSON.parse(str)
const validatedChannel = ChannelSchema.parse(parsedChannel)
if (scrubSecret) {
return { ...validatedChannel, secret: undefined }
}
return validatedChannel
}
type MemoryStoredChannel = {
channel: Channel
expiresAt: number
}
export class MemoryChannelRepo implements ChannelRepo {
private channels: Map<string, MemoryStoredChannel> = new Map()
private timeouts: Map<string, NodeJS.Timeout> = new Map()
private setChannelTimeout(slug: string, ttl: number) {
// Clear any existing timeout
const existingTimeout = this.timeouts.get(slug)
if (existingTimeout) {
clearTimeout(existingTimeout)
}
// Set new timeout to remove channel when expired
const timeout = setTimeout(() => {
this.channels.delete(slug)
this.timeouts.delete(slug)
}, ttl * 1000)
this.timeouts.set(slug, timeout)
}
async createChannel(
uploaderPeerID: string,
ttl: number = config.channel.ttl,
): Promise<Channel> {
const shortSlug = await generateShortSlugUntilUnique(async (key) =>
this.channels.has(key),
)
const longSlug = await generateLongSlugUntilUnique(async (key) =>
this.channels.has(key),
)
const channel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
}
const expiresAt = Date.now() + ttl * 1000
const storedChannel = { channel, expiresAt }
const shortKey = getShortSlugKey(shortSlug)
const longKey = getLongSlugKey(longSlug)
this.channels.set(shortKey, storedChannel)
this.channels.set(longKey, storedChannel)
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
return channel
}
async fetchChannel(
slug: string,
scrubSecret = false,
): Promise<Channel | null> {
const shortKey = getShortSlugKey(slug)
const shortChannel = this.channels.get(shortKey)
if (shortChannel) {
return scrubSecret
? { ...shortChannel.channel, secret: undefined }
: shortChannel.channel
}
const longKey = getLongSlugKey(slug)
const longChannel = this.channels.get(longKey)
if (longChannel) {
return scrubSecret
? { ...longChannel.channel, secret: undefined }
: longChannel.channel
}
return null
}
async renewChannel(
slug: string,
secret: string,
ttl: number = config.channel.ttl,
): Promise<boolean> {
const channel = await this.fetchChannel(slug)
if (!channel || channel.secret !== secret) {
return false
}
const expiresAt = Date.now() + ttl * 1000
const storedChannel = { channel, expiresAt }
const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug)
this.channels.set(longKey, storedChannel)
this.channels.set(shortKey, storedChannel)
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
return true
}
async destroyChannel(slug: string): Promise<void> {
const channel = await this.fetchChannel(slug)
if (!channel) {
return
}
const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug)
// Clear timeouts
const shortTimeout = this.timeouts.get(shortKey)
if (shortTimeout) {
clearTimeout(shortTimeout)
this.timeouts.delete(shortKey)
}
const longTimeout = this.timeouts.get(longKey)
if (longTimeout) {
clearTimeout(longTimeout)
this.timeouts.delete(longKey)
}
this.channels.delete(longKey)
this.channels.delete(shortKey)
}
}
export class RedisChannelRepo implements ChannelRepo {
client: Redis.Redis
constructor(redisURL: string) {
this.client = new Redis(redisURL)
}
async createChannel(
uploaderPeerID: string,
ttl: number = config.channel.ttl,
): Promise<Channel> {
const shortSlug = await generateShortSlugUntilUnique(
async (key) => (await this.client.get(key)) !== null,
)
const longSlug = await generateLongSlugUntilUnique(
async (key) => (await this.client.get(key)) !== null,
)
const channel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
}
const channelStr = serializeChannel(channel)
await this.client.setex(getLongSlugKey(longSlug), ttl, channelStr)
await this.client.setex(getShortSlugKey(shortSlug), ttl, channelStr)
return channel
}
async fetchChannel(
slug: string,
scrubSecret = false,
): Promise<Channel | null> {
const shortChannelStr = await this.client.get(getShortSlugKey(slug))
if (shortChannelStr) {
return deserializeChannel(shortChannelStr, scrubSecret)
}
const longChannelStr = await this.client.get(getLongSlugKey(slug))
if (longChannelStr) {
return deserializeChannel(longChannelStr, scrubSecret)
}
return null
}
async renewChannel(
slug: string,
secret: string,
ttl: number = config.channel.ttl,
): Promise<boolean> {
const channel = await this.fetchChannel(slug)
if (!channel || channel.secret !== secret) {
return false
}
await this.client.expire(getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(getShortSlugKey(channel.shortSlug), ttl)
return true
}
async destroyChannel(slug: string): Promise<void> {
const channel = await this.fetchChannel(slug)
if (!channel) {
return
}
await this.client.del(getLongSlugKey(channel.longSlug))
await this.client.del(getShortSlugKey(channel.shortSlug))
}
}
let _channelRepo: ChannelRepo | null = null
export function getOrCreateChannelRepo(): ChannelRepo {
if (!_channelRepo) {
if (process.env.REDIS_URL) {
_channelRepo = new RedisChannelRepo(process.env.REDIS_URL)
console.log('[ChannelRepo] Using Redis storage')
} else {
_channelRepo = new MemoryChannelRepo()
console.log('[ChannelRepo] Using in-memory storage')
}
}
return _channelRepo
}

@ -1,22 +0,0 @@
import "babel-polyfill";
import "./index.styl";
import React from "react";
import ReactRouter from "react-router";
import routes from "./routes";
import alt from "./alt";
import webrtcSupport from "webrtcsupport";
import SupportActions from "./actions/SupportActions";
let bootstrap = document.getElementById("bootstrap").innerHTML;
alt.bootstrap(bootstrap);
window.FilePizza = () => {
ReactRouter.run(routes, ReactRouter.HistoryLocation, function(Handler) {
React.render(<Handler data={bootstrap} />, document);
});
if (!webrtcSupport.support) SupportActions.noSupport();
let isChrome = navigator.userAgent.toLowerCase().indexOf("chrome") > -1;
if (isChrome) SupportActions.isChrome();
};

@ -1,93 +0,0 @@
import Bootstrap from "./Bootstrap";
import ErrorPage from "./ErrorPage";
import FrozenHead from "react-frozenhead";
import React from "react";
import SupportStore from "../stores/SupportStore";
import { RouteHandler } from "react-router";
import ga from "react-google-analytics";
if (process.env.GA_ACCESS_TOKEN) {
ga("create", process.env.GA_ACCESS_TOKEN, "auto");
ga("send", "pageview");
}
export default class App extends React.Component {
constructor() {
super();
this.state = SupportStore.getState();
this._onChange = () => {
this.setState(SupportStore.getState());
};
}
componentDidMount() {
SupportStore.listen(this._onChange);
}
componentWillUnmount() {
SupportStore.unlisten(this._onChange);
}
render() {
return (
<html lang="en">
<FrozenHead>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="monetization" content="$twitter.xrptipbot.com/kernio" />
<meta property="og:url" content="https://file.pizza" />
<meta
property="og:title"
content="FilePizza - Your files, delivered."
/>
<meta
property="og:description"
content="Peer-to-peer file transfers in your web browser."
/>
<meta
property="og:image"
content="https://file.pizza/images/fb.png"
/>
<title>FilePizza - Your files, delivered.</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<Bootstrap data={this.props.data} />
<script src="https://cdn.jsdelivr.net/webtorrent/latest/webtorrent.min.js" />
<script src="/app.js" />
</FrozenHead>
<body>
<div className="container">
{this.state.isSupported ? <RouteHandler /> : <ErrorPage />}
</div>
<footer className="footer">
<p>
<strong>Like FilePizza?</strong> Support its development! <a href="https://commerce.coinbase.com/checkout/247b6ffe-fb4e-47a8-9a76-e6b7ef83ea22" className="donate-button">donate</a>
</p>
<p className="byline">
Cooked up by{" "}
<a href="http://kern.io" target="_blank">
Alex Kern
</a>{" "}
&amp;{" "}
<a href="http://neeraj.io" target="_blank">
Neeraj Baid
</a>{" "}
while eating <strong>Sliver</strong> @ UC Berkeley &middot;{" "}
<a href="https://github.com/kern/filepizza#faq" target="_blank">
FAQ
</a>{" "}
&middot;{" "}
<a href="https://github.com/kern/filepizza" target="_blank">
Fork us
</a>
</p>
</footer>
<script>FilePizza()</script>
{ process.env.GA_ACCESS_TOKEN ? <ga.Initializer /> : <div></div> }
</body>
</html>
);
}
}

@ -1,12 +0,0 @@
import React from 'react'
export default class Bootstrap extends React.Component {
render() {
return <script
id="bootstrap"
type="application/json"
dangerouslySetInnerHTML={{ __html: this.props.data}} />
}
}

@ -0,0 +1,18 @@
import React, { JSX } from 'react'
export default function CancelButton({
onClick,
text = 'Cancel',
}: {
onClick: React.MouseEventHandler
text?: string
}): JSX.Element {
return (
<button
onClick={onClick}
className="px-4 py-2 text-sm font-medium text-stone-700 dark:text-stone-200 bg-white dark:bg-stone-800 border border-stone-300 dark:border-stone-600 rounded-md hover:bg-stone-50 dark:hover:bg-stone-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-indigo-400"
>
{text}
</button>
)
}

@ -1,40 +0,0 @@
import React from 'react'
import DownloadStore from '../stores/DownloadStore'
import SupportStore from '../stores/SupportStore'
function getState() {
return {
active: SupportStore.getState().isChrome && DownloadStore.getState().fileSize >= 500000000
}
}
export default class ChromeNotice extends React.Component {
constructor() {
super()
this.state = getState()
this._onChange = () => {
this.setState(getState())
}
}
componentDidMount() {
DownloadStore.listen(this._onChange)
SupportStore.listen(this._onChange)
}
componentWillUnmount() {
DownloadStore.unlisten(this._onChange)
SupportStore.unlisten(this._onChange)
}
render() {
if (this.state.active) {
return <p className="notice">Chrome has issues downloading files > 500 MB. Try using Firefox instead.</p>
} else {
return null
}
}
}

@ -0,0 +1,70 @@
import React, { JSX } from 'react'
import { UploaderConnection, UploaderConnectionStatus } from '../types'
import ProgressBar from './ProgressBar'
export function ConnectionListItem({
conn,
}: {
conn: UploaderConnection
}): JSX.Element {
const getStatusColor = (status: UploaderConnectionStatus) => {
switch (status) {
case UploaderConnectionStatus.Uploading:
return 'bg-blue-500 dark:bg-blue-600'
case UploaderConnectionStatus.Paused:
return 'bg-yellow-500 dark:bg-yellow-600'
case UploaderConnectionStatus.Done:
return 'bg-green-500 dark:bg-green-600'
case UploaderConnectionStatus.Closed:
return 'bg-red-500 dark:bg-red-600'
case UploaderConnectionStatus.InvalidPassword:
return 'bg-red-500 dark:bg-red-600'
default:
return 'bg-stone-500 dark:bg-stone-600'
}
}
return (
<div className="w-full mt-4">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
{conn.browserName && conn.browserVersion ? (
<>
{conn.browserName}{' '}
<span className="text-stone-400">v{conn.browserVersion}</span>
</>
) : (
'Downloader'
)}
</span>
<span
className={`px-1.5 py-0.5 text-white rounded-md transition-colors duration-200 font-medium text-[10px] ${getStatusColor(
conn.status,
)}`}
>
{conn.status.replace(/_/g, ' ')}
</span>
</div>
<div className="text-sm text-stone-500 dark:text-stone-400">
<div>
Completed: {conn.completedFiles} / {conn.totalFiles} files
</div>
{conn.uploadingFileName &&
conn.status === UploaderConnectionStatus.Uploading && (
<div>
Current file: {Math.round(conn.currentFileProgress * 100)}%
</div>
)}
</div>
</div>
<ProgressBar
value={
(conn.completedFiles + conn.currentFileProgress) / conn.totalFiles
}
max={1}
/>
</div>
)
}

@ -0,0 +1,32 @@
import React, { JSX } from 'react'
import useClipboard from '../hooks/useClipboard'
import InputLabel from './InputLabel'
export function CopyableInput({
label,
value,
}: {
label: string
value: string
}): JSX.Element {
const { hasCopied, onCopy } = useClipboard(value)
return (
<div className="flex flex-col w-full">
<InputLabel>{label}</InputLabel>
<div className="flex w-full">
<input
className="flex-grow px-3 py-2 text-xs border border-r-0 rounded-l text-stone-900 dark:text-stone-100 bg-white dark:bg-stone-800 border-stone-300 dark:border-stone-600"
value={value}
readOnly
/>
<button
className="px-4 py-2 text-sm text-stone-700 dark:text-stone-200 bg-stone-100 dark:bg-stone-700 hover:bg-stone-200 dark:hover:bg-stone-600 rounded-r border-t border-r border-b border-stone-300 dark:border-stone-600"
onClick={onCopy}
>
{hasCopied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
)
}

@ -1,25 +0,0 @@
import React from 'react'
export default class DownloadButton extends React.Component {
constructor() {
super()
this.onClick = this.onClick.bind(this)
}
onClick(e) {
this.props.onClick(e)
}
render() {
return <button
className="download-button"
onClick={this.onClick}>
Download
</button>
}
}
DownloadButton.propTypes = {
onClick: React.PropTypes.func.isRequired
}

@ -0,0 +1,16 @@
import React, { JSX } from 'react'
export default function DownloadButton({
onClick,
}: {
onClick?: React.MouseEventHandler
}): JSX.Element {
return (
<button
onClick={onClick}
className="h-12 px-4 bg-gradient-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow"
>
Download
</button>
)
}

@ -1,86 +0,0 @@
import ChromeNotice from './ChromeNotice'
import DownloadActions from '../actions/DownloadActions'
import DownloadButton from './DownloadButton'
import DownloadStore from '../stores/DownloadStore'
import ErrorPage from './ErrorPage'
import ProgressBar from './ProgressBar'
import React from 'react'
import Spinner from './Spinner'
import { formatSize } from '../util'
export default class DownloadPage extends React.Component {
constructor() {
super()
this.state = DownloadStore.getState()
this._onChange = () => {
this.setState(DownloadStore.getState())
}
this.downloadFile = this.downloadFile.bind(this)
}
componentDidMount() {
DownloadStore.listen(this._onChange)
}
componentWillUnmount() {
DownloadStore.unlisten(this._onChange)
}
downloadFile() {
DownloadActions.requestDownload()
}
render() {
switch (this.state.status) {
case 'ready':
return <div className="page">
<h1>FilePizza</h1>
<Spinner dir="down"
name={this.state.fileName}
size={this.state.fileSize} />
<ChromeNotice />
<p className="notice">Peers: {this.state.peers} &middot; Up: {formatSize(this.state.speedUp)} &middot; Down: {formatSize(this.state.speedDown)}</p>
<DownloadButton onClick={this.downloadFile} />
</div>
case 'requesting':
case 'downloading':
return <div className="page">
<h1>FilePizza</h1>
<Spinner dir="down" animated
name={this.state.fileName}
size={this.state.fileSize} />
<ChromeNotice />
<p className="notice">Peers: {this.state.peers} &middot; Up: {formatSize(this.state.speedUp)} &middot; Down: {formatSize(this.state.speedDown)}</p>
<ProgressBar value={this.state.progress} />
</div>
case 'done':
return <div className="page">
<h1>FilePizza</h1>
<Spinner dir="down"
name={this.state.fileName}
size={this.state.fileSize} />
<ChromeNotice />
<p className="notice">Peers: {this.state.peers} &middot; Up: {formatSize(this.state.speedUp)} &middot; Down: {formatSize(this.state.speedDown)}</p>
<ProgressBar value={1} />
</div>
default:
return <ErrorPage />
}
}
}

@ -0,0 +1,272 @@
'use client'
import React, { JSX, useState, useCallback, useEffect } from 'react'
import { useDownloader } from '../hooks/useDownloader'
import PasswordField from './PasswordField'
import UnlockButton from './UnlockButton'
import Loading from './Loading'
import UploadFileList from './UploadFileList'
import DownloadButton from './DownloadButton'
import StopButton from './StopButton'
import ProgressBar from './ProgressBar'
import TitleText from './TitleText'
import ReturnHome from './ReturnHome'
import { pluralize } from '../utils/pluralize'
import { ErrorMessage } from './ErrorMessage'
interface FileInfo {
fileName: string
size: number
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,
totalSize,
}: {
filesInfo: FileInfo[]
bytesDownloaded: number
totalSize: number
}): JSX.Element {
return (
<>
<TitleText>
You downloaded {pluralize(filesInfo.length, 'file', 'files')}.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} />
</div>
<ReturnHome />
</div>
</>
)
}
export function DownloadInProgress({
filesInfo,
bytesDownloaded,
totalSize,
onStop,
}: {
filesInfo: FileInfo[]
bytesDownloaded: number
totalSize: number
onStop: () => void
}): JSX.Element {
return (
<>
<TitleText>
You are downloading {pluralize(filesInfo.length, 'file', 'files')}.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} />
</div>
<div className="flex justify-center w-full">
<StopButton onClick={onStop} isDownloading />
</div>
</div>
</>
)
}
export function ReadyToDownload({
filesInfo,
onStart,
}: {
filesInfo: FileInfo[]
onStart: () => void
}): JSX.Element {
return (
<>
<TitleText>
You are about to start downloading{' '}
{pluralize(filesInfo.length, 'file', 'files')}.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<DownloadButton onClick={onStart} />
</div>
</>
)
}
export function PasswordEntry({
onSubmit,
errorMessage,
}: {
onSubmit: (password: string) => void
errorMessage: string | null
}): JSX.Element {
const [password, setPassword] = useState('')
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
onSubmit(password)
},
[onSubmit, password],
)
return (
<>
<TitleText>This download requires a password.</TitleText>
<div className="flex flex-col space-y-5 w-full">
<form
action="#"
method="post"
onSubmit={handleSubmit}
className="w-full"
>
<div className="flex flex-col space-y-5 w-full">
<PasswordField
value={password}
onChange={setPassword}
isRequired
isInvalid={Boolean(errorMessage)}
/>
<UnlockButton />
</div>
</form>
</div>
{errorMessage && <ErrorMessage message={errorMessage} />}
</>
)
}
export default function Downloader({
uploaderPeerID,
}: {
uploaderPeerID: string
}): JSX.Element {
const {
filesInfo,
isConnected,
isPasswordRequired,
isDownloading,
isDone,
errorMessage,
submitPassword,
startDownload,
stopDownload,
totalSize,
bytesDownloaded,
} = useDownloader(uploaderPeerID)
if (isDone && filesInfo) {
return (
<DownloadComplete
filesInfo={filesInfo}
bytesDownloaded={bytesDownloaded}
totalSize={totalSize}
/>
)
}
if (!isConnected) {
return <ConnectingToUploader />
}
if (isDownloading && filesInfo) {
return (
<DownloadInProgress
filesInfo={filesInfo}
bytesDownloaded={bytesDownloaded}
totalSize={totalSize}
onStop={stopDownload}
/>
)
}
if (filesInfo) {
return <ReadyToDownload filesInfo={filesInfo} onStart={startDownload} />
}
if (isPasswordRequired) {
return (
<PasswordEntry errorMessage={errorMessage} onSubmit={submitPassword} />
)
}
return <Loading text="Uh oh... Something went wrong." />
}

@ -1,57 +0,0 @@
import React from 'react'
export default class DropZone extends React.Component {
constructor() {
super()
this.state = { focus: false }
this.onDragEnter = this.onDragEnter.bind(this)
this.onDragLeave = this.onDragLeave.bind(this)
this.onDragOver = this.onDragOver.bind(this)
this.onDrop = this.onDrop.bind(this)
}
onDragEnter() {
this.setState({ focus: true })
}
onDragLeave(e) {
if (e.target !== this.refs.overlay.getDOMNode()) return
this.setState({ focus: false })
}
onDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
onDrop(e) {
e.preventDefault()
this.setState({ focus: false })
let file = e.dataTransfer.files[0]
if (this.props.onDrop && file) this.props.onDrop(file)
}
render() {
return <div className="drop-zone" ref="root"
onDragEnter={this.onDragEnter}
onDragLeave={this.onDragLeave}
onDragOver={this.onDragOver}
onDrop={this.onDrop}>
<div className="drop-zone-overlay"
hidden={!this.state.focus}
ref="overlay" />
{this.props.children}
</div>
}
}
DropZone.propTypes = {
onDrop: React.PropTypes.func.isRequired
}

@ -0,0 +1,109 @@
import React, { JSX, useState, useCallback, useEffect, useRef } from 'react'
import { extractFileList } from '../fs'
export default function DropZone({
onDrop,
}: {
onDrop: (files: File[]) => void
}): JSX.Element {
const [isDragging, setIsDragging] = useState(false)
const [fileCount, setFileCount] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault()
setIsDragging(true)
setFileCount(e.dataTransfer?.items.length || 0)
}, [])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
const currentTarget =
e.currentTarget === window ? window.document : e.currentTarget
if (
e.relatedTarget &&
currentTarget instanceof Node &&
currentTarget.contains(e.relatedTarget as Node)
) {
return
}
setIsDragging(false)
}, [])
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy'
}
}, [])
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer) {
const files = await extractFileList(e)
onDrop(files)
}
},
[onDrop],
)
const handleClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const handleFileInputChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files)
onDrop(files)
}
},
[onDrop],
)
useEffect(() => {
window.addEventListener('dragenter', handleDragEnter)
window.addEventListener('dragleave', handleDragLeave)
window.addEventListener('dragover', handleDragOver)
window.addEventListener('drop', handleDrop)
return () => {
window.removeEventListener('dragenter', handleDragEnter)
window.removeEventListener('dragleave', handleDragLeave)
window.removeEventListener('dragover', handleDragOver)
window.removeEventListener('drop', handleDrop)
}
}, [handleDragEnter, handleDragLeave, handleDragOver, handleDrop])
return (
<>
<div
className={`fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center text-2xl text-white transition-opacity duration-300 backdrop-blur-sm z-50 ${
isDragging ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}
>
Drop to select {fileCount} file{fileCount !== 1 ? 's' : ''}
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileInputChange}
multiple
/>
<button
className="block cursor-pointer relative py-3 px-6 text-base font-bold text-stone-700 dark:text-stone-200 bg-white dark:bg-stone-800 border-2 border-stone-700 dark:border-stone-700 rounded-lg transition-all duration-300 ease-in-out outline-none hover:shadow-md active:shadow-inner focus:shadow-outline"
onClick={handleClick}
>
<span className="text-center text-stone-700 dark:text-stone-200">
Drop a file to get started
</span>
</button>
</>
)
}

@ -0,0 +1,12 @@
import { JSX } from 'react'
export function ErrorMessage({ message }: { message: string }): JSX.Element {
return (
<div
className="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded relative"
role="alert"
>
<span className="block sm:inline">{message}</span>
</div>
)
}

@ -1,41 +0,0 @@
import ErrorStore from '../stores/ErrorStore'
import React from 'react'
import Spinner from './Spinner'
export default class ErrorPage extends React.Component {
constructor() {
super()
this.state = ErrorStore.getState()
this._onChange = () => {
this.setState(ErrorStore.getState())
}
}
componentDidMount() {
ErrorStore.listen(this._onChange)
}
componentWillUnmount() {
ErrorStore.unlisten(this._onChange)
}
render() {
return <div className="page">
<Spinner dir="up" />
<h1 className="with-subtitle">FilePizza</h1>
<p className="subtitle">
<strong>{this.state.status}:</strong> {this.state.message}
</p>
{this.state.stack
? <pre>{this.state.stack}</pre>
: null}
</div>
}
}

@ -0,0 +1,68 @@
'use client'
import React, { JSX, useCallback } from 'react'
const DONATE_HREF =
'https://commerce.coinbase.com/checkout/247b6ffe-fb4e-47a8-9a76-e6b7ef83ea22'
function FooterLink({
href,
children,
}: {
href: string
children: React.ReactNode
}): JSX.Element {
return (
<a
className="text-stone-600 dark:text-stone-400 underline hover:text-stone-800 dark:hover:text-stone-200"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
}
export function Footer(): JSX.Element {
const handleDonate = useCallback(() => {
window.location.href = DONATE_HREF
}, [])
return (
<>
<div className="h-[100px]" /> {/* Spacer to account for footer height */}
<footer className="fixed bottom-0 left-0 right-0 text-center py-2.5 pb-4 text-xs border-t border-stone-200 dark:border-stone-700 shadow-[0_-1px_2px_rgba(0,0,0,0.04)] dark:shadow-[0_-1px_2px_rgba(255,255,255,0.04)] bg-white dark:bg-stone-900">
<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 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]"
onClick={handleDonate}
>
Donate
</button>
</div>
<p className="text-stone-600 dark:text-stone-400">
Cooked up by{' '}
<FooterLink href="http://kern.io">Alex Kern</FooterLink> &amp;{' '}
<FooterLink href="http://neeraj.io">Neeraj Baid</FooterLink> while
eating <strong>Sliver</strong> @ UC Berkeley &middot;{' '}
<FooterLink href="https://github.com/kern/filepizza#faq">
FAQ
</FooterLink>{' '}
&middot;{' '}
<FooterLink href="https://github.com/kern/filepizza">
Fork us
</FooterLink>
</p>
</div>
</footer>
</>
)
}
export default Footer

@ -0,0 +1,40 @@
import React, { JSX } from 'react'
export default function InputLabel({
children,
hasError = false,
tooltip,
}: {
children: React.ReactNode
hasError?: boolean
tooltip?: string
}): JSX.Element {
return (
<div className="relative flex items-center gap-1">
<label
className={`text-[10px] mb-0.5 font-bold group relative inline-block ${
hasError ? 'text-red-500' : 'text-stone-400'
}`}
>
{children}
</label>
{tooltip && (
<div className="relative">
<div
className="text-[11px] text-stone-400 dark:text-stone-400 cursor-help hover:opacity-80 peer focus:opacity-80"
role="button"
aria-label="Show tooltip"
tabIndex={0}
>
</div>
<div className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-1 opacity-0 peer-hover:opacity-100 peer-focus:opacity-100 transition-opacity duration-200 z-10">
<div className="bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 text-xs rounded px-3 py-2 w-[320px] border border-stone-200 dark:border-stone-700 shadow-lg">
{tooltip}
</div>
</div>
</div>
)}
</div>
)
}

@ -0,0 +1,9 @@
import React, { JSX } from 'react'
export default function Loading({ text }: { text: string }): JSX.Element {
return (
<div className="flex flex-col items-center">
<p className="text-sm text-stone-600 dark:text-stone-400 mt-2">{text}</p>
</div>
)
}

@ -0,0 +1,56 @@
'use client'
import { useTheme } from 'next-themes'
import { JSX } from 'react'
function LightModeIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4 block dark:hidden"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
)
}
function DarkModeIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4 hidden dark:block"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
)
}
export function ModeToggle(): JSX.Element {
const { setTheme, resolvedTheme } = useTheme()
return (
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="fixed top-4 right-4 border rounded-md w-6 h-6 flex items-center justify-center"
>
<span className="sr-only">Toggle mode</span>
<LightModeIcon />
<DarkModeIcon />
</button>
)
}

@ -0,0 +1,44 @@
import React, { JSX, useCallback } from 'react'
import InputLabel from './InputLabel'
export default function PasswordField({
value,
onChange,
isRequired = false,
isInvalid = false,
}: {
value: string
onChange: (v: string) => void
isRequired?: boolean
isInvalid?: boolean
}): JSX.Element {
const handleChange = useCallback(
function (e: React.ChangeEvent<HTMLInputElement>): void {
onChange(e.target.value)
},
[onChange],
)
return (
<div className="flex flex-col w-full">
<InputLabel
hasError={isInvalid}
tooltip="The downloader must provide this password to start downloading the file. If you don't specify a password here, any downloader with the link to the file will be able to download it. It is not used to encrypt the file, as this is performed by WebRTC's DTLS already."
>
{isRequired ? 'Password' : 'Password (optional)'}
</InputLabel>
<input
autoFocus
type="password"
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 ${
isInvalid
? 'border-red-500 dark:border-red-400'
: 'border-stone-300 dark:border-stone-600'
} bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100`}
placeholder="Enter a secret password for this slice of FilePizza..."
value={value}
onChange={handleChange}
/>
</div>
)
}

@ -1,43 +0,0 @@
import React from 'react'
import classnames from 'classnames'
function formatProgress(dec) {
return (dec * 100).toPrecision(3) + "%"
}
export default class ProgressBar extends React.Component {
render() {
const failed = this.props.value < 0;
const inProgress = this.props.value < 1 && this.props.value >= 0;
const classes = classnames('progress-bar', {
'progress-bar-failed': failed,
'progress-bar-in-progress': inProgress,
'progress-bar-small': this.props.small
})
const formatted = formatProgress(this.props.value)
return <div className={classes}>
{failed
? <div className="progress-bar-text">Failed</div>
: inProgress ? <div
className="progress-bar-inner"
style={{width: formatted}}>
<div className="progress-bar-text">
{formatted}
</div>
</div>
: <div className="progress-bar-text">Delivered</div>}
</div>
}
}
ProgressBar.propTypes = {
value: React.PropTypes.number.isRequired,
small: React.PropTypes.bool
}
ProgressBar.defaultProps = {
small: false
}

@ -0,0 +1,33 @@
import React, { JSX } from 'react'
export default function ProgressBar({
value,
max,
}: {
value: number
max: number
}): JSX.Element {
const percentage = (value / max) * 100
const isComplete = value === max
return (
<div className="w-full h-12 bg-stone-200 dark:bg-stone-700 rounded-md overflow-hidden relative shadow-sm">
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-black font-bold">{Math.round(percentage)}%</span>
</div>
<div
className={`h-full ${
isComplete
? 'bg-gradient-to-b from-green-500 to-green-600'
: 'bg-gradient-to-b from-blue-500 to-blue-600'
} transition-all duration-300 ease-in-out`}
style={{ width: `${percentage}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-white font-bold text-shadow">
{Math.round(percentage)}%
</span>
</div>
</div>
)
}

@ -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>
)
}

@ -0,0 +1,142 @@
'use client'
import { JSX } from 'react'
import { useWebRTCPeer } from './WebRTCProvider'
import { useCallback, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import CancelButton from './CancelButton'
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">
<CancelButton onClick={() => setShowModal(false)} />
<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>
)}
</>
)
}

@ -0,0 +1,15 @@
import { Link } from 'next-view-transitions'
import { JSX } from 'react'
export default function ReturnHome(): JSX.Element {
return (
<div className="flex justify-center">
<Link
href="/"
className="text-stone-500 dark:text-stone-200 hover:underline"
>
Serve up a fresh slice &raquo;
</Link>
</div>
)
}

@ -1,38 +0,0 @@
import React from 'react'
import classnames from 'classnames'
import { formatSize } from '../util'
export default class Spinner extends React.Component {
render() {
const classes = classnames('spinner', {
'spinner-animated': this.props.animated
})
return <div className={classes}>
<img
alt={this.props.name || this.props.dir}
src={`/images/${this.props.dir}.png`}
className="spinner-image" />
{this.props.name === null ? null
: <div className="spinner-name">{this.props.name}</div>}
{this.props.size === null ? null
: <div className="spinner-size">{formatSize(this.props.size)}</div>}
</div>
}
}
Spinner.propTypes = {
dir: React.PropTypes.oneOf(['up', 'down']).isRequired,
name: React.PropTypes.string,
size: React.PropTypes.number,
animated: React.PropTypes.bool
}
Spinner.defaultProps = {
name: null,
size: null,
animated: false
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,16 @@
import React from 'react'
export default function StartButton({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>
}): React.ReactElement {
return (
<button
onClick={onClick}
className="px-4 py-2 bg-gradient-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow"
>
Start
</button>
)
}

@ -0,0 +1,26 @@
import React from 'react'
export default function StopButton({
isDownloading,
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>
isDownloading?: boolean
}): React.ReactElement {
return (
<button
className="px-2 py-1 text-xs text-orange-500 dark:text-orange-400 bg-transparent hover:bg-orange-100 dark:hover:bg-orange-900 rounded transition-colors duration-200 flex items-center"
onClick={onClick}
>
<svg
className="w-4 h-4 mr-1"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="4" width="16" height="16" />
</svg>
{isDownloading ? 'Stop Download' : 'Stop Upload'}
</button>
)
}

@ -1,38 +0,0 @@
import React from 'react'
import QRCode from 'react-qr'
export default class Tempalink extends React.Component {
constructor() {
super()
this.onClick = this.onClick.bind(this)
}
onClick(e) {
e.target.setSelectionRange(0, 9999)
}
render() {
var url = window.location.origin + '/' + this.props.token
var shortUrl = window.location.origin + '/download/' + this.props.shortToken
return <div className="tempalink">
<div className="qr">
<QRCode text={url} />
</div>
<div className="urls">
<div className="long-url">
<input
onClick={this.onClick}
readOnly
type="text"
value={url} />
</div>
<div className="short-url">
or, for short: <span>{shortUrl}</span>
</div>
</div>
</div>
}
}

@ -0,0 +1,90 @@
'use client'
import { JSX, useState } from 'react'
import CancelButton from './CancelButton'
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">
Files are shared directly between browsers no server
storage
</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">
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 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">
<CancelButton
text="Got it!"
onClick={() => setShowModal(false)}
/>
</div>
</div>
</div>
)}
</>
)
}

@ -0,0 +1,7 @@
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export type ThemeProviderProps = Parameters<typeof NextThemesProvider>[0]
export const ThemeProvider = NextThemesProvider as React.FC<ThemeProviderProps>

@ -0,0 +1,13 @@
import React, { JSX } from 'react'
export default function TitleText({
children,
}: {
children: React.ReactNode
}): JSX.Element {
return (
<p className="text-lg text-center text-stone-800 dark:text-stone-200 max-w-md">
{children}
</p>
)
}

@ -0,0 +1,25 @@
import React, { JSX } from 'react'
function getTypeColor(fileType: string): string {
if (fileType.startsWith('image/'))
return 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
if (fileType.startsWith('text/'))
return 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
if (fileType.startsWith('audio/'))
return 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
if (fileType.startsWith('video/'))
return 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
return 'bg-stone-100 dark:bg-stone-900 text-stone-800 dark:text-stone-200'
}
export default function TypeBadge({ type }: { type: string }): JSX.Element {
return (
<div
className={`px-2 py-1 text-[10px] font-semibold rounded ${getTypeColor(
type,
)} transition-all duration-300`}
>
{type}
</div>
)
}

@ -0,0 +1,16 @@
import React, { JSX } from 'react'
export default function UnlockButton({
onClick,
}: {
onClick?: React.MouseEventHandler<HTMLButtonElement>
}): JSX.Element {
return (
<button
onClick={onClick}
className="px-4 py-2 bg-gradient-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow"
>
Unlock
</button>
)
}

@ -0,0 +1,45 @@
import React, { JSX } from 'react'
import TypeBadge from './TypeBadge'
type UploadedFileLike = {
fileName?: string
type: string
}
export default function UploadFileList({
files,
onRemove,
}: {
files: UploadedFileLike[]
onRemove?: (index: number) => void
}): JSX.Element {
const items = files.map((f: UploadedFileLike, i: number) => (
<div
key={f.fileName}
className={`w-full border-b border-stone-300 dark:border-stone-700 last:border-0`}
>
<div className="flex justify-between items-center py-2 pl-3 pr-2">
<p className="truncate text-sm font-medium text-stone-800 dark:text-stone-200">
{f.fileName}
</p>
<div className="flex items-center">
<TypeBadge type={f.type} />
{onRemove && (
<button
onClick={() => onRemove?.(i)}
className="text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200 focus:outline-none pl-3 pr-1"
>
</button>
)}
</div>
</div>
</div>
))
return (
<div className="w-full border border-stone-300 dark:border-stone-700 rounded-md shadow-sm dark:shadow-sm-dark bg-white dark:bg-stone-800">
{items}
</div>
)
}

@ -1,90 +0,0 @@
import DropZone from './DropZone'
import React from 'react'
import Spinner from './Spinner'
import Tempalink from './Tempalink'
import UploadActions from '../actions/UploadActions'
import UploadStore from '../stores/UploadStore'
import socket from 'filepizza-socket'
import { formatSize } from '../util'
export default class UploadPage extends React.Component {
constructor() {
super()
this.state = UploadStore.getState()
this._onChange = () => {
this.setState(UploadStore.getState())
}
this.uploadFile = this.uploadFile.bind(this)
}
componentDidMount() {
UploadStore.listen(this._onChange)
}
componentWillUnmount() {
UploadStore.unlisten(this._onChange)
}
uploadFile(file) {
UploadActions.uploadFile(file)
}
handleSelectedFile(event) {
let files = event.target.files
if (files.length > 0) {
UploadActions.uploadFile(files[0])
}
}
render() {
switch (this.state.status) {
case 'ready':
return <DropZone onDrop={this.uploadFile}>
<div className="page">
<Spinner dir="up" />
<h1>FilePizza</h1>
<p>Free peer-to-peer file transfers in your browser.</p>
<small className="notice">We never store anything. Files only served fresh.</small>
<p>
<label className="select-file-label">
<input type="file" onChange={this.handleSelectedFile} required/>
<span>select a file</span>
</label>
</p>
</div>
</DropZone>
case 'processing':
return <div className="page">
<Spinner dir="up" animated />
<h1>FilePizza</h1>
<p>Processing...</p>
</div>
case 'uploading':
return <div className="page">
<h1>FilePizza</h1>
<Spinner dir="up" animated
name={this.state.fileName}
size={this.state.fileSize} />
<p>Send someone this link to download.</p>
<small className="notice">This link will work as long as this page is open.</small>
<p>Peers: {this.state.peers} &middot; Up: {formatSize(this.state.speedUp)}</p>
<Tempalink token={this.state.token} shortToken={this.state.shortToken} />
</div>
}
}
}

@ -1,41 +0,0 @@
import Arrow from '@app/components/Arrow';
import React from 'react';
import UploadActions from '@app/actions/UploadActions';
export default class UploadPage extends React.Component {
constructor() {
super()
this.uploadFile = this.uploadFile.bind(this)
}
uploadFile(file) {
UploadActions.uploadFile(file);
}
render() {
switch (this.props.status) {
case 'ready':
return <div>
<DropZone onDrop={this.uploadFile} />
<Arrow dir="up" />
</div>;
break;
case 'processing':
return <div>
<Arrow dir="up" animated />
<FileDescription file={this.props.file} />
</div>;
break;
case 'uploading':
return <div>
<Arrow dir="up" animated />
<FileDescription file={this.props.file} />
<Temaplink token={this.props.token} />
</div>;
break;
}
}
}

@ -0,0 +1,72 @@
'use client'
import React, { JSX, useCallback } from 'react'
import { UploadedFile, UploaderConnectionStatus } from '../types'
import { useWebRTCPeer } from './WebRTCProvider'
import QRCode from 'react-qr-code'
import Loading from './Loading'
import StopButton from './StopButton'
import { useUploaderChannel } from '../hooks/useUploaderChannel'
import { useUploaderConnections } from '../hooks/useUploaderConnections'
import { CopyableInput } from './CopyableInput'
import { ConnectionListItem } from './ConnectionListItem'
import { ErrorMessage } from './ErrorMessage'
const QR_CODE_SIZE = 128
export default function Uploader({
files,
password,
onStop,
}: {
files: UploadedFile[]
password: string
onStop: () => void
}): JSX.Element {
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..." />
}
const activeDownloaders = connections.filter(
(conn) => conn.status === UploaderConnectionStatus.Uploading,
).length
if (error) {
return <ErrorMessage message={error.message} />
}
return (
<>
<div className="flex w-full items-center">
<div className="flex-none mr-4">
<QRCode value={shortURL ?? ''} size={QR_CODE_SIZE} />
</div>
<div className="flex-auto flex flex-col justify-center space-y-2">
<CopyableInput label="Long URL" value={longURL ?? ''} />
<CopyableInput label="Short URL" value={shortURL ?? ''} />
</div>
</div>
<div className="mt-6 pt-4 border-t border-stone-200 dark:border-stone-700 w-full">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-semibold text-stone-400 dark:text-stone-200">
{activeDownloaders} Downloading, {connections.length} Total
</h2>
<StopButton onClick={handleStop} />
</div>
{connections.map((conn, i) => (
<ConnectionListItem key={i} conn={conn} />
))}
</div>
</>
)
}

@ -0,0 +1,95 @@
'use client'
import React, {
JSX,
useState,
useEffect,
useContext,
useCallback,
useMemo,
} from 'react'
import Loading from './Loading'
import Peer from 'peerjs'
const ICE_SERVERS: RTCConfiguration = {
iceServers: [
{
urls:
process.env.NEXT_PUBLIC_STUN_SERVER ?? 'stun:stun.l.google.com:19302',
},
],
}
export type WebRTCPeerValue = {
peer: Peer
stop: () => void
}
const WebRTCContext = React.createContext<WebRTCPeerValue | null>(null)
export const useWebRTCPeer = (): WebRTCPeerValue => {
const value = useContext(WebRTCContext)
if (value === null) {
throw new Error('useWebRTC must be used within a WebRTCProvider')
}
return value
}
let globalPeer: Peer | null = null
async function getOrCreateGlobalPeer(): Promise<Peer> {
if (!globalPeer) {
globalPeer = new Peer({
config: ICE_SERVERS,
})
}
if (globalPeer.id) {
return globalPeer
}
await new Promise<void>((resolve) => {
const listener = (id: string) => {
console.log('[WebRTCProvider] Peer ID:', id)
globalPeer?.off('open', listener)
resolve()
}
globalPeer?.on('open', listener)
})
return globalPeer
}
export default function WebRTCPeerProvider({
children,
}: {
children?: React.ReactNode
}): JSX.Element {
const [peerValue, setPeerValue] = useState<Peer | null>(globalPeer)
const [isStopped, setIsStopped] = useState(false)
const stop = useCallback(() => {
globalPeer?.destroy()
globalPeer = null
setPeerValue(null)
setIsStopped(true)
}, [])
useEffect(() => {
getOrCreateGlobalPeer().then(setPeerValue)
}, [])
const value = useMemo(() => ({ peer: peerValue!, stop }), [peerValue, stop])
if (isStopped) {
return <></>
}
if (!peerValue) {
return <Loading text="Initializing WebRTC peer..." />
}
return (
<WebRTCContext.Provider value={value}>{children}</WebRTCContext.Provider>
)
}

@ -0,0 +1,45 @@
import { JSX } from 'react'
export default function Wordmark(): JSX.Element {
return (
<svg
width="972"
height="212"
viewBox="0 0 972 212"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-auto text-red-600 dark:brightness-0 dark:invert"
aria-label="FilePizza logo"
role="img"
>
<path
d="M870.506 211.68C859.866 211.68 851 208.04 843.906 200.76C836.813 193.48 833.266 182.093 833.266 166.6C833.266 152.787 835.973 138.32 841.386 123.2C846.986 107.893 855.2 95.0133 866.026 84.56C877.04 73.92 890.106 68.6 905.226 68.6C912.88 68.6 918.573 69.9066 922.306 72.52C926.04 75.1333 927.906 78.5866 927.906 82.88V84.84L930.986 70H971.306L951.146 165.2C950.4 168 950.026 170.987 950.026 174.16C950.026 177.893 950.866 180.6 952.546 182.28C954.413 183.773 957.4 184.52 961.506 184.52C964.12 184.52 966.173 184.147 967.666 183.4C963.56 193.853 959.64 201.227 955.906 205.52C952.173 209.627 946.76 211.68 939.666 211.68C932.013 211.68 925.76 209.44 920.906 204.96C916.24 200.293 913.346 193.853 912.226 185.64C900.84 203 886.933 211.68 870.506 211.68ZM888.706 184.52C893.373 184.52 897.946 182.373 902.426 178.08C907.093 173.6 910.266 167.533 911.946 159.88L925.386 96.6C925.386 94.1733 924.453 91.84 922.586 89.6C920.72 87.1733 917.826 85.96 913.906 85.96C906.44 85.96 899.72 90.3466 893.746 99.12C887.773 107.707 883.106 118.16 879.746 130.48C876.386 142.613 874.706 153.347 874.706 162.68C874.706 172.013 876.013 177.987 878.626 180.6C881.426 183.213 884.786 184.52 888.706 184.52Z"
fill="currentColor"
/>
<path
d="M788.541 85.12H772.861C767.634 85.12 763.714 85.4 761.101 85.96C758.674 86.52 757.461 87.7333 757.461 89.6C757.461 89.9733 757.927 90.44 758.861 91C759.981 91.3733 760.541 92.96 760.541 95.76C760.541 100.613 759.047 104.347 756.061 106.96C753.074 109.387 749.527 110.6 745.421 110.6C741.874 110.6 738.794 109.573 736.181 107.52C733.754 105.28 732.541 102.2 732.541 98.28C732.541 94.36 733.847 90.16 736.461 85.68C739.261 81.2 743.087 77.4667 747.941 74.48C752.981 71.4933 758.674 70 765.021 70H836.981L768.381 188.44C769.501 188.44 771.554 188.627 774.541 189C780.887 189.747 785.927 190.12 789.661 190.12C797.687 190.12 802.074 188.253 802.821 184.52C801.141 184.333 799.834 183.773 798.901 182.84C797.967 181.72 797.501 180.32 797.501 178.64C797.501 175.653 798.901 172.947 801.701 170.52C804.501 167.907 808.327 166.6 813.181 166.6C817.287 166.6 820.367 167.813 822.421 170.24C824.474 172.667 825.501 175.933 825.501 180.04C825.501 184.52 824.101 189.093 821.301 193.76C818.501 198.427 814.487 202.347 809.261 205.52C804.034 208.507 798.061 210 791.341 210H717.141L788.541 85.12Z"
fill="currentColor"
/>
<path
d="M585.619 54.88C579.459 54.88 574.233 52.7333 569.939 48.44C565.646 44.1467 563.499 38.92 563.499 32.76C563.499 26.6 565.646 21.3733 569.939 17.08C574.233 12.6 579.459 10.36 585.619 10.36C591.779 10.36 597.006 12.6 601.299 17.08C605.779 21.3733 608.019 26.6 608.019 32.76C608.019 38.92 605.779 44.1467 601.299 48.44C597.006 52.7333 591.779 54.88 585.619 54.88ZM566.579 211.68C557.619 211.68 550.339 208.88 544.739 203.28C539.326 197.68 536.619 189.28 536.619 178.08C536.619 173.413 537.366 167.347 538.859 159.88L557.899 70H598.219L578.059 165.2C577.313 168 576.939 170.987 576.939 174.16C576.939 177.893 577.779 180.6 579.459 182.28C581.326 183.773 584.313 184.52 588.419 184.52C591.779 184.52 594.766 183.96 597.379 182.84C596.633 192.173 593.273 199.36 587.299 204.4C581.513 209.253 574.606 211.68 566.579 211.68ZM668.779 85.12H653.099C647.873 85.12 643.953 85.4 641.339 85.96C638.913 86.52 637.699 87.7333 637.699 89.6C637.699 89.9733 638.166 90.44 639.099 91C640.219 91.3733 640.779 92.96 640.779 95.76C640.779 100.613 639.286 104.347 636.299 106.96C633.313 109.387 629.766 110.6 625.659 110.6C622.113 110.6 619.033 109.573 616.419 107.52C613.993 105.28 612.779 102.2 612.779 98.28C612.779 94.36 614.086 90.16 616.699 85.68C619.499 81.2 623.326 77.4667 628.179 74.48C633.219 71.4933 638.913 70 645.259 70H717.219L648.619 188.44C649.739 188.44 651.793 188.627 654.779 189C661.126 189.747 666.166 190.12 669.899 190.12C677.926 190.12 682.313 188.253 683.059 184.52C681.379 184.333 680.073 183.773 679.139 182.84C678.206 181.72 677.739 180.32 677.739 178.64C677.739 175.653 679.139 172.947 681.939 170.52C684.739 167.907 688.566 166.6 693.419 166.6C697.526 166.6 700.606 167.813 702.659 170.24C704.713 172.667 705.739 175.933 705.739 180.04C705.739 184.52 704.339 189.093 701.539 193.76C698.739 198.427 694.726 202.347 689.499 205.52C684.273 208.507 678.299 210 671.579 210H597.379L668.779 85.12Z"
fill="currentColor"
/>
<path
d="M442.752 31.92L484.192 26.32L464.312 119.84C473.272 119.093 481.485 115.173 488.952 108.08C496.605 100.987 502.578 92.2133 506.872 81.76C511.165 71.3067 513.312 61.04 513.312 50.96C513.312 39.76 510.418 30.6133 504.632 23.52C498.845 16.4267 490.165 12.88 478.592 12.88C455.818 12.88 437.992 18.8533 425.112 30.8C412.418 42.7467 406.072 59.4533 406.072 80.92C406.072 87.8267 406.725 92.68 408.032 95.48C409.338 98.0933 409.992 99.5867 409.992 99.96C399.912 99.96 392.352 97.9067 387.312 93.8C382.458 89.5067 380.032 82.5067 380.032 72.8C380.032 60.8533 384.885 49.28 394.592 38.08C404.485 26.6933 417.085 17.5467 432.392 10.64C447.698 3.54667 463.005 0 478.312 0C493.058 0 505.378 2.52 515.272 7.56C525.165 12.6 532.445 19.32 537.112 27.72C541.965 35.9333 544.392 45.08 544.392 55.16C544.392 67.2933 541.032 79.1467 534.312 90.72C527.778 102.293 518.352 111.813 506.032 119.28C493.712 126.56 479.525 130.2 463.472 130.2H462.072L444.992 210H404.672L442.752 31.92Z"
fill="currentColor"
/>
<path
d="M315.293 211.68C301.666 211.68 291.026 208.133 283.372 201.04C275.719 193.76 271.892 182.467 271.892 167.16C271.892 154.28 274.413 140.093 279.453 124.6C284.493 109.107 292.706 95.76 304.092 84.56C315.479 73.1733 329.946 67.48 347.492 67.48C368.026 67.48 378.293 76.44 378.293 94.36C378.293 104.813 375.306 114.427 369.333 123.2C363.359 131.973 355.426 139.067 345.533 144.48C335.639 149.707 325.093 152.693 313.893 153.44C313.519 157.547 313.332 160.347 313.332 161.84C313.332 177.333 319.119 185.08 330.693 185.08C335.919 185.08 341.519 183.68 347.492 180.88C353.466 178.08 358.879 174.533 363.733 170.24C358.693 197.867 342.546 211.68 315.293 211.68ZM315.853 140C322.946 139.813 329.573 137.48 335.733 133C342.079 128.52 347.119 122.827 350.853 115.92C354.773 108.827 356.733 101.453 356.733 93.8C356.733 86.1466 354.399 82.32 349.733 82.32C343.199 82.32 336.666 88.2933 330.133 100.24C323.786 112 319.026 125.253 315.853 140Z"
fill="currentColor"
/>
<path
d="M228.884 211.68C219.924 211.68 212.644 208.88 207.044 203.28C201.631 197.68 198.924 189.28 198.924 178.08C198.924 173.413 199.671 167.347 201.164 159.88L231.124 19.6L272.564 14L240.364 165.2C239.617 168 239.244 170.987 239.244 174.16C239.244 177.893 240.084 180.6 241.764 182.28C243.631 183.773 246.617 184.52 250.724 184.52C256.137 184.52 261.177 182.28 265.844 177.8C270.511 173.133 273.871 167.16 275.924 159.88H287.684C280.777 180.04 271.911 193.76 261.084 201.04C250.257 208.133 239.524 211.68 228.884 211.68Z"
fill="currentColor"
/>
<path
d="M68.56 22.96H65.48C53.16 22.96 43.5467 27.2533 36.64 35.84C29.92 44.24 26.56 59.2667 26.56 80.92C26.56 87.8267 27.2133 92.68 28.52 95.48C29.8267 98.0933 30.48 99.5867 30.48 99.96C20.4 99.96 12.84 97.9067 7.79999 93.8C2.94666 89.5067 0.519989 82.5067 0.519989 72.8C0.519989 59.5467 3.78666 47.5067 10.32 36.68C16.8533 25.6667 27.2133 16.8 41.4 10.08C55.7733 3.36 73.9733 0 96 0C102.72 0 111.96 0.84 123.72 2.52C124.84 2.70668 129.227 3.26668 136.88 4.20001C144.72 5.13334 152.653 5.60001 160.68 5.60001C174.68 5.60001 188.213 3.82668 201.28 0.28001C199.04 12.6 195.213 22.96 189.8 31.36C184.387 39.5733 176.64 43.68 166.56 43.68C159.84 43.68 153.867 43.0267 148.64 41.72C143.413 40.2267 137.16 38.08 129.88 35.28C122.413 32.48 115.04 30.0533 107.76 28L95.72 84H131L126.52 104.72H91.24L68.56 210H28.24L68.56 22.96ZM154.24 211.68C145.28 211.68 138 208.88 132.4 203.28C126.987 197.68 124.28 189.28 124.28 178.08C124.28 173.413 125.027 167.347 126.52 159.88L145.56 70H185.88L165.72 165.2C164.973 168 164.6 170.987 164.6 174.16C164.6 177.893 165.44 180.6 167.12 182.28C168.987 183.773 171.973 184.52 176.08 184.52C181.493 184.52 186.533 182.28 191.2 177.8C195.867 173.133 199.227 167.16 201.28 159.88H213.04C206.133 180.04 197.267 193.76 186.44 201.04C175.613 208.133 164.88 211.68 154.24 211.68Z"
fill="currentColor"
/>
</svg>
)
}

@ -0,0 +1,28 @@
import toppings from './toppings'
export default {
redisURL: 'redis://localhost:6379/0',
channel: {
ttl: 60 * 60, // 1 hour
},
bodyKeys: {
uploaderPeerID: {
min: 3,
max: 256,
},
slug: {
min: 3,
max: 256,
},
},
shortSlug: {
numChars: 8,
chars: '0123456789abcdefghijklmnopqrstuvwxyz',
maxAttempts: 8,
},
longSlug: {
numWords: 4,
words: toppings,
maxAttempts: 8,
},
}

@ -1,58 +0,0 @@
import toppings from './toppings'
import xkcdPassword from 'xkcd-password'
const TOKEN_OPTIONS = {
numWords: 4,
minLength: 3,
maxLength: 20
}
const SHORT_TOKEN_OPTIONS = {
length: 8,
chars: '0123456789abcdefghijklmnopqrstuvwxyz'
}
let tokens = {}
let shortTokens = {}
const tokenGenerator = new xkcdPassword()
tokenGenerator.initWithWordList(toppings)
function generateShortToken() {
var result = '';
for (var i = SHORT_TOKEN_OPTIONS.length; i > 0; --i)
result += SHORT_TOKEN_OPTIONS.chars[Math.floor(Math.random() * SHORT_TOKEN_OPTIONS.chars.length)];
return result;
}
export function create(socket) {
return tokenGenerator.generate(TOKEN_OPTIONS).then((parts) => {
const token = parts.join('/')
const shortToken = generateShortToken()
let result = {
token: token,
shortToken: shortToken,
socket: socket
}
tokens[token] = result
shortTokens[shortToken] = result
return result
})
}
export function find(token) {
return tokens[token]
}
export function findShort(shortToken) {
return shortTokens[shortToken.toLowerCase()]
}
export function remove(client) {
if (client == null) return
delete tokens[client.token]
delete shortTokens[client.shortToken]
}

@ -0,0 +1,83 @@
import { UploadedFile } from './types'
const getAsFile = (entry: any): Promise<File> =>
new Promise((resolve, reject) => {
entry.file((file: UploadedFile) => {
file.entryFullPath = entry.fullPath
resolve(file)
}, reject)
})
const readDirectoryEntries = (reader: any): Promise<any[]> =>
new Promise((resolve, reject) => {
reader.readEntries((entries) => {
resolve(entries)
}, reject)
})
const scanDirectoryEntry = async (entry: any): Promise<File[]> => {
const directoryReader = entry.createReader()
const result: File[] = []
// eslint-disable-next-line no-constant-condition
while (true) {
const subentries = await readDirectoryEntries(directoryReader)
if (!subentries.length) {
return result
}
for (const se of subentries) {
if (se.isDirectory) {
const ses = await scanDirectoryEntry(se)
result.push(...ses)
} else {
const file = await getAsFile(se)
result.push(file)
}
}
}
}
export const extractFileList = async (
e: React.DragEvent | DragEvent,
): Promise<File[]> => {
if (!e.dataTransfer || !e.dataTransfer.items.length) {
return []
}
const items = e.dataTransfer.items
const scans: Promise<File[]>[] = []
const files: Promise<File>[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
const entry = item.webkitGetAsEntry()
if (entry) {
if (entry.isDirectory) {
scans.push(scanDirectoryEntry(entry))
} else {
files.push(getAsFile(entry))
}
}
}
const scanResults = await Promise.all(scans)
const fileResults = await Promise.all(files)
return scanResults.flat().concat(fileResults)
}
// Borrowed from StackOverflow
// http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
export const formatSize = (bytes: number): string => {
if (bytes === 0) {
return '0 Bytes'
}
const k = 1000
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toPrecision(3)} ${sizes[i]}`
}
export const getFileName = (file: UploadedFile): string => {
return file.name ?? file.entryFullPath ?? ''
}

@ -0,0 +1,31 @@
import { useState, useCallback, useEffect } from 'react'
export default function useClipboard(
text: string,
delay = 1000,
): {
hasCopied: boolean
onCopy: () => void
} {
const [hasCopied, setHasCopied] = useState(false)
const onCopy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setHasCopied(true)
})
}, [text])
useEffect(() => {
let timeoutId: NodeJS.Timeout
if (hasCopied) {
timeoutId = setTimeout(() => {
setHasCopied(false)
}, delay)
}
return () => {
clearTimeout(timeoutId)
}
}, [hasCopied, delay])
return { hasCopied, onCopy }
}

@ -0,0 +1,257 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useWebRTCPeer } from '../components/WebRTCProvider'
import { z } from 'zod'
import { ChunkMessage, decodeMessage, Message, MessageType } from '../messages'
import { DataConnection } from 'peerjs'
import {
streamDownloadSingleFile,
streamDownloadMultipleFiles,
} from '../utils/download'
import {
browserName,
browserVersion,
osName,
osVersion,
mobileVendor,
mobileModel,
} from 'react-device-detect'
const cleanErrorMessage = (errorMessage: string): string =>
errorMessage.startsWith('Could not connect to peer')
? 'Could not connect to the uploader. Did they close their browser?'
: errorMessage
const getZipFilename = (): string => `filepizza-download-${Date.now()}.zip`
export function useDownloader(uploaderPeerID: string): {
filesInfo: Array<{ fileName: string; size: number; type: string }> | null
isConnected: boolean
isPasswordRequired: boolean
isDownloading: boolean
isDone: boolean
errorMessage: string | null
submitPassword: (password: string) => void
startDownload: () => void
stopDownload: () => void
totalSize: number
bytesDownloaded: number
} {
const { peer } = useWebRTCPeer()
const [dataConnection, setDataConnection] = useState<DataConnection | null>(
null,
)
const [filesInfo, setFilesInfo] = useState<Array<{
fileName: string
size: number
type: string
}> | null>(null)
const processChunk = useRef<
((message: z.infer<typeof ChunkMessage>) => void) | null
>(null)
const [isConnected, setIsConnected] = useState(false)
const [isPasswordRequired, setIsPasswordRequired] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [isDone, setDone] = useState(false)
const [bytesDownloaded, setBytesDownloaded] = useState(0)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
useEffect(() => {
if (!peer) return
console.log('[Downloader] connecting to uploader', uploaderPeerID)
const conn = peer.connect(uploaderPeerID, { reliable: true })
setDataConnection(conn)
const handleOpen = () => {
setIsConnected(true)
conn.send({
type: MessageType.RequestInfo,
browserName,
browserVersion,
osName,
osVersion,
mobileVendor,
mobileModel,
} as z.infer<typeof Message>)
}
const handleData = (data: unknown) => {
try {
const message = decodeMessage(data)
switch (message.type) {
case MessageType.PasswordRequired:
setIsPasswordRequired(true)
if (message.errorMessage) setErrorMessage(message.errorMessage)
break
case MessageType.Info:
setFilesInfo(message.files)
break
case MessageType.Chunk:
processChunk.current?.(message)
break
case MessageType.Error:
console.error(message.error)
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)
}
}
const handleClose = () => {
setDataConnection(null)
setIsConnected(false)
setIsDownloading(false)
}
const handleError = (err: Error) => {
console.error(err)
setErrorMessage(cleanErrorMessage(err.message))
if (conn.open) conn.close()
else handleClose()
}
conn.on('open', handleOpen)
conn.on('data', handleData)
conn.on('error', handleError)
conn.on('close', handleClose)
peer.on('error', handleError)
return () => {
if (conn.open) {
conn.close()
} else {
conn.once('open', () => {
conn.close()
})
}
conn.off('open', handleOpen)
conn.off('data', handleData)
conn.off('error', handleError)
conn.off('close', handleClose)
peer.off('error', handleError)
}
}, [peer])
const submitPassword = useCallback(
(pass: string) => {
if (!dataConnection) return
dataConnection.send({
type: MessageType.UsePassword,
password: pass,
} as z.infer<typeof Message>)
},
[dataConnection],
)
const startDownload = useCallback(() => {
if (!filesInfo || !dataConnection) return
setIsDownloading(true)
const fileStreamByPath: Record<
string,
{
stream: ReadableStream<Uint8Array>
enqueue: (chunk: Uint8Array) => void
close: () => void
}
> = {}
const fileStreams = filesInfo.map((info) => {
let enqueue: ((chunk: Uint8Array) => void) | null = null
let close: (() => void) | null = null
const stream = new ReadableStream<Uint8Array>({
start(ctrl) {
enqueue = (chunk: Uint8Array) => ctrl.enqueue(chunk)
close = () => ctrl.close()
},
})
if (!enqueue || !close)
throw new Error('Failed to initialize stream controllers')
fileStreamByPath[info.fileName] = { stream, enqueue, close }
return stream
})
let nextFileIndex = 0
const startNextFileOrFinish = () => {
if (nextFileIndex >= filesInfo.length) return
dataConnection.send({
type: MessageType.Start,
fileName: filesInfo[nextFileIndex].fileName,
offset: 0,
} as z.infer<typeof Message>)
nextFileIndex++
}
processChunk.current = (message: z.infer<typeof ChunkMessage>) => {
const fileStream = fileStreamByPath[message.fileName]
if (!fileStream) {
console.error('no stream found for ' + message.fileName)
return
}
setBytesDownloaded((bd) => bd + (message.bytes as ArrayBuffer).byteLength)
fileStream.enqueue(new Uint8Array(message.bytes as ArrayBuffer))
if (message.final) {
fileStream.close()
startNextFileOrFinish()
}
}
const downloads = filesInfo.map((info, i) => ({
name: info.fileName.replace(/^\//, ''),
size: info.size,
stream: () => fileStreams[i],
}))
const downloadPromise =
downloads.length > 1
? streamDownloadMultipleFiles(downloads, getZipFilename())
: streamDownloadSingleFile(downloads[0], downloads[0].name)
downloadPromise
.then(() => {
dataConnection.send({ type: MessageType.Done } as z.infer<
typeof Message
>)
setDone(true)
})
.catch(console.error)
startNextFileOrFinish()
}, [dataConnection, filesInfo])
const stopDownload = useCallback(() => {
// TODO(@kern): Continue here with stop / pause logic
if (dataConnection) {
dataConnection.send({ type: MessageType.Pause })
dataConnection.close()
}
setIsDownloading(false)
setDone(false)
setBytesDownloaded(0)
setErrorMessage(null)
// fileStreams.forEach((stream) => stream.cancel())
// fileStreams.length = 0
// Object.values(fileStreamByPath).forEach((stream) => stream.cancel())
// Object.keys(fileStreamByPath).forEach((key) => delete fileStreamByPath[key])
// }, [dataConnection, fileStreams, fileStreamByPath])
}, [dataConnection])
return {
filesInfo,
isConnected,
isPasswordRequired,
isDownloading,
isDone,
errorMessage,
submitPassword,
startDownload,
stopDownload,
totalSize: filesInfo?.reduce((acc, info) => acc + info.size, 0) ?? 0,
bytesDownloaded,
}
}

@ -0,0 +1,109 @@
import { useQuery, useMutation } from '@tanstack/react-query'
import { useEffect } from 'react'
function generateURL(slug: string): string {
const hostPrefix =
window.location.protocol +
'//' +
window.location.hostname +
(['80', '443'].includes(window.location.port)
? ''
: ':' + window.location.port)
return `${hostPrefix}/download/${slug}`
}
export function useUploaderChannel(
uploaderPeerID: string,
renewInterval = 5000,
): {
isLoading: boolean
error: Error | null
longSlug: string | undefined
shortSlug: string | undefined
longURL: string | undefined
shortURL: string | undefined
} {
const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID],
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')
}
return response.json()
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
})
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()
},
})
useEffect(() => {
if (!secret || !shortSlug) return
let timeout: NodeJS.Timeout | null = null
const run = (): void => {
timeout = setTimeout(() => {
renewMutation.mutate({ secret })
run()
}, renewInterval)
}
run()
return () => {
if (timeout) clearTimeout(timeout)
}
}, [secret, shortSlug, renewMutation, renewInterval])
useEffect(() => {
if (!shortSlug || !secret) return
const handleUnload = (): void => {
// Using sendBeacon for best-effort delivery during page unload
navigator.sendBeacon('/api/destroy', JSON.stringify({ slug: shortSlug }))
}
window.addEventListener('beforeunload', handleUnload)
window.addEventListener('unload', handleUnload)
return () => {
window.removeEventListener('beforeunload', handleUnload)
window.removeEventListener('unload', handleUnload)
}
}, [shortSlug, secret])
return {
isLoading,
error,
longSlug,
shortSlug,
longURL,
shortURL,
}
}

@ -0,0 +1,334 @@
import { useState, useEffect } from 'react'
import Peer, { DataConnection } from 'peerjs'
import {
UploadedFile,
UploaderConnection,
UploaderConnectionStatus,
} from '../types'
import { decodeMessage, Message, MessageType } from '../messages'
import { getFileName } from '../fs'
// TODO(@kern): Test for better values
const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi
function validateOffset(
files: UploadedFile[],
fileName: string,
offset: number,
): UploadedFile {
const validFile = files.find(
(file) => getFileName(file) === fileName && offset <= file.size,
)
if (!validFile) {
throw new Error('invalid file offset')
}
return validFile
}
export function useUploaderConnections(
peer: Peer,
files: UploadedFile[],
password: string,
): Array<UploaderConnection> {
const [connections, setConnections] = useState<Array<UploaderConnection>>([])
useEffect(() => {
const cleanupHandlers: Array<() => void> = []
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,
dataConnection: conn,
completedFiles: 0,
totalFiles: files.length,
currentFileProgress: 0,
}
setConnections((conns) => {
return [newConn, ...conns]
})
const updateConnection = (
fn: (c: UploaderConnection) => UploaderConnection,
) => {
setConnections((conns) =>
conns.map((c) => (c.dataConnection === conn ? fn(c) : c)),
)
}
const onData = (data: any): void => {
try {
const message = decodeMessage(data)
switch (message.type) {
case MessageType.RequestInfo: {
const newConnectionState = {
browserName: message.browserName,
browserVersion: message.browserVersion,
osName: message.osName,
osVersion: message.osVersion,
mobileVendor: message.mobileVendor,
mobileModel: message.mobileModel,
}
if (password) {
const request: Message = {
type: MessageType.PasswordRequired,
}
conn.send(request)
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Pending) {
return draft
}
return {
...draft,
...newConnectionState,
status: UploaderConnectionStatus.Authenticating,
}
})
return
}
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Pending) {
return draft
}
return {
...draft,
...newConnectionState,
status: UploaderConnectionStatus.Ready,
}
})
const fileInfo = files.map((f) => {
return {
fileName: getFileName(f),
size: f.size,
type: f.type,
}
})
const request: Message = {
type: MessageType.Info,
files: fileInfo,
}
conn.send(request)
break
}
case MessageType.UsePassword: {
const { password: submittedPassword } = message
if (submittedPassword === password) {
updateConnection((draft) => {
if (
draft.status !== UploaderConnectionStatus.Authenticating &&
draft.status !== UploaderConnectionStatus.InvalidPassword
) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.Ready,
}
})
const fileInfo = files.map((f) => ({
fileName: getFileName(f),
size: f.size,
type: f.type,
}))
const request: Message = {
type: MessageType.Info,
files: fileInfo,
}
conn.send(request)
} else {
updateConnection((draft) => {
if (
draft.status !== UploaderConnectionStatus.Authenticating
) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.InvalidPassword,
}
})
const request: Message = {
type: MessageType.PasswordRequired,
errorMessage: 'Invalid password',
}
conn.send(request)
}
break
}
case MessageType.Start: {
const fileName = message.fileName
let offset = message.offset
const file = validateOffset(files, fileName, offset)
const sendNextChunkAsync = () => {
sendChunkTimeout = setTimeout(() => {
const end = Math.min(file.size, offset + MAX_CHUNK_SIZE)
const chunkSize = end - offset
const final = chunkSize < MAX_CHUNK_SIZE
const request: Message = {
type: MessageType.Chunk,
fileName,
offset,
bytes: file.slice(offset, end),
final,
}
conn.send(request)
updateConnection((draft) => {
offset = end
if (final) {
console.log('final chunk', draft.completedFiles + 1)
return {
...draft,
status: UploaderConnectionStatus.Ready,
completedFiles: draft.completedFiles + 1,
currentFileProgress: 0,
}
} else {
sendNextChunkAsync()
return {
...draft,
uploadingOffset: end,
currentFileProgress: end / file.size,
}
}
})
}, 0)
}
updateConnection((draft) => {
if (
draft.status !== UploaderConnectionStatus.Ready &&
draft.status !== UploaderConnectionStatus.Paused
) {
return draft
}
sendNextChunkAsync()
return {
...draft,
status: UploaderConnectionStatus.Uploading,
uploadingFileName: fileName,
uploadingOffset: offset,
currentFileProgress: offset / file.size,
}
})
break
}
case MessageType.Pause: {
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Uploading) {
return draft
}
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
sendChunkTimeout = null
}
return {
...draft,
status: UploaderConnectionStatus.Paused,
}
})
break
}
case MessageType.Done: {
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Ready) {
return draft
}
conn.close()
return {
...draft,
status: UploaderConnectionStatus.Done,
}
})
break
}
}
} catch (err) {
console.error(err)
}
}
const onClose = (): void => {
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
}
updateConnection((draft) => {
if (
[
UploaderConnectionStatus.InvalidPassword,
UploaderConnectionStatus.Done,
].includes(draft.status)
) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.Closed,
}
})
}
conn.on('data', onData)
conn.on('close', onClose)
cleanupHandlers.push(() => {
conn.off('data', onData)
conn.off('close', onClose)
conn.close()
})
}
peer.on('connection', listener)
return () => {
peer.off('connection', listener)
cleanupHandlers.forEach((fn) => fn())
}
}, [peer, files, password])
return connections
}

@ -1,48 +0,0 @@
var twilio = require("twilio");
var winston = require("winston");
if (process.env.TWILIO_SID && process.env.TWILIO_TOKEN) {
var twilioSID = process.env.TWILIO_SID;
var twilioToken = process.env.TWILIO_TOKEN;
var client = twilio(twilioSID, twilioToken);
winston.info("Using Twilio TURN service");
} else {
var client = null;
}
var ICE_SERVERS = [
{
urls: "stun:stun.l.google.com:19302"
}
];
if (process.env.ICE_SERVERS) {
ICE_SERVERS = JSON.parse(process.env.ICE_SERVERS)
}
var CACHE_LIFETIME = 5 * 60 * 1000; // 5 minutes
var cachedPromise = null;
function clearCache() {
cachedPromise = null;
}
exports.getICEServers = function() {
if (client == null) return Promise.resolve(ICE_SERVERS);
if (cachedPromise) return cachedPromise;
cachedPromise = new Promise(function(resolve, reject) {
client.tokens.create({}, function(err, token) {
if (err) {
winston.error(err);
return resolve(DEFAULT_ICE_SERVERS);
}
winston.info("Retrieved ICE servers from Twilio");
setTimeout(clearCache, CACHE_LIFETIME);
resolve(token.ice_servers);
});
});
return cachedPromise;
};

@ -1,24 +0,0 @@
#! /usr/bin/env node
try {
require('../newrelic')
require('newrelic')
} catch (ex) {
// Don't load New Relic if the configuration file doesn't exist.
}
process.on('unhandledRejection', (reason, p) => {
p.catch(err => {
console.error('Exiting due to unhandled rejection!')
console.error(err)
process.exit(1)
})
})
process.on('uncaughtException', err => {
console.error('Exiting due to uncaught exception!')
console.error(err.stack)
process.exit(1)
})
module.exports = require('./server')

@ -1,419 +0,0 @@
@import "nib";
beige = #F9F2E7
dark-gray = #333
gray = #777
green = #3F6B29
light-blue = #40C0CB
light-gray = #EEE
light-green = #4BB74C
light-red = #E23430
light-yellow = #FFE476
red = #B11C17
yellow = #DEAC11
@keyframes rotate {
from { transform: rotate(0deg) }
to { transform: rotate(360deg) }
}
global-reset()
* { box-sizing: border-box }
html, body { height: 100% }
h1 {
color: red;
font: bold 56px/64px "Lobster Two", sans-serif;
text-align: center;
margin: 0 0 10px;
}
a {
color: gray
}
strong {
font-weight: bold
}
p {
color: gray;
font: 18px/22px "Quicksand", sans-serif;
text-align: center;
margin: 0;
&.notice {
margin: 10px 0;
}
}
small {
color: gray;
font: 12px/22px "Quicksand", sans-serif;
text-align: center;
margin: 0;
&.notice {
margin: 0 0 10px;
}
}
.footer {
width: 100%
text-align: center
color: gray
padding: 10px 0 10px
position: fixed
bottom: 0
border-radius: 5px 5px 0 0
background white
box-shadow 0 -2px 4px light-gray
iframe {
display: inline-block;
vertical-align: bottom;
margin-right: 10px;
}
p {
font: 12px/20px "Quicksand", sans-serif;
@media (max-width: 600px) {
font-size 14px
}
}
form p {
display: inline;
}
.byline {
padding: 0 10px;
}
@media (max-width: 600px) {
position: relative
}
}
.page {
display: flex
flex-direction: column;
justify-content: center;
height: 100%;
min-height: 640px;
padding: 40px 0 80px;
}
.drop-zone {
.drop-zone-overlay {
position: fixed
top: 0
left: 0
width: 100%
height: 100%
background: rgba(0, 0, 0, 0.5)
text-align: center
&:after {
color: white
content: 'DROP TO UPLOAD'
display: block
font: 24px/40px "Quicksand", sans-serif
margin-top: -20px
position: relative
text-shadow: 0 1px dark-gray
top: 50%
}
}
}
.spinner {
width: 300px
min-height: 300px
margin: 0 auto 20px
display: flex
flex-direction: column
align-items: center
align-content: center
justify-content: center
@media (max-width: 600px) {
width: 150px
min-height: 150px
}
&:before {
background: url(/images/pizza.png) center center / 300px 300px no-repeat
content: ""
position: absolute
width: 300px
height: 300px
transition: transform 1s
z-index: -1
@media (max-width: 600px) {
width: 150px
height: 150px
background-size: 150px 150px
}
}
&.spinner-animated:before {
animation: rotate 5s infinite linear
}
.spinner-image {
display: block;
width: 40%;
}
.spinner-name {
font: bold 18px/20px "Quicksand", sans-serif
text-align: center
color: white
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
margin-top: 10px
text-shadow: 0 0 3px #333
}
.spinner-size {
font: italic 12px/20px "Quicksand", sans-serif
text-align: center
color: white
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
text-shadow: 0 0 3px #333
}
}
.tempalink {
display: flex
width: calc(100vw - 40px)
max-width: 1024px
margin 20px auto
.long-url input {
background: beige
color: dark-gray
border: 0
margin: 0
font: 18px/1 monospace
height: 60px
padding: 20px
text-align: center
width: 100%
border-radius: 4px 4px 0 0
}
.short-url {
background: light-gray
color: dark-gray
height: 40px
padding: 10px 20px
text-align: center
width: 100%
font: 14px/1 "Quicksand", sans-serif;
border-radius: 0 0 4px 4px
span {
font: 14px/1 monospace
}
}
.qr {
flex: none
padding-right: 40px
}
.urls {
flex: auto
}
img {
height: 100px
width: 100px
display: block
}
}
@media screen and (max-width: 850px) {
.tempalink {
display: flex
flex-direction: column;
width: calc(100vw - 40px)
max-width: 1024px
margin 20px auto
.qr {
margin: auto;
justify-content: center
padding: 20px
}
.short-url {
display: flex
flex-direction: column
height: 70px
span {
word-break: break-all
padding: 2px
}
}
.long-url input {
rows: auto
}
}
}
@media screen and (max-height: 360px) {
.container {
margin-bottom: 60px
}
}
.data {
color: gray
font: 14px/20px "Quicksand", sans-serif
text-align: center
overflow: hidden
.datum {
float: left
width: 50%
}
}
.download-button {
display: block
box-shadow: inset 0 1px 1px green
padding: 20px
background: light-green
border: none
color: white
width: calc(100vw - 40px)
max-width: 800px
margin: 0 auto
border-radius: 4px
font: bold 18px/20px "Quicksand", sans-serif
transition: 0.25s
cursor: pointer
text-transform: uppercase
&:hover, &:focus {
box-shadow: inset 0 1px 1px lighten(green, 10%)
background: lighten(light-green, 10%)
}
&:active {
background: light-green
}
}
.progress-bar {
height: 60px
overflow: hidden
background: light-green
transition: all 1s ease
width: calc(100vw - 40px)
max-width: 800px
margin: 0 auto
border-radius: 4px
.progress-bar-inner {
float: left
height: 100%
background: light-green
box-shadow: inset 0 1px 1px green
overflow: hidden
border-radius: 4px
}
.progress-bar-text {
font: 14px/60px "Quicksand", sans-serif
color: white
text-align: center
text-transform: uppercase
}
&.progress-bar-failed {
background: red
box-shadow: inset 0 1px 1px light-red
.progress-bar-text {
text-align: center
}
}
&.progress-bar-in-progress {
background: beige
.progress-bar-inner {
background: #FFCC00
box-shadow: inset 0 1px 1px light-yellow
}
.progress-bar-text {
color: black
float: right
margin-right: 5px
}
}
&.progress-bar-small {
height: 30px
width: 50%
margin: 8px auto
border-radius: 5px
.progress-bar-text {
line-height: 30px
}
}
}
.select-file-label {
border: 2px solid gray
border-radius: 4px
padding: 2px 5px
margin-top: 10px
background: light-gray
display: inline-block
cursor: pointer
transition: all 0.25s ease
&:hover, &:active {
border-color: yellow
background: white
color: red
}
input[type="file"] {
position: fixed
top: -1000px
}
}
.donate-button {
border: 2px solid green
border-radius: 4px
padding: 2px 5px
margin-right: 10px
background: light-green
display: inline-block
cursor: pointer
transition: all 0.25s ease
font: 12px/1 "Quicksand", sans-serif
color: white
text-decoration: none
&:hover, &:active {
border-color: light-green
color: white
}
}

@ -0,0 +1,7 @@
import debug from 'debug'
export const error = debug('filepizza:error')
export const info = debug('filepizza:info')
export const warn = debug('filepizza:warn')

@ -0,0 +1,95 @@
import { z } from 'zod'
export enum MessageType {
RequestInfo = 'RequestInfo',
Info = 'Info',
Start = 'Start',
Chunk = 'Chunk',
Pause = 'Pause',
Done = 'Done',
Error = 'Error',
PasswordRequired = 'PasswordRequired',
UsePassword = 'UsePassword',
Report = 'Report',
}
export const RequestInfoMessage = z.object({
type: z.literal(MessageType.RequestInfo),
browserName: z.string(),
browserVersion: z.string(),
osName: z.string(),
osVersion: z.string(),
mobileVendor: z.string(),
mobileModel: z.string(),
})
export const InfoMessage = z.object({
type: z.literal(MessageType.Info),
files: z.array(
z.object({
fileName: z.string(),
size: z.number(),
type: z.string(),
}),
),
})
export const StartMessage = z.object({
type: z.literal(MessageType.Start),
fileName: z.string(),
offset: z.number(),
})
export const ChunkMessage = z.object({
type: z.literal(MessageType.Chunk),
fileName: z.string(),
offset: z.number(),
bytes: z.unknown(),
final: z.boolean(),
})
export const DoneMessage = z.object({
type: z.literal(MessageType.Done),
})
export const ErrorMessage = z.object({
type: z.literal(MessageType.Error),
error: z.string(),
})
export const PasswordRequiredMessage = z.object({
type: z.literal(MessageType.PasswordRequired),
errorMessage: z.string().optional(),
})
export const UsePasswordMessage = z.object({
type: z.literal(MessageType.UsePassword),
password: z.string(),
})
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,
StartMessage,
ChunkMessage,
DoneMessage,
ErrorMessage,
PasswordRequiredMessage,
UsePasswordMessage,
PauseMessage,
ReportMessage,
])
export type Message = z.infer<typeof Message>
export function decodeMessage(data: unknown): Message {
return Message.parse(data)
}

@ -1,35 +0,0 @@
var db = require('../db')
var express = require('express')
var routes = module.exports = new express.Router()
function bootstrap (uploader, req, res, next) {
if (uploader) {
res.locals.data = {
DownloadStore: {
status: 'ready',
token: uploader.token,
fileSize: uploader.fileSize,
fileName: uploader.fileName,
fileType: uploader.fileType,
infoHash: uploader.infoHash
}
}
next()
} else {
var err = new Error('Not Found')
err.status = 404
next(err)
}
}
routes.get(/^\/([a-z]+\/[a-z]+\/[a-z]+\/[a-z]+)$/, function (req, res, next) {
var uploader = db.find(req.params[0])
return bootstrap(uploader, req, res, next)
})
routes.get(/^\/download\/(\w+)$/, function (req, res, next) {
var uploader = db.findShort(req.params[0])
return bootstrap(uploader, req, res, next)
})

@ -1,19 +0,0 @@
module.exports = function (err, req, res, next) {
var status = err.status || 500
var message = err.message || ''
var stack = process.env.NODE_ENV === 'production' ? null : err.stack || null
req.url = '/error'
res.status(status)
res.locals.data = {
ErrorStore: {
status: status,
message: message,
stack: stack
}
}
next()
}

@ -1,16 +0,0 @@
const path = require('path')
const BUNDLE_PATH = path.resolve(__dirname, '../../dist/bundle.js')
if (process.env.NODE_ENV === 'production') {
module.exports = function (req, res) {
res.sendFile(BUNDLE_PATH)
}
} else {
const webpackMiddleware = require('webpack-dev-middleware')
const webpack = require('webpack')
const config = require('../../webpack.config.js')
config.output.filename = '/app.js'
config.output.path = '/'
module.exports = webpackMiddleware(webpack(config))
}

@ -1,30 +0,0 @@
var React = require('react')
var ReactRouter = require('react-router')
var alt = require('../alt')
var routes = require('../routes')
function isNotFound(state) {
for (var r of state.routes) {
if (r.isNotFound) return true
}
return false
}
module.exports = function (req, res) {
alt.bootstrap(JSON.stringify(res.locals.data || {}))
ReactRouter.run(routes, req.url, function (Handler, state) {
var html = React.renderToString(<Handler data={alt.takeSnapshot()} />)
alt.flush()
res.setHeader('Content-Type', 'text/html');
if (isNotFound(state)) res.status(404)
res.write('<!DOCTYPE html>\n')
res.end(html)
})
}

@ -1,6 +0,0 @@
var express = require('express')
var path = require('path')
var STATIC_PATH = path.resolve(__dirname, '../static')
module.exports = express.static(STATIC_PATH)

@ -1,18 +0,0 @@
import React from 'react'
import { Route, DefaultRoute, NotFoundRoute, RouteHandler } from 'react-router'
import App from './components/App'
import DownloadPage from './components/DownloadPage'
import UploadPage from './components/UploadPage'
import ErrorPage from './components/ErrorPage'
export default (
<Route handler={App}>
<DefaultRoute handler={UploadPage} />
<Route name="download" path="/:a/:b/:c/:d" handler={DownloadPage} />
<Route name="download-short" path="/download/:a" handler={DownloadPage} />
<Route name="error" path="error" handler={ErrorPage} />
<NotFoundRoute handler={ErrorPage} />
</Route>
)

@ -0,0 +1,53 @@
import { NextApiRequest, NextApiResponse } from 'next'
import config from './config'
export type APIError = Error & { statusCode?: number }
export type BodyKey = keyof typeof config.bodyKeys
export function throwAPIError(message: string, statusCode = 500): void {
const err = new Error(message) as APIError
err.statusCode = statusCode
throw err
}
export function routeHandler<T>(
fn: (req: NextApiRequest, res: NextApiResponse) => Promise<T>,
): (req: NextApiRequest, res: NextApiResponse) => Promise<void> {
return async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
if (req.method !== 'POST') {
res.statusCode = 405
res.json({ error: 'method not allowed' })
return
}
try {
const result = await fn(req, res)
res.statusCode = 200
res.json(result)
} catch (err) {
res.statusCode = err.statusCode || 500
res.json({ error: err.message })
}
}
}
export function getBodyKey(req: NextApiRequest, key: BodyKey): string {
const { min, max } = config.bodyKeys[key]
const val = req.body[key]
if (typeof val !== 'string') {
throwAPIError(`${key} must be a string`)
}
if (val.length < min) {
throwAPIError(`${key} must be at least ${min} chars`)
}
if (val.length > max) {
throwAPIError(`${key} must be at most ${max} chars`)
}
return val
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save