pull/134/head
Alex Kern 12 months ago
parent 248df834c1
commit 950694f3b6
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

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

@ -9,7 +9,8 @@ module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
"plugin:@typescript-eslint/recommended",
"plugin:@next/next/recommended"
],
plugins: [
"@typescript-eslint",
@ -17,9 +18,9 @@ module.exports = {
rules: {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-use-before-define": ["error", { "variables": false }],
"@typescript-eslint/no-parameter-properties": ["error", { "allows": ["private readonly"] }],
"@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",

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

@ -7,6 +7,7 @@ RUN pnpm install
COPY . ./
RUN pnpm build
ENV PORT 3000
ENV NODE_ENV production
EXPOSE 80
CMD node ./dist/index.js
EXPOSE 3000
CMD pnpm start

@ -2,68 +2,52 @@
*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).
## 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
$ git clone https://github.com/kern/filepizza.git
$ npm install
$ npm run-script build
$ npm 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.
```
$ git clone https://github.com/kern/filepizza.git
$ pnpm install
$ pnpm dev
$ pnpm build
$ pnpm start
```
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
```
## 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.
## Stack
**Can multiple people download my file at once?** Yes! Just send them your tempalink.
FilePizza uses:
**How big can my files be?** Chrome has issues supporting files >500 MB. Firefox does not have any issues with large files, however.
* Next.js
* Tailwind
* TypeScript
* React
* PeerJS
* View Transitions
* Redis (optional)
**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.
## FAQ
**Are my files encrypted?** Yes, all WebRTC communications are automatically encrypted using public-key cryptography.
**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.
**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
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:
**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.
$ 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
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.

@ -4,8 +4,21 @@ services:
image: redis:latest
ports:
- 6379:6379
# coturn:
# image: instrumentisto/coturn:latest
# network_mode: host
# ports:
# - 3478:3478
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

2
next-env.d.ts vendored

@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

@ -10,12 +10,19 @@
"build": "next build",
"start": "next start",
"start:peerjs": "./bin/peerjs.js",
"lint": "eslint 'src/**/*.ts[x]'",
"lint:check": "eslint 'src/**/*.ts[x]'",
"lint:fix": "eslint 'src/**/*.ts[x]' --fix",
"docker:build": "docker build -t kern/filepizza .",
"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"
"type:check": "tsc --noEmit",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm build && pnpm docker:build"
},
"repository": {
"type": "git",
@ -30,8 +37,9 @@
"debug": "^4.3.6",
"express": "^4.19.2",
"ioredis": "^4.28.5",
"next": "^14.2.8",
"next": "^15.1.3",
"next-themes": "^0.4.4",
"next-view-transitions": "^0.3.4",
"nodemon": "^1.19.4",
"peer": "^0.5.3",
"peerjs": "^1.5.4",
@ -49,13 +57,14 @@
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/ioredis": "^4.28.10",
"@types/node": "^14.18.63",
"@types/react": "^16.14.60",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.35.1",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.3",
"husky": "^4.3.8",
"lint-staged": "^10.5.4",
"prettier": "^2.8.8",

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { Channel, channelRepo } from '../../../channel'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: Request): Promise<NextResponse> {
const { uploaderPeerID } = await request.json()
@ -11,6 +11,8 @@ export async function POST(request: Request): Promise<NextResponse> {
)
}
const channel: Channel = await channelRepo.createChannel(uploaderPeerID)
const channel = await getOrCreateChannelRepo().createChannel(
uploaderPeerID,
)
return NextResponse.json(channel)
}

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
const { slug } = await request.json()
@ -11,9 +11,10 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Anyone can destroy a channel if they know the slug. This enables a terms violation reporter to destroy the channel after they report it.
try {
await channelRepo.destroyChannel(slug)
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 },

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
const { slug, secret } = await request.json()
@ -12,6 +12,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'Secret is required' }, { status: 400 })
}
const success = await channelRepo.renewChannel(slug, secret)
const success = await getOrCreateChannelRepo().renewChannel(slug, secret)
return NextResponse.json({ success })
}

@ -1,5 +1,6 @@
import { JSX } from 'react'
import { notFound } from 'next/navigation'
import { channelRepo } from '../../../channel'
import { getOrCreateChannelRepo } from '../../../channel'
import Spinner from '../../../components/Spinner'
import Wordmark from '../../../components/Wordmark'
import Downloader from '../../../components/Downloader'
@ -17,10 +18,11 @@ const normalizeSlug = (rawSlug: string | string[]): string => {
export default async function DownloadPage({
params,
}: {
params: { slug: string[] }
params: Promise<{ slug: string[] }>
}): Promise<JSX.Element> {
const slug = normalizeSlug(params.slug)
const channel = await channelRepo.fetchChannel(slug)
const { slug: slugRaw } = await params
const slug = normalizeSlug(slugRaw)
const channel = await getOrCreateChannelRepo().fetchChannel(slug)
if (!channel) {
notFound()
@ -32,7 +34,10 @@ export default async function DownloadPage({
<Wordmark />
<WebRTCPeerProvider>
<Downloader uploaderPeerID={channel.uploaderPeerID} />
<ReportTermsViolationButton uploaderPeerID={channel.uploaderPeerID} slug={slug} />
<ReportTermsViolationButton
uploaderPeerID={channel.uploaderPeerID}
slug={slug}
/>
</WebRTCPeerProvider>
</div>
)

@ -5,6 +5,7 @@ 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.',
@ -22,7 +23,7 @@ export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false
userScalable: false,
}
export default function RootLayout({
@ -31,8 +32,9 @@ export default function RootLayout({
children: React.ReactNode
}): React.ReactElement {
return (
<html lang="en" suppressHydrationWarning>
<head>
<ViewTransitions>
<html lang="en" suppressHydrationWarning>
<head>
<meta name="monetization" content="$twitter.xrptipbot.com/kernio" />
</head>
<body>
@ -43,7 +45,8 @@ export default function RootLayout({
<ModeToggle />
</FilePizzaQueryClientProvider>
</ThemeProvider>
</body>
</html>
</body>
</html>
</ViewTransitions>
)
}

@ -1,3 +1,4 @@
import { JSX } from 'react'
import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark'
import ReturnHome from '../components/ReturnHome'
@ -11,9 +12,9 @@ export const metadata = {
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="up" />
<Spinner direction="down" />
<Wordmark />
<TitleText>404: This slice of FilePizza is missing!</TitleText>
<TitleText>404: Looks like this slice of FilePizza got eaten!</TitleText>
<ReturnHome />
</div>
)

@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useState } from 'react'
import React, { JSX, useCallback, useState } from 'react'
import WebRTCPeerProvider from '../components/WebRTCProvider'
import DropZone from '../components/DropZone'
import UploadFileList from '../components/UploadFileList'

@ -1,3 +1,4 @@
import { JSX } from 'react'
import Spinner from '../../components/Spinner'
import Wordmark from '../../components/Wordmark'
import TitleText from '../../components/TitleText'
@ -8,14 +9,17 @@ export default function ReportedPage(): JSX.Element {
<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>
<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.
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

@ -22,23 +22,90 @@ const ChannelSchema = z.object({
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, secret: string): Promise<void>
renewChannel(slug: string, secret: string, ttl?: number): Promise<boolean>
destroyChannel(slug: string): Promise<void>
}
export class RedisChannelRepo implements ChannelRepo {
client: Redis.Redis
function getShortSlugKey(shortSlug: string): string {
return `short:${shortSlug}`
}
constructor(redisURL: string) {
this.client = new Redis(redisURL)
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 this.generateShortSlug()
const longSlug = await this.generateLongSlug()
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(),
@ -46,10 +113,18 @@ export class RedisChannelRepo implements ChannelRepo {
shortSlug,
uploaderPeerID,
}
const channelStr = this.serializeChannel(channel)
await this.client.setex(this.getLongSlugKey(longSlug), ttl, channelStr)
await this.client.setex(this.getShortSlugKey(shortSlug), ttl, channelStr)
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
}
@ -58,14 +133,20 @@ export class RedisChannelRepo implements ChannelRepo {
slug: string,
scrubSecret = false,
): Promise<Channel | null> {
const shortChannelStr = await this.client.get(this.getShortSlugKey(slug))
if (shortChannelStr) {
return this.deserializeChannel(shortChannelStr, scrubSecret)
const shortKey = getShortSlugKey(slug)
const shortChannel = this.channels.get(shortKey)
if (shortChannel) {
return scrubSecret
? { ...shortChannel.channel, secret: undefined }
: shortChannel.channel
}
const longChannelStr = await this.client.get(this.getLongSlugKey(slug))
if (longChannelStr) {
return this.deserializeChannel(longChannelStr, scrubSecret)
const longKey = getLongSlugKey(slug)
const longChannel = this.channels.get(longKey)
if (longChannel) {
return scrubSecret
? { ...longChannel.channel, secret: undefined }
: longChannel.channel
}
return null
@ -81,8 +162,17 @@ export class RedisChannelRepo implements ChannelRepo {
return false
}
await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl)
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
}
@ -93,55 +183,114 @@ export class RedisChannelRepo implements ChannelRepo {
return
}
await this.client.del(this.getLongSlugKey(channel.longSlug))
await this.client.del(this.getShortSlugKey(channel.shortSlug))
}
const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug)
private async generateShortSlug(): Promise<string> {
for (let i = 0; i < config.shortSlug.maxAttempts; i++) {
const slug = generateShortSlug()
const currVal = await this.client.get(this.getShortSlugKey(slug))
if (!currVal) {
return slug
}
// Clear timeouts
const shortTimeout = this.timeouts.get(shortKey)
if (shortTimeout) {
clearTimeout(shortTimeout)
this.timeouts.delete(shortKey)
}
throw new Error('max attempts reached generating short slug')
const longTimeout = this.timeouts.get(longKey)
if (longTimeout) {
clearTimeout(longTimeout)
this.timeouts.delete(longKey)
}
this.channels.delete(longKey)
this.channels.delete(shortKey)
}
}
private async generateLongSlug(): Promise<string> {
for (let i = 0; i < config.longSlug.maxAttempts; i++) {
const slug = await generateLongSlug()
const currVal = await this.client.get(this.getLongSlugKey(slug))
if (!currVal) {
return slug
}
}
export class RedisChannelRepo implements ChannelRepo {
client: Redis.Redis
throw new Error('max attempts reached generating long slug')
constructor(redisURL: string) {
this.client = new Redis(redisURL)
}
private getShortSlugKey(shortSlug: string): string {
return `short:${shortSlug}`
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
}
private getLongSlugKey(longSlug: string): string {
return `long:${longSlug}`
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
}
private serializeChannel(channel: Channel): string {
return JSON.stringify(channel)
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
}
private deserializeChannel(str: string, scrubSecret = false): Channel {
const parsedChannel = JSON.parse(str)
const validatedChannel = ChannelSchema.parse(parsedChannel)
if (scrubSecret) {
return { ...validatedChannel, secret: undefined }
async destroyChannel(slug: string): Promise<void> {
const channel = await this.fetchChannel(slug)
if (!channel) {
return
}
return validatedChannel
await this.client.del(getLongSlugKey(channel.longSlug))
await this.client.del(getShortSlugKey(channel.shortSlug))
}
}
export const channelRepo = new RedisChannelRepo(config.redisURL)
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,16 +1,18 @@
import React from 'react'
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"
>
Cancel
{text}
</button>
)
}

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
import { UploaderConnection, UploaderConnectionStatus } from '../types'
import ProgressBar from './ProgressBar'
@ -51,11 +51,12 @@ export function ConnectionListItem({
<div>
Completed: {conn.completedFiles} / {conn.totalFiles} files
</div>
{conn.uploadingFileName && conn.status === UploaderConnectionStatus.Uploading && (
<div>
Current file: {Math.round(conn.currentFileProgress * 100)}%
</div>
)}
{conn.uploadingFileName &&
conn.status === UploaderConnectionStatus.Uploading && (
<div>
Current file: {Math.round(conn.currentFileProgress * 100)}%
</div>
)}
</div>
</div>
<ProgressBar

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
import useClipboard from '../hooks/useClipboard'
import InputLabel from './InputLabel'

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
export default function DownloadButton({
onClick,

@ -1,6 +1,6 @@
'use client'
import React, { useState, useCallback, useEffect } from 'react'
import React, { JSX, useState, useCallback, useEffect } from 'react'
import { useDownloader } from '../hooks/useDownloader'
import PasswordField from './PasswordField'
import UnlockButton from './UnlockButton'
@ -20,7 +20,11 @@ interface FileInfo {
type: string
}
export function ConnectingToUploader({ showTroubleshootingAfter = 3000 }: { showTroubleshootingAfter?: number }): JSX.Element {
export function ConnectingToUploader({
showTroubleshootingAfter = 3000,
}: {
showTroubleshootingAfter?: number
}): JSX.Element {
const [showTroubleshooting, setShowTroubleshooting] = useState(false)
useEffect(() => {
@ -37,44 +41,55 @@ export function ConnectingToUploader({ showTroubleshootingAfter = 3000 }: { show
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"
>
<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>
<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>
<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>
<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>
<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"
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.
</a>{' '}
relay servers. This means it may not work on all networks.
</p>
</div>
</div>

@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import React, { JSX, useState, useCallback, useEffect, useRef } from 'react'
import { extractFileList } from '../fs'
export default function DropZone({

@ -1,3 +1,5 @@
import { JSX } from 'react'
export function ErrorMessage({ message }: { message: string }): JSX.Element {
return (
<div

@ -1,6 +1,6 @@
'use client'
import React, { useCallback } from 'react'
import React, { JSX, useCallback } from 'react'
const DONATE_HREF =
'https://commerce.coinbase.com/checkout/247b6ffe-fb4e-47a8-9a76-e6b7ef83ea22'

@ -1,19 +1,40 @@
import React from 'react'
import React, { JSX } from 'react'
export default function InputLabel({
children,
hasError = false,
tooltip,
}: {
children: React.ReactNode
hasError?: boolean
tooltip?: string
}): JSX.Element {
return (
<label
className={`text-[10px] mb-0.5 font-bold ${
hasError ? 'text-red-500' : 'text-stone-400'
}`}
>
{children}
</label>
<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>
)
}

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
export default function Loading({ text }: { text: string }): JSX.Element {
return (

@ -1,7 +1,7 @@
'use client'
import { useTheme } from 'next-themes'
import { JSX } from 'react'
function LightModeIcon(): JSX.Element {
return (
<svg

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'
import React, { JSX, useCallback } from 'react'
import InputLabel from './InputLabel'
export default function PasswordField({
@ -21,7 +21,10 @@ export default function PasswordField({
return (
<div className="flex flex-col w-full">
<InputLabel hasError={isInvalid}>
<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

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
export default function ProgressBar({
value,

@ -1,10 +1,18 @@
'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 {
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)
@ -20,7 +28,7 @@ export default function ReportTermsViolationButton({ uploaderPeerID, slug }: { u
throw new Error('Failed to report violation')
}
return response.json()
}
},
})
const handleReport = useCallback(() => {
@ -31,8 +39,10 @@ export default function ReportTermsViolationButton({ uploaderPeerID, slug }: { u
// 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' } })
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()
@ -63,54 +73,58 @@ export default function ReportTermsViolationButton({ uploaderPeerID, slug }: { u
</div>
{showModal && (
<div
<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
<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()}
onClick={(e) => e.stopPropagation()}
>
<h2
id="modal-title"
<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>
<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>
<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>
<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>
<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>
<p>
If you've spotted a violation of these terms, click Report to
halt its delivery.
</p>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
disabled={isReporting}
onClick={() => setShowModal(false)}
className="px-4 py-2 text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200 transition-colors"
aria-label="Cancel report"
>
Cancel
</button>
<CancelButton onClick={() => setShowModal(false)} />
<button
disabled={isReporting}
onClick={handleReport}
@ -125,4 +139,4 @@ export default function ReportTermsViolationButton({ uploaderPeerID, slug }: { u
)}
</>
)
}
}

@ -1,4 +1,5 @@
import Link from 'next/link'
import { Link } from 'next-view-transitions'
import { JSX } from 'react'
export default function ReturnHome(): JSX.Element {
return (

File diff suppressed because it is too large Load Diff

@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { JSX, useState } from 'react'
import CancelButton from './CancelButton'
export default function TermsAcceptance(): JSX.Element {
const [showModal, setShowModal] = useState(false)
@ -16,60 +17,70 @@ export default function TermsAcceptance(): JSX.Element {
aria-label="View upload terms"
>
our terms
</button>.
</button>
.
</span>
</div>
{showModal && (
<div
<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
<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()}
onClick={(e) => e.stopPropagation()}
>
<h2
id="modal-title"
<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>
<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>
<span className="text-sm">
Share download links only with known recipients
</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base"></span>
<span className="text-sm">No illegal or harmful content allowed</span>
</li>
<li className="flex items-start gap-3 px-4 py-2 rounded-lg bg-stone-100 dark:bg-stone-800">
<span className="text-base">📤</span>
<span className="text-sm">Files are shared directly between browsers - no server storage</span>
<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>
<p className="text-sm italic">
By uploading a file, you confirm that you understand and agree
to these terms.
</p>
</div>
<div className="mt-6 flex justify-end">
<button
<CancelButton
text="Got it!"
onClick={() => setShowModal(false)}
className="px-4 py-2 bg-stone-200 dark:bg-stone-700 text-stone-700 dark:text-stone-300 rounded-md hover:bg-stone-300 dark:hover:bg-stone-600 transition-all duration-200"
aria-label="Close terms"
>
Got it!
</button>
/>
</div>
</div>
</div>

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
export default function TitleText({
children,

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
function getTypeColor(fileType: string): string {
if (fileType.startsWith('image/'))

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
export default function UnlockButton({
onClick,

@ -1,4 +1,4 @@
import React from 'react'
import React, { JSX } from 'react'
import TypeBadge from './TypeBadge'
type UploadedFileLike = {

@ -1,6 +1,6 @@
'use client'
import React, { useCallback } from 'react'
import React, { JSX, useCallback } from 'react'
import { UploadedFile, UploaderConnectionStatus } from '../types'
import { useWebRTCPeer } from './WebRTCProvider'
import QRCode from 'react-qr-code'

@ -1,6 +1,13 @@
'use client'
import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'
import React, {
JSX,
useState,
useEffect,
useContext,
useCallback,
useMemo,
} from 'react'
import Loading from './Loading'
import Peer from 'peerjs'
@ -76,7 +83,7 @@ export default function WebRTCPeerProvider({
const value = useMemo(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
() => ({ peer: peerValue!, stop }),
[peerValue, stop]
[peerValue, stop],
)
if (isStopped) {
@ -88,8 +95,6 @@ export default function WebRTCPeerProvider({
}
return (
<WebRTCContext.Provider value={value}>
{children}
</WebRTCContext.Provider>
<WebRTCContext.Provider value={value}>{children}</WebRTCContext.Provider>
)
}
}

@ -1,14 +1,45 @@
import React from 'react'
import Image from 'next/image'
import { JSX } from 'react'
export default function Wordmark(): JSX.Element {
return (
<Image
src="/images/wordmark.png"
className="max-h-12 dark:brightness-0 dark:invert"
alt="FilePizza Wordmark"
width={200}
height={45}
/>
<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>
)
}

@ -1,6 +1,12 @@
import { useState, useCallback, useEffect } from 'react'
export default function useClipboard(text: string, delay = 1000) {
export default function useClipboard(
text: string,
delay = 1000,
): {
hasCopied: boolean
onCopy: () => void
} {
const [hasCopied, setHasCopied] = useState(false)
const onCopy = useCallback(() => {

@ -86,10 +86,7 @@ export function useUploaderChannel(
const handleUnload = (): void => {
// Using sendBeacon for best-effort delivery during page unload
navigator.sendBeacon(
'/api/destroy',
JSON.stringify({ slug: shortSlug })
)
navigator.sendBeacon('/api/destroy', JSON.stringify({ slug: shortSlug }))
}
window.addEventListener('beforeunload', handleUnload)

@ -230,7 +230,10 @@ export function useUploaderConnections(
}
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Ready && draft.status !== UploaderConnectionStatus.Paused) {
if (
draft.status !== UploaderConnectionStatus.Ready &&
draft.status !== UploaderConnectionStatus.Paused
) {
return draft
}

@ -1,12 +1,12 @@
import { createZipStream } from '../zip-stream'
// eslint-disable-next-line @typescript-eslint/no-var-requires
// eslint-disable-next-line @typescript-eslint/no-require-imports
if (typeof window !== 'undefined') require('web-streams-polyfill/ponyfill')
const baseURL = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const streamSaver =
// eslint-disable-next-line @typescript-eslint/no-require-imports
typeof window !== 'undefined' ? require('streamsaver') : null
if (typeof window !== 'undefined') {
streamSaver.mitm = baseURL + '/stream.html'

@ -121,14 +121,14 @@ export function createZipStream(
header.view.setUint16(
6,
(((date.getHours() << 6) | date.getMinutes()) << 5) |
(date.getSeconds() / 2),
(date.getSeconds() / 2),
true,
)
header.view.setUint16(
8,
((((date.getFullYear() - 1980) << 4) | (date.getMonth() + 1)) <<
5) |
date.getDate(),
date.getDate(),
true,
)
header.view.setUint16(22, nameBuf.length, true)
@ -229,8 +229,7 @@ export function createZipStream(
return new ReadableStream({
start: (c) => {
ctrl = c
underlyingSource.start &&
Promise.resolve(underlyingSource.start(zipWriter))
if (underlyingSource.start) Promise.resolve(underlyingSource.start(zipWriter))
},
pull() {
return (

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save