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",
"scripts": {
"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",
"build": "next build",
"build:code": "NEXT_PUBLIC_RETRIEVE_CODE_MODE=true next build",
"start": "next start",
"start:peerjs": "./bin/peerjs.js",
"lint:check": "eslint 'src/**/*.ts[x]'",
@ -33,6 +35,7 @@
"url": "https://github.com/kern/filepizza/issues"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.55.2",
"autoprefixer": "^10.4.20",
"debug": "^4.3.6",
@ -84,4 +87,4 @@
"git add"
]
}
}
}

File diff suppressed because it is too large Load Diff

@ -1,6 +1,7 @@
'use client'
import React, { JSX, useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import WebRTCPeerProvider from '../components/WebRTCProvider'
import DropZone from '../components/DropZone'
import UploadFileList from '../components/UploadFileList'
@ -16,6 +17,8 @@ import { getFileName } from '../fs'
import TitleText from '../components/TitleText'
import { pluralize } from '../utils/pluralize'
import TermsAcceptance from '../components/TermsAcceptance'
import DownloadZone from '../components/DownloadZone'
import config from '../config'
function PageWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
@ -32,12 +35,18 @@ function InitialState({
}: {
onDrop: (files: UploadedFile[]) => void
}): JSX.Element {
const router = useRouter()
const handleComplete = (code: string) => {
router.push(`/download/${code}`)
}
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} />
{config.retrieveCodeMode && <DownloadZone onComplete={handleComplete} />}
<TermsAcceptance />
</PageWrapper>
)

@ -1,7 +1,11 @@
import 'server-only'
import config from './config'
import { Redis, getRedisClient } from './redisClient'
import { generateShortSlug, generateLongSlug } from './slugs'
import {
generateShortSlug,
generateLongSlug,
generateRetrieveCode,
} from './slugs'
import crypto from 'crypto'
import { z } from 'zod'
@ -10,6 +14,7 @@ export type Channel = {
longSlug: string
shortSlug: string
uploaderPeerID: string
retrieveCode: string
}
const ChannelSchema = z.object({
@ -17,6 +22,7 @@ const ChannelSchema = z.object({
longSlug: z.string(),
shortSlug: z.string(),
uploaderPeerID: z.string(),
retrieveCode: z.string(),
})
export interface ChannelRepo {
@ -34,6 +40,10 @@ function getLongSlugKey(longSlug: string): string {
return `long:${longSlug}`
}
function getRetrieveCodeKey(retrieveCode: string): string {
return `retrieve:${retrieveCode}`
}
async function generateShortSlugUntilUnique(
checkExists: (key: string) => Promise<boolean>,
): Promise<string> {
@ -62,6 +72,20 @@ async function generateLongSlugUntilUnique(
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 {
return JSON.stringify(channel)
}
@ -110,12 +134,16 @@ export class MemoryChannelRepo implements ChannelRepo {
const longSlug = await generateLongSlugUntilUnique(async (key) =>
this.channels.has(key),
)
const retrieveCode = await generateRetrieveCodeUntilUnique(async (key) =>
this.channels.has(key),
)
const channel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
retrieveCode,
}
const expiresAt = Date.now() + ttl * 1000
@ -123,13 +151,15 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(shortSlug)
const longKey = getLongSlugKey(longSlug)
const retrieveCodeKey = getRetrieveCodeKey(retrieveCode)
this.channels.set(shortKey, storedChannel)
this.channels.set(longKey, storedChannel)
this.channels.set(retrieveCodeKey, storedChannel)
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
this.setChannelTimeout(retrieveCodeKey, ttl)
return channel
}
@ -145,6 +175,14 @@ export class MemoryChannelRepo implements ChannelRepo {
: 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 longChannel = this.channels.get(longKey)
if (longChannel) {
@ -171,13 +209,15 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug)
const retrieveCodeKey = getRetrieveCodeKey(channel.retrieveCode)
this.channels.set(longKey, storedChannel)
this.channels.set(shortKey, storedChannel)
this.channels.set(retrieveCodeKey, storedChannel)
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
this.setChannelTimeout(retrieveCodeKey, ttl)
return true
}
@ -189,6 +229,7 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug)
const retrieveCodeKey = getRetrieveCodeKey(channel.retrieveCode)
// Clear timeouts
const shortTimeout = this.timeouts.get(shortKey)
@ -203,8 +244,15 @@ export class MemoryChannelRepo implements ChannelRepo {
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(shortKey)
this.channels.delete(retrieveCodeKey)
}
}
@ -225,18 +273,22 @@ export class RedisChannelRepo implements ChannelRepo {
const longSlug = await generateLongSlugUntilUnique(
async (key) => (await this.client.get(key)) !== null,
)
const retrieveCode = await generateRetrieveCodeUntilUnique(
async (key) => (await this.client.get(key)) !== null,
)
const channel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
retrieveCode,
}
const channelStr = serializeChannel(channel)
await this.client.setex(getLongSlugKey(longSlug), ttl, channelStr)
await this.client.setex(getShortSlugKey(shortSlug), ttl, channelStr)
await this.client.setex(getRetrieveCodeKey(retrieveCode), ttl, channelStr)
return channel
}
@ -249,6 +301,13 @@ export class RedisChannelRepo implements ChannelRepo {
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))
if (longChannelStr) {
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(getShortSlugKey(channel.shortSlug), ttl)
await this.client.expire(getRetrieveCodeKey(channel.retrieveCode), ttl)
return true
}
@ -281,6 +340,7 @@ export class RedisChannelRepo implements ChannelRepo {
await this.client.del(getLongSlugKey(channel.longSlug))
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 { ErrorMessage } from './ErrorMessage'
import { setRotating } from '../hooks/useRotatingSpinner'
import config from '../config'
import { RetrieveCodeBox } from './RetrieveCodeBox'
const QR_CODE_SIZE = 128
@ -25,8 +27,15 @@ export default function Uploader({
onStop: () => void
}): JSX.Element {
const { peer, stop } = useWebRTCPeer()
const { isLoading, error, longSlug, shortSlug, longURL, shortURL } =
useUploaderChannel(peer.id)
const {
isLoading,
error,
longSlug,
shortSlug,
longURL,
shortURL,
retrieveCode,
} = useUploaderChannel(peer.id)
const connections = useUploaderConnections(peer, files, password)
const handleStop = useCallback(() => {
@ -56,10 +65,15 @@ export default function Uploader({
<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>
{!config.retrieveCodeMode && (
<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>
)}
{config.retrieveCodeMode && retrieveCode && (
<RetrieveCodeBox retrieveCode={retrieveCode} />
)}
</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">

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

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

@ -45,14 +45,7 @@ function generateRandomWords(
}
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
return generateRandomWord(config.shortSlug.chars, config.shortSlug.numChars)
}
export const generateLongSlug = async (): Promise<string> => {
@ -62,3 +55,18 @@ export const generateLongSlug = async (): Promise<string> => {
)
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