Add retrieve code mode support. (e.g. Like send anywhere)

If enable retrieve mode (NEXT_PUBLIC_RETRIEVE_CODE_MODE=true), it allows user to get retrieve code to be used to download the file directly from home page.
BTW, hide long url & short url download links for clean layout.
pull/189/head
Guang Huang 12 months ago
parent b79363b750
commit adabe57cd8

@ -7,8 +7,10 @@
"homepage": "https://github.com/kern/filepizza", "homepage": "https://github.com/kern/filepizza",
"scripts": { "scripts": {
"dev": "next", "dev": "next",
"dev:code": "NEXT_PUBLIC_RETRIEVE_CODE_MODE=true next",
"dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next", "dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next",
"build": "next build", "build": "next build",
"build:code": "NEXT_PUBLIC_RETRIEVE_CODE_MODE=true next build",
"start": "next start", "start": "next start",
"start:peerjs": "./bin/peerjs.js", "start:peerjs": "./bin/peerjs.js",
"lint:check": "eslint 'src/**/*.ts[x]'", "lint:check": "eslint 'src/**/*.ts[x]'",
@ -33,6 +35,7 @@
"url": "https://github.com/kern/filepizza/issues" "url": "https://github.com/kern/filepizza/issues"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.55.2", "@tanstack/react-query": "^5.55.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"debug": "^4.3.6", "debug": "^4.3.6",
@ -84,4 +87,4 @@
"git add" "git add"
] ]
} }
} }

File diff suppressed because it is too large Load Diff

