diff --git a/next-env.d.ts b/next-env.d.ts index 7b7aa2c..db98b80 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,2 +1,4 @@ /// /// + +declare module 'xkcd-password' diff --git a/src/bootstrap.ts b/src/bootstrap.ts deleted file mode 100644 index 9ad59f1..0000000 --- a/src/bootstrap.ts +++ /dev/null @@ -1,34 +0,0 @@ -const express = require('express') -const db = require('../db') - -const 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 { - const err = new Error('Not Found') - err.status = 404 - next(err) - } -} - -routes.get(/^\/([a-z]+\/[a-z]+\/[a-z]+\/[a-z]+)$/, (req, res, next) => { - const uploader = db.find(req.params[0]) - return bootstrap(uploader, req, res, next) -}) -routes.get(/^\/download\/(\w+)$/, (req, res, next) => { - const uploader = db.findShort(req.params[0]) - return bootstrap(uploader, req, res, next) -}) diff --git a/src/channel.ts b/src/channel.ts index 104f6a9..6970c2e 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1,5 +1,6 @@ import config from './config' import Redis from 'ioredis' +import { generateShortSlug, generateLongSlug } from './slugs' export type Channel = { uploaderPeerID: string @@ -76,11 +77,27 @@ export class RedisChannelRepo implements ChannelRepo { } private async generateShortSlug(): Promise { - return 'foo' // TODO + for (let i = 0; i < config.shortSlug.maxAttempts; i++) { + const slug = generateShortSlug() + const currVal = await this.client.get(this.getShortSlugKey(slug)) + if (!currVal) { + return slug + } + } + + throw new Error('max attempts reached generating short slug') } private async generateLongSlug(): Promise { - return 'foo/bar/baz' // TODO + for (let i = 0; i < config.longSlug.maxAttempts; i++) { + const slug = await generateLongSlug() + const currVal = await this.client.get(this.getLongSlugKey(slug)) + if (!currVal) { + return slug + } + } + + throw new Error('max attempts reached generating long slug') } private getShortSlugKey(shortSlug: string): string { diff --git a/src/components/Downloader.tsx b/src/components/Downloader.tsx index 011c498..540fdfc 100644 --- a/src/components/Downloader.tsx +++ b/src/components/Downloader.tsx @@ -1,56 +1,54 @@ -import React, { useEffect, useRef } from 'react' -import { usePeerData } from 'react-peer-data' -import { Room } from 'peer-data' +import React from 'react' interface Props { - roomName: string + uploaderPeerID: string } -const Downloader: React.FC = ({ roomName }: Props) => { - const room = useRef(null) - const peerData = usePeerData() - - useEffect(() => { - if (room.current) return - - room.current = peerData.connect(roomName) - console.log(room.current) - setInterval(() => console.log(room.current), 1000) - room.current - .on('participant', (participant) => { - console.log(participant.getId() + ' joined') - participant.newDataChannel() - - participant - .on('connected', () => { - console.log('connected', participant.id) - }) - .on('disconnected', () => { - console.log('disconnected', participant.id) - }) - .on('track', (event) => { - console.log('stream', participant.id, event.streams[0]) - }) - .on('message', (payload) => { - console.log(participant.id, payload) - }) - .on('error', (event) => { - console.error('peer', participant.id, event) - participant.renegotiate() - }) - - console.log(participant) - participant.send(`hello there, I'm the downloader`) - }) - .on('error', (event) => { - console.error('room', roomName, event) - }) - - return () => { - room.current.disconnect() - room.current = null - } - }, [peerData]) +const Downloader: React.FC = ({ uploaderPeerID }: Props) => { + // const room = useRef(null) + // const peerData = usePeerData() + + // useEffect(() => { + // if (room.current) return + + // room.current = peerData.connect(roomName) + // console.log(room.current) + // setInterval(() => console.log(room.current), 1000) + // room.current + // .on('participant', (participant) => { + // console.log(participant.getId() + ' joined') + // participant.newDataChannel() + + // participant + // .on('connected', () => { + // console.log('connected', participant.id) + // }) + // .on('disconnected', () => { + // console.log('disconnected', participant.id) + // }) + // .on('track', (event) => { + // console.log('stream', participant.id, event.streams[0]) + // }) + // .on('message', (payload) => { + // console.log(participant.id, payload) + // }) + // .on('error', (event) => { + // console.error('peer', participant.id, event) + // participant.renegotiate() + // }) + + // console.log(participant) + // participant.send(`hello there, I'm the downloader`) + // }) + // .on('error', (event) => { + // console.error('room', roomName, event) + // }) + + // return () => { + // room.current.disconnect() + // room.current = null + // } + // }, [peerData]) return null } diff --git a/src/config.ts b/src/config.ts index 35a6daf..da715e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +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, + }, } diff --git a/src/db.ts b/src/db.ts index c781e3d..46d8a82 100644 --- a/src/db.ts +++ b/src/db.ts @@ -48,9 +48,3 @@ export function find(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] -} diff --git a/src/pages/api/create.ts b/src/pages/api/create.ts index f3fa9db..be03de1 100644 --- a/src/pages/api/create.ts +++ b/src/pages/api/create.ts @@ -1,19 +1,10 @@ -import type { Request, Response } from 'express' -import { channelRepo } from '../../channel' +import { NextApiRequest, NextApiResponse } from 'next' +import { Channel, channelRepo } from '../../channel' +import { routeHandler, getBodyKey } from '../../routes' -export default (req: Request, res: Response): void => { - // TODO: validate method and uploaderPeerID - - channelRepo - .create(req.body.uploaderPeerID) - .then((channel) => { - res.statusCode = 200 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(channel)) - }) - .catch((err) => { - res.statusCode = 500 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: err.toString() })) - }) -} +export default routeHandler( + (req: NextApiRequest, _res: NextApiResponse): Promise => { + const uploaderPeerID = getBodyKey(req, 'uploaderPeerID') + return channelRepo.create(uploaderPeerID) + }, +) diff --git a/src/pages/api/destroy.ts b/src/pages/api/destroy.ts index 3fb6889..e6eb946 100644 --- a/src/pages/api/destroy.ts +++ b/src/pages/api/destroy.ts @@ -1,19 +1,10 @@ -import type { Request, Response } from 'express' +import { NextApiRequest, NextApiResponse } from 'next' import { channelRepo } from '../../channel' +import { routeHandler, getBodyKey } from '../../routes' -export default (req: Request, res: Response): void => { - // TODO: validate method and slug - - channelRepo - .destroy(req.body.slug) - .then((channel) => { - res.statusCode = 200 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(channel)) - }) - .catch((err) => { - res.statusCode = 500 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: err.toString() })) - }) -} +export default routeHandler( + (req: NextApiRequest, _res: NextApiResponse): Promise => { + const slug = getBodyKey(req, 'slug') + return channelRepo.destroy(slug) + }, +) diff --git a/src/pages/api/renew.ts b/src/pages/api/renew.ts index cf57dc8..61ea8d9 100644 --- a/src/pages/api/renew.ts +++ b/src/pages/api/renew.ts @@ -1,19 +1,10 @@ -import type { Request, Response } from 'express' +import { NextApiRequest, NextApiResponse } from 'next' import { channelRepo } from '../../channel' +import { routeHandler, getBodyKey } from '../../routes' -export default (req: Request, res: Response): void => { - // TODO: validate method and slug - - channelRepo - .renew(req.body.slug) - .then((channel) => { - res.statusCode = 200 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(channel)) - }) - .catch((err) => { - res.statusCode = 500 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: err.toString() })) - }) -} +export default routeHandler( + (req: NextApiRequest, _res: NextApiResponse): Promise => { + const slug = getBodyKey(req, 'slug') + return channelRepo.renew(slug) + }, +) diff --git a/src/pages/download/[...slug].tsx b/src/pages/download/[...slug].tsx index 269d885..e2f67ea 100644 --- a/src/pages/download/[...slug].tsx +++ b/src/pages/download/[...slug].tsx @@ -1,25 +1,49 @@ import React from 'react' import WebRTCProvider from '../../components/WebRTCProvider' -import { useRouter } from 'next/router' import Downloader from '../../components/Downloader' -import { NextPage } from 'next' +import { NextPage, GetServerSideProps } from 'next' +import { channelRepo } from '../../channel' -const DownloadPage: NextPage = () => { - const router = useRouter() - const { slug } = router.query +type Props = { + slug: string + uploaderPeerID: string + error?: string +} +const DownloadPage: NextPage = ({ slug, uploaderPeerID }) => { return ( <> -
{JSON.stringify(slug)}
- +
{slug}
+
{uploaderPeerID}
+
) } -DownloadPage.getInitialProps = () => { - return {} +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const slug = normalizeSlug(ctx.query.slug) + const channel = await channelRepo.fetch(slug) + + if (!channel) { + ctx.res.statusCode = 404 + return { + props: { slug, uploaderPeerID: '', error: 'not found' }, + } + } + + return { + props: { slug, uploaderPeerID: channel.uploaderPeerID }, + } +} + +const normalizeSlug = (rawSlug: string | string[]): string => { + if (typeof rawSlug === 'string') { + return rawSlug + } else { + return rawSlug.join('/') + } } export default DownloadPage diff --git a/src/redis.ts b/src/redis.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..707dde4 --- /dev/null +++ b/src/routes.ts @@ -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( + fn: (req: NextApiRequest, res: NextApiResponse) => Promise, +): (req: NextApiRequest, res: NextApiResponse) => Promise { + return async (req: NextApiRequest, res: NextApiResponse): Promise => { + 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 +} diff --git a/src/slugs.ts b/src/slugs.ts new file mode 100644 index 0000000..6da53ab --- /dev/null +++ b/src/slugs.ts @@ -0,0 +1,24 @@ +import xkcdPassword from 'xkcd-password' +import config from './config' + +export const generateShortSlug = (): string => { + let result = '' + for (let i = 0; i < config.shortSlug.numChars; i++) { + result += + config.shortSlug.chars[ + Math.floor(Math.random() * config.shortSlug.chars.length) + ] + } + return result +} + +const longSlugGenerator = new xkcdPassword() +longSlugGenerator.initWithWordList(config.longSlug.words) + +export const generateLongSlug = (): Promise => { + return longSlugGenerator.generate({ + numWords: config.longSlug.numWords, + minLength: 1, + maxLength: 256, + }) +} diff --git a/src/util.ts b/src/util.ts index 7ff4847..30e2b9b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,10 @@ -// Taken from StackOverflow +import xkcdPassword from 'xkcd-password' +import toppings from './toppings' +import config from './config' + +// Borrowed from StackOverflow // http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript -export function formatSize(bytes) { +export const formatSize = (bytes: number): string => { if (bytes === 0) { return '0 Bytes' } diff --git a/src/wt.ts b/src/wt.ts deleted file mode 100644 index e601f64..0000000 --- a/src/wt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import socket from 'filepizza-socket' - -export function getClient() { - return new Promise((resolve, reject) => { - socket.emit('trackerConfig', {}, (trackerConfig) => { - const client = new WebTorrent({ - tracker: trackerConfig, - }) - resolve(client) - }) - }) -}