mirror of https://github.com/kern/filepizza
parent
e4fb3431fb
commit
e402c807c4
@ -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,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: Build on push
|
||||
name: main
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build:
|
||||
name: Docker build, tag, and push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: actions/checkout@v4
|
||||
- name: Docker build, tag, and push
|
||||
uses: pangzineng/Github-Action-One-Click-Docker@master
|
||||
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
|
||||
@ -1,3 +1,5 @@
|
||||
.DS_Store
|
||||
.next
|
||||
node_modules
|
||||
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
|
||||
MAINTAINER Alexander Kern <filepizza@kern.io>
|
||||
FROM node:lts-alpine
|
||||
|
||||
RUN apk add --no-cache pnpm
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install
|
||||
COPY . ./
|
||||
RUN npm install && npm run build
|
||||
RUN pnpm build
|
||||
|
||||
ENV PORT 3000
|
||||
ENV NODE_ENV production
|
||||
EXPOSE 80
|
||||
CMD node ./dist/index.js
|
||||
EXPOSE 3000
|
||||
CMD pnpm start
|
||||
|
||||
@ -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'],
|
||||
});
|
||||
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
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>{" "}
|
||||
&{" "}
|
||||
<a href="http://neeraj.io" target="_blank">
|
||||
Neeraj Baid
|
||||
</a>{" "}
|
||||
while eating <strong>Sliver</strong> @ UC Berkeley ·{" "}
|
||||
<a href="https://github.com/kern/filepizza#faq" target="_blank">
|
||||
FAQ
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<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} · Up: {formatSize(this.state.speedUp)} · 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} · Up: {formatSize(this.state.speedUp)} · 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} · Up: {formatSize(this.state.speedUp)} · 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> &{' '}
|
||||
<FooterLink href="http://neeraj.io">Neeraj Baid</FooterLink> while
|
||||
eating <strong>Sliver</strong> @ UC Berkeley ·{' '}
|
||||
<FooterLink href="https://github.com/kern/filepizza#faq">
|
||||
FAQ
|
||||
</FooterLink>{' '}
|
||||
·{' '}
|
||||
<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,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 »
|
||||
</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,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} · 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…
Reference in New Issue