@ -1,6 +1,7 @@
'use client' 'use client'
import React, { JSX, useCallback, useState } from 'react' import React, { JSX, useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import WebRTCPeerProvider from '../components/WebRTCProvider' import WebRTCPeerProvider from '../components/WebRTCProvider'
import DropZone from '../components/DropZone' import DropZone from '../components/DropZone'
import UploadFileList from '../components/UploadFileList' import UploadFileList from '../components/UploadFileList'
@ -16,6 +17,8 @@ import { getFileName } from '../fs'
import TitleText from '../components/TitleText' import TitleText from '../components/TitleText'
import { pluralize } from '../utils/pluralize' import { pluralize } from '../utils/pluralize'
import TermsAcceptance from '../components/TermsAcceptance' import TermsAcceptance from '../components/TermsAcceptance'
import DownloadZone from '../components/DownloadZone'
import config from '../config'
function PageWrapper({ children }: { children: React.ReactNode }): JSX.Element { function PageWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return ( return (
@ -32,12 +35,18 @@ function InitialState({
}: { }: {
onDrop: (files: UploadedFile[]) => void onDrop: (files: UploadedFile[]) => void
}): JSX.Element { }): JSX.Element {
const router = useRouter()
const handleComplete = (code: string) => {
router.push(`/download/${code}`)
}
return ( return (
<PageWrapper> <PageWrapper>
<div className="flex flex-col items-center space-y-1 max-w-md"> <div className="flex flex-col items-center space-y-1 max-w-md">
<TitleText>Peer-to-peer file transfers in your browser.</TitleText> <TitleText>Peer-to-peer file transfers in your browser.</TitleText>
</div> </div>
<DropZone onDrop={onDrop} /> <DropZone onDrop={onDrop} />
{config.retrieveCodeMode && <DownloadZone onComplete={handleComplete} />}
<TermsAcceptance /> <TermsAcceptance />
</PageWrapper> </PageWrapper>
) )

@ -1,7 +1,11 @@
import 'server-only' import 'server-only'
import config from './config' import config from './config'
import { Redis, getRedisClient } from './redisClient' import { Redis, getRedisClient } from './redisClient'
import { generateShortSlug, generateLongSlug } from './slugs' import {
generateShortSlug,
generateLongSlug,
generateRetrieveCode,
} from './slugs'
import crypto from 'crypto' import crypto from 'crypto'
import { z } from 'zod' import { z } from 'zod'
@ -10,6 +14,7 @@ export type Channel = {
longSlug: string longSlug: string
shortSlug: string shortSlug: string
uploaderPeerID: string uploaderPeerID: string
retrieveCode: string
} }
const ChannelSchema = z.object({ const ChannelSchema = z.object({
@ -17,6 +22,7 @@ const ChannelSchema = z.object({
longSlug: z.string(), longSlug: z.string(),
shortSlug: z.string(), shortSlug: z.string(),
uploaderPeerID: z.string(), uploaderPeerID: z.string(),
retrieveCode: z.string(),
}) })
export interface ChannelRepo { export interface ChannelRepo {
@ -34,6 +40,10 @@ function getLongSlugKey(longSlug: string): string {
return `long:${longSlug}` return `long:${longSlug}`
} }
function getRetrieveCodeKey(retrieveCode: string): string {
return `retrieve:${retrieveCode}`
}
async function generateShortSlugUntilUnique( async function generateShortSlugUntilUnique(
checkExists: (key: string) => Promise<boolean>, checkExists: (key: string) => Promise<boolean>,
): Promise<string> { ): Promise<string> {
@ -62,6 +72,20 @@ async function generateLongSlugUntilUnique(
throw new Error('max attempts reached generating long slug') throw new Error('max attempts reached generating long slug')
} }
async function generateRetrieveCodeUntilUnique(
checkExists: (key: string) => Promise<boolean>,
): Promise<string> {
for (let i = 0; i < config.retrieveCodeSlug.maxAttempts; i++) {
const code = generateRetrieveCode()
const exists = await checkExists(getRetrieveCodeKey(code))
if (!exists) {
return code
}
}
throw new Error('max attempts reached generating retrieve code')
}
function serializeChannel(channel: Channel): string { function serializeChannel(channel: Channel): string {
return JSON.stringify(channel) return JSON.stringify(channel)
} }
@ -110,12 +134,16 @@ export class MemoryChannelRepo implements ChannelRepo {
const longSlug = await generateLongSlugUntilUnique(async (key) => const longSlug = await generateLongSlugUntilUnique(async (key) =>
this.channels.has(key), this.channels.has(key),
) )
const retrieveCode = await generateRetrieveCodeUntilUnique(async (key) =>
this.channels.has(key),
)
const channel: Channel = { const channel: Channel = {
secret: crypto.randomUUID(), secret: crypto.randomUUID(),
longSlug, longSlug,
shortSlug, shortSlug,
uploaderPeerID, uploaderPeerID,
retrieveCode,
} }
const expiresAt = Date.now() + ttl * 1000 const expiresAt = Date.now() + ttl * 1000
@ -123,13 +151,15 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(shortSlug) const shortKey = getShortSlugKey(shortSlug)
const longKey = getLongSlugKey(longSlug) const longKey = getLongSlugKey(longSlug)
const retrieveCodeKey = getRetrieveCodeKey(retrieveCode)
this.channels.set(shortKey, storedChannel) this.channels.set(shortKey, storedChannel)
this.channels.set(longKey, storedChannel) this.channels.set(longKey, storedChannel)
this.channels.set(retrieveCodeKey, storedChannel)
this.setChannelTimeout(shortKey, ttl) this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl) this.setChannelTimeout(longKey, ttl)
this.setChannelTimeout(retrieveCodeKey, ttl)
return channel return channel
} }
@ -145,6 +175,14 @@ export class MemoryChannelRepo implements ChannelRepo {
: shortChannel.channel : shortChannel.channel
} }
const retrieveCodeKey = getRetrieveCodeKey(slug)
const retrieveCodeChannel = this.channels.get(retrieveCodeKey)
if (retrieveCodeChannel) {
return scrubSecret
? { ...retrieveCodeChannel.channel, secret: undefined }
: retrieveCodeChannel.channel
}
const longKey = getLongSlugKey(slug) const longKey = getLongSlugKey(slug)
const longChannel = this.channels.get(longKey) const longChannel = this.channels.get(longKey)
if (longChannel) { if (longChannel) {
@ -171,13 +209,15 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(channel.shortSlug) const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug) const longKey = getLongSlugKey(channel.longSlug)
const retrieveCodeKey = getRetrieveCodeKey(channel.retrieveCode)
this.channels.set(longKey, storedChannel) this.channels.set(longKey, storedChannel)
this.channels.set(shortKey, storedChannel) this.channels.set(shortKey, storedChannel)
this.channels.set(retrieveCodeKey, storedChannel)
this.setChannelTimeout(shortKey, ttl) this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl) this.setChannelTimeout(longKey, ttl)
this.setChannelTimeout(retrieveCodeKey, ttl)
return true return true
} }
@ -189,6 +229,7 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(channel.shortSlug) const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug) const longKey = getLongSlugKey(channel.longSlug)
const retrieveCodeKey = getRetrieveCodeKey(channel.retrieveCode)
// Clear timeouts // Clear timeouts
const shortTimeout = this.timeouts.get(shortKey) const shortTimeout = this.timeouts.get(shortKey)
@ -203,8 +244,15 @@ export class MemoryChannelRepo implements ChannelRepo {
this.timeouts.delete(longKey) this.timeouts.delete(longKey)
} }
const retrieveCodeTimeout = this.timeouts.get(retrieveCodeKey)
if (retrieveCodeTimeout) {
clearTimeout(retrieveCodeTimeout)
this.timeouts.delete(retrieveCodeKey)
}
this.channels.delete(longKey) this.channels.delete(longKey)
this.channels.delete(shortKey) this.channels.delete(shortKey)
this.channels.delete(retrieveCodeKey)
} }
} }
@ -225,18 +273,22 @@ export class RedisChannelRepo implements ChannelRepo {
const longSlug = await generateLongSlugUntilUnique( const longSlug = await generateLongSlugUntilUnique(
async (key) => (await this.client.get(key)) !== null, async (key) => (await this.client.get(key)) !== null,
) )
const retrieveCode = await generateRetrieveCodeUntilUnique(
async (key) => (await this.client.get(key)) !== null,
)
const channel: Channel = { const channel: Channel = {
secret: crypto.randomUUID(), secret: crypto.randomUUID(),
longSlug, longSlug,
shortSlug, shortSlug,
uploaderPeerID, uploaderPeerID,
retrieveCode,
} }
const channelStr = serializeChannel(channel) const channelStr = serializeChannel(channel)
await this.client.setex(getLongSlugKey(longSlug), ttl, channelStr) await this.client.setex(getLongSlugKey(longSlug), ttl, channelStr)
await this.client.setex(getShortSlugKey(shortSlug), ttl, channelStr) await this.client.setex(getShortSlugKey(shortSlug), ttl, channelStr)
await this.client.setex(getRetrieveCodeKey(retrieveCode), ttl, channelStr)
return channel return channel
} }
@ -249,6 +301,13 @@ export class RedisChannelRepo implements ChannelRepo {
return deserializeChannel(shortChannelStr, scrubSecret) return deserializeChannel(shortChannelStr, scrubSecret)
} }
const retrieveCodeChannelStr = await this.client.get(
getRetrieveCodeKey(slug),
)
if (retrieveCodeChannelStr) {
return deserializeChannel(retrieveCodeChannelStr, scrubSecret)
}
const longChannelStr = await this.client.get(getLongSlugKey(slug)) const longChannelStr = await this.client.get(getLongSlugKey(slug))
if (longChannelStr) { if (longChannelStr) {
return deserializeChannel(longChannelStr, scrubSecret) return deserializeChannel(longChannelStr, scrubSecret)
@ -269,7 +328,7 @@ export class RedisChannelRepo implements ChannelRepo {
await this.client.expire(getLongSlugKey(channel.longSlug), ttl) await this.client.expire(getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(getShortSlugKey(channel.shortSlug), ttl) await this.client.expire(getShortSlugKey(channel.shortSlug), ttl)
await this.client.expire(getRetrieveCodeKey(channel.retrieveCode), ttl)
return true return true
} }
@ -281,6 +340,7 @@ export class RedisChannelRepo implements ChannelRepo {
await this.client.del(getLongSlugKey(channel.longSlug)) await this.client.del(getLongSlugKey(channel.longSlug))
await this.client.del(getShortSlugKey(channel.shortSlug)) await this.client.del(getShortSlugKey(channel.shortSlug))
await this.client.del(getRetrieveCodeKey(channel.retrieveCode))
} }
} }

@ -0,0 +1,19 @@
import React, { JSX } from 'react'
import { ClipboardIcon } from '@heroicons/react/24/outline'
import useClipboard from '../hooks/useClipboard'
export function CopyableIcon({ value }: { value: string }): JSX.Element {
const { hasCopied, onCopy } = useClipboard(value)
return (
<div className="relative flex items-center">
<ClipboardIcon
onClick={onCopy}
className="w-6 h-6 text-gray-500 cursor-pointer hover:text-gray-600"
/>
{hasCopied && (
<span className="absolute ml-8 text-sm text-green-500">Copied!</span>
)}
</div>
)
}

@ -0,0 +1,113 @@
import React, { JSX, useRef, useState } from 'react'
import config from '../config'
type DownloadZoneProps = {
onComplete: (code: string) => void
}
export default function DownloadZone({
onComplete,
}: DownloadZoneProps): JSX.Element {
const inputsRef = useRef<(HTMLInputElement | null)[]>([])
const [code, setCode] = useState<string[]>(
Array(config.retrieveCodeSlug.numChars).fill(''),
)
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const value = e.target.value
// Only allow chars defined in config, in fact, it only has at most 1 char
if (
!value
.split('')
.every((char) => config.retrieveCodeSlug.chars.includes(char))
) {
e.target.value = ''
return
}
const newCode = [...code]
newCode[index] = value
setCode(newCode)
if (value && index < inputsRef.current.length - 1) {
const nextInput = inputsRef.current[index + 1]
nextInput?.focus()
nextInput?.select()
}
if (index === inputsRef.current.length - 1 && value) {
onComplete(newCode.join(''))
}
}
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
index: number,
) => {
if (e.key === 'Backspace' && !e.currentTarget.value && index > 0) {
const prevInput = inputsRef.current[index - 1]
prevInput?.focus()
prevInput?.select()
} else if (e.key === 'ArrowLeft' && index > 0) {
const prevInput = inputsRef.current[index - 1]
prevInput?.focus()
prevInput?.select()
} else if (e.key === 'ArrowRight' && index < inputsRef.current.length - 1) {
const nextInput = inputsRef.current[index + 1]
nextInput?.focus()
nextInput?.select()
}
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pasteData = e.clipboardData.getData('Text')
const validChars = pasteData
.split('')
.filter((char) => config.retrieveCodeSlug.chars.includes(char))
const newCode = validChars.slice(0, config.retrieveCodeSlug.numChars)
newCode.forEach((char, index) => {
if (inputsRef.current[index]) {
inputsRef.current[index]!.value = char
}
})
setCode(newCode)
if (newCode.length === config.retrieveCodeSlug.numChars) {
onComplete(newCode.join(''))
}
}
return (
<div className="mt-4 text-center">
<h2 className="text-lg font-semibold">Retrieve your file</h2>
<div className="flex justify-center space-x-2 mt-2">
{Array(config.retrieveCodeSlug.numChars)
.fill('')
.map((_, index) => (
<input
key={index}
type="text"
maxLength={1}
className="w-10 h-10 border border-gray-500 rounded text-center"
onChange={(e) => handleInputChange(e, index)}
onKeyDown={(e) => handleKeyDown(e, index)}
onPaste={handlePaste}
ref={(el) => {
if (el) {
inputsRef.current[index] = el
}
}}
/>
))}
</div>
<p className="text-sm text-gray-500 mt-2">
Enter the code to download file.
</p>
</div>
)
}

@ -0,0 +1,33 @@
import React, { JSX } from 'react'
import { CopyableIcon } from './CopyableIcon'
import config from '../config'
export function RetrieveCodeBox({
retrieveCode,
}: {
retrieveCode: string
}): JSX.Element {
return (
<div className="flex-auto text-center pr-12">
<div className="flex items-center justify-center space-x-2">
<h2 className="text-lg font-semibold">File Retrieve Code</h2>
<CopyableIcon value={retrieveCode} />
</div>
<div className="flex justify-center space-x-2 mt-2">
{Array(config.retrieveCodeSlug.numChars)
.fill('')
.map((_, index) => (
<div
key={index}
className="w-10 h-10 border border-gray-500 rounded text-center flex items-center justify-center"
>
{retrieveCode[index]}
</div>
))}
</div>
<p className="text-sm text-gray-500 mt-2">
Use the above code to retrieve the file.
</p>
</div>
)
}

@ -12,6 +12,8 @@ import { CopyableInput } from './CopyableInput'
import { ConnectionListItem } from './ConnectionListItem' import { ConnectionListItem } from './ConnectionListItem'
import { ErrorMessage } from './ErrorMessage' import { ErrorMessage } from './ErrorMessage'
import { setRotating } from '../hooks/useRotatingSpinner' import { setRotating } from '../hooks/useRotatingSpinner'
import config from '../config'
import { RetrieveCodeBox } from './RetrieveCodeBox'
const QR_CODE_SIZE = 128 const QR_CODE_SIZE = 128
@ -25,8 +27,15 @@ export default function Uploader({
onStop: () => void onStop: () => void
}): JSX.Element { }): JSX.Element {
const { peer, stop } = useWebRTCPeer() const { peer, stop } = useWebRTCPeer()
const { isLoading, error, longSlug, shortSlug, longURL, shortURL } = const {
useUploaderChannel(peer.id) isLoading,
error,
longSlug,
shortSlug,
longURL,
shortURL,
retrieveCode,
} = useUploaderChannel(peer.id)
const connections = useUploaderConnections(peer, files, password) const connections = useUploaderConnections(peer, files, password)
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
@ -56,10 +65,15 @@ export default function Uploader({
<div className="flex-none mr-4"> <div className="flex-none mr-4">
<QRCode value={shortURL ?? ''} size={QR_CODE_SIZE} /> <QRCode value={shortURL ?? ''} size={QR_CODE_SIZE} />
</div> </div>
<div className="flex-auto flex flex-col justify-center space-y-2"> {!config.retrieveCodeMode && (
<CopyableInput label="Long URL" value={longURL ?? ''} /> <div className="flex-auto flex flex-col justify-center space-y-2">
<CopyableInput label="Short URL" value={shortURL ?? ''} /> <CopyableInput label="Long URL" value={longURL ?? ''} />
</div> <CopyableInput label="Short URL" value={shortURL ?? ''} />
</div>
)}
{config.retrieveCodeMode && retrieveCode && (
<RetrieveCodeBox retrieveCode={retrieveCode} />
)}
</div> </div>
<div className="mt-6 pt-4 border-t border-stone-200 dark:border-stone-700 w-full"> <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"> <div className="flex justify-between items-center mb-2">

@ -2,6 +2,7 @@ import toppings from './toppings'
export default { export default {
redisURL: 'redis://localhost:6379/0', redisURL: 'redis://localhost:6379/0',
retrieveCodeMode: process.env.NEXT_PUBLIC_RETRIEVE_CODE_MODE === 'true',
channel: { channel: {
ttl: 60 * 60, // 1 hour ttl: 60 * 60, // 1 hour
}, },
@ -25,4 +26,9 @@ export default {
words: toppings, words: toppings,
maxAttempts: 8, maxAttempts: 8,
}, },
retrieveCodeSlug: {
numChars: 6,
chars: '0123456789',
maxAttempts: 8,
},
} }

@ -20,6 +20,7 @@ export function useUploaderChannel(
shortSlug: string | undefined shortSlug: string | undefined
longURL: string | undefined longURL: string | undefined
shortURL: string | undefined shortURL: string | undefined
retrieveCode: string | undefined
} { } {
const { isLoading, error, data } = useQuery({ const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID], queryKey: ['uploaderChannel', uploaderPeerID],
@ -56,6 +57,7 @@ export function useUploaderChannel(
const secret = data?.secret const secret = data?.secret
const longSlug = data?.longSlug const longSlug = data?.longSlug
const shortSlug = data?.shortSlug const shortSlug = data?.shortSlug
const retrieveCode = data?.retrieveCode
const longURL = longSlug ? generateURL(longSlug) : undefined const longURL = longSlug ? generateURL(longSlug) : undefined
const shortURL = shortSlug ? generateURL(shortSlug) : undefined const shortURL = shortSlug ? generateURL(shortSlug) : undefined
@ -130,5 +132,6 @@ export function useUploaderChannel(
shortSlug, shortSlug,
longURL, longURL,
shortURL, shortURL,
retrieveCode,
} }
} }

@ -45,14 +45,7 @@ function generateRandomWords(
} }
export const generateShortSlug = (): string => { export const generateShortSlug = (): string => {
let result = '' return generateRandomWord(config.shortSlug.chars, config.shortSlug.numChars)
for (let i = 0; i < config.shortSlug.numChars; i++) {
result +=
config.shortSlug.chars[
Math.floor(Math.random() * config.shortSlug.chars.length)
]
}
return result
} }
export const generateLongSlug = async (): Promise<string> => { export const generateLongSlug = async (): Promise<string> => {
@ -62,3 +55,18 @@ export const generateLongSlug = async (): Promise<string> => {
) )
return parts.join('/') return parts.join('/')
} }
export const generateRetrieveCode = (): string => {
return generateRandomWord(
config.retrieveCodeSlug.chars,
config.retrieveCodeSlug.numChars,
)
}
const generateRandomWord = (wordList: string, wordLength: number): string => {
let result = ''
for (let i = 0; i < wordLength; i++) {
result += wordList[Math.floor(Math.random() * wordList.length)]
}
return result
}

Loading…
Cancel
Save