Added support for "sharedSlug" where multiple uploaders can share files

pull/268/head
abawi 8 months ago
parent 7e8650fbc0
commit a311ccdc31

@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: Request): Promise<NextResponse> {
const { uploaderPeerID } = await request.json()
const { uploaderPeerID, sharedSlug } = await request.json()
if (!uploaderPeerID) {
return NextResponse.json(
@ -11,6 +11,6 @@ export async function POST(request: Request): Promise<NextResponse> {
)
}
const channel = await getOrCreateChannelRepo().createChannel(uploaderPeerID)
const channel = await getOrCreateChannelRepo().createChannel(uploaderPeerID, undefined, sharedSlug)
return NextResponse.json(channel)
}

@ -4,6 +4,7 @@ import { getOrCreateChannelRepo } from '../../../channel'
import Spinner from '../../../components/Spinner'
import Wordmark from '../../../components/Wordmark'
import Downloader from '../../../components/Downloader'
import MultiDownloader from '../../../components/MultiDownloader'
import WebRTCPeerProvider from '../../../components/WebRTCProvider'
import ReportTermsViolationButton from '../../../components/ReportTermsViolationButton'
@ -33,7 +34,14 @@ export default async function DownloadPage({
<Spinner direction="down" />
<Wordmark />
<WebRTCPeerProvider>
<Downloader uploaderPeerID={channel.uploaderPeerID} />
{channel.additionalUploaders && channel.additionalUploaders.length > 0 ? (
<MultiDownloader
primaryUploaderID={channel.uploaderPeerID}
additionalUploaders={channel.additionalUploaders}
/>
) : (
<Downloader uploaderPeerID={channel.uploaderPeerID} />
)}
<ReportTermsViolationButton
uploaderPeerID={channel.uploaderPeerID}
slug={slug}

@ -1,20 +1,20 @@
'use client'
import React, { JSX, useCallback, useState } from 'react'
import React, { JSX, useCallback, useState, useMemo } from 'react'
import { getFileName } from '../fs'
import { UploadedFile } from '../types'
import { pluralize } from '../utils/pluralize'
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 SharedLinkField from '../components/SharedLinkField'
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 }: { children: React.ReactNode }): JSX.Element {
@ -52,17 +52,33 @@ function useUploaderFileListData(uploadedFiles: UploadedFile[]) {
}, [uploadedFiles])
}
function extractSlugFromLink(link: string): string | undefined {
if (!link) return undefined
try {
const url = new URL(link)
const pathParts = url.pathname.split('/')
return pathParts[pathParts.length - 1]
} catch {
return link.trim() ? link.trim() : undefined
}
}
function ConfirmUploadState({
uploadedFiles,
password,
sharedLink,
onChangePassword,
onChangeSharedLink,
onCancel,
onStart,
onRemoveFile,
}: {
uploadedFiles: UploadedFile[]
password: string
sharedLink: string
onChangePassword: (pw: string) => void
onChangeSharedLink: (link: string) => void
onCancel: () => void
onStart: () => void
onRemoveFile: (index: number) => void
@ -76,6 +92,7 @@ function ConfirmUploadState({
</TitleText>
<UploadFileList files={fileListData} onRemove={onRemoveFile} />
<PasswordField value={password} onChange={onChangePassword} />
<SharedLinkField value={sharedLink} onChange={onChangeSharedLink} />
<div className="flex space-x-4">
<CancelButton onClick={onCancel} />
<StartButton onClick={onStart} />
@ -87,13 +104,17 @@ function ConfirmUploadState({
function UploadingState({
uploadedFiles,
password,
sharedLink,
onStop,
}: {
uploadedFiles: UploadedFile[]
password: string
sharedLink: string
onStop: () => void
}): JSX.Element {
const fileListData = useUploaderFileListData(uploadedFiles)
const sharedSlug = extractSlugFromLink(sharedLink)
return (
<PageWrapper>
<TitleText>
@ -101,7 +122,7 @@ function UploadingState({
</TitleText>
<UploadFileList files={fileListData} />
<WebRTCPeerProvider>
<Uploader files={uploadedFiles} password={password} onStop={onStop} />
<Uploader files={uploadedFiles} password={password} sharedSlug={sharedSlug} onStop={onStop} />
</WebRTCPeerProvider>
</PageWrapper>
)
@ -110,6 +131,7 @@ function UploadingState({
export default function UploadPage(): JSX.Element {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [password, setPassword] = useState('')
const [sharedLink, setSharedLink] = useState('')
const [uploading, setUploading] = useState(false)
const handleDrop = useCallback((files: UploadedFile[]): void => {
@ -120,6 +142,10 @@ export default function UploadPage(): JSX.Element {
setPassword(pw)
}, [])
const handleChangeSharedLink = useCallback((link: string) => {
setSharedLink(link)
}, [])
const handleStart = useCallback(() => {
setUploading(true)
}, [])
@ -146,7 +172,9 @@ export default function UploadPage(): JSX.Element {
<ConfirmUploadState
uploadedFiles={uploadedFiles}
password={password}
sharedLink={sharedLink}
onChangePassword={handleChangePassword}
onChangeSharedLink={handleChangeSharedLink}
onCancel={handleCancel}
onStart={handleStart}
onRemoveFile={handleRemoveFile}
@ -158,6 +186,7 @@ export default function UploadPage(): JSX.Element {
<UploadingState
uploadedFiles={uploadedFiles}
password={password}
sharedLink={sharedLink}
onStop={handleStop}
/>
)

@ -10,6 +10,8 @@ export type Channel = {
longSlug: string
shortSlug: string
uploaderPeerID: string
sharedSlug?: string
additionalUploaders?: string[]
}
const ChannelSchema = z.object({
@ -17,10 +19,12 @@ const ChannelSchema = z.object({
longSlug: z.string(),
shortSlug: z.string(),
uploaderPeerID: z.string(),
sharedSlug: z.string().optional(),
additionalUploaders: z.array(z.string()).optional()
})
export interface ChannelRepo {
createChannel(uploaderPeerID: string, ttl?: number): Promise<Channel>
createChannel(uploaderPeerID: string, ttl?: number, sharedSlug?: string): Promise<Channel>
fetchChannel(slug: string): Promise<Channel | null>
renewChannel(slug: string, secret: string, ttl?: number): Promise<boolean>
destroyChannel(slug: string): Promise<void>
@ -85,13 +89,11 @@ export class MemoryChannelRepo implements ChannelRepo {
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)
@ -103,19 +105,61 @@ export class MemoryChannelRepo implements ChannelRepo {
async createChannel(
uploaderPeerID: string,
ttl: number = config.channel.ttl,
sharedSlug?: string,
): Promise<Channel> {
const shortSlug = await generateShortSlugUntilUnique(async (key) =>
this.channels.has(key),
)
const longSlug = await generateLongSlugUntilUnique(async (key) =>
this.channels.has(key),
)
if (sharedSlug) {
const sharedKey = getLongSlugKey(sharedSlug)
const existingStoredChannel = this.channels.get(sharedKey)
if (existingStoredChannel) {
const updatedChannel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
sharedSlug
}
const expiresAt = Date.now() + ttl * 1000
const storedChannel = { channel: updatedChannel, expiresAt }
const shortKey = getShortSlugKey(shortSlug)
const longKey = getLongSlugKey(longSlug)
this.channels.set(shortKey, storedChannel)
this.channels.set(longKey, storedChannel)
const existingChannel = {...existingStoredChannel.channel}
existingChannel.additionalUploaders = [
...(existingChannel.additionalUploaders || []),
uploaderPeerID
]
this.channels.set(sharedKey, {
channel: existingChannel,
expiresAt: expiresAt
})
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
this.setChannelTimeout(sharedKey, ttl)
return updatedChannel
}
}
const channel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
sharedSlug
}
const expiresAt = Date.now() + ttl * 1000
@ -127,6 +171,12 @@ export class MemoryChannelRepo implements ChannelRepo {
this.channels.set(shortKey, storedChannel)
this.channels.set(longKey, storedChannel)
if (sharedSlug) {
const sharedKey = getLongSlugKey(sharedSlug)
this.channels.set(sharedKey, storedChannel)
this.setChannelTimeout(sharedKey, ttl)
}
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
@ -175,6 +225,12 @@ export class MemoryChannelRepo implements ChannelRepo {
this.channels.set(longKey, storedChannel)
this.channels.set(shortKey, storedChannel)
if (channel.sharedSlug) {
const sharedKey = getLongSlugKey(channel.sharedSlug)
this.channels.set(sharedKey, storedChannel)
this.setChannelTimeout(sharedKey, ttl)
}
this.setChannelTimeout(shortKey, ttl)
this.setChannelTimeout(longKey, ttl)
@ -190,7 +246,6 @@ export class MemoryChannelRepo implements ChannelRepo {
const shortKey = getShortSlugKey(channel.shortSlug)
const longKey = getLongSlugKey(channel.longSlug)
// Clear timeouts
const shortTimeout = this.timeouts.get(shortKey)
if (shortTimeout) {
clearTimeout(shortTimeout)
@ -203,6 +258,16 @@ export class MemoryChannelRepo implements ChannelRepo {
this.timeouts.delete(longKey)
}
if (channel.sharedSlug) {
const sharedKey = getLongSlugKey(channel.sharedSlug)
const sharedTimeout = this.timeouts.get(sharedKey)
if (sharedTimeout) {
clearTimeout(sharedTimeout)
this.timeouts.delete(sharedKey)
}
this.channels.delete(sharedKey)
}
this.channels.delete(longKey)
this.channels.delete(shortKey)
}
@ -218,25 +283,63 @@ export class RedisChannelRepo implements ChannelRepo {
async createChannel(
uploaderPeerID: string,
ttl: number = config.channel.ttl,
sharedSlug?: string,
): 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,
)
if (sharedSlug) {
const existingChannelStr = await this.client.get(getLongSlugKey(sharedSlug))
if (existingChannelStr) {
const existingChannel = deserializeChannel(existingChannelStr)
const updatedChannel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
sharedSlug
}
const channelStr = serializeChannel(updatedChannel)
await this.client.setex(getLongSlugKey(longSlug), ttl, channelStr)
await this.client.setex(getShortSlugKey(shortSlug), ttl, channelStr)
const updatedSharedChannel = {
...existingChannel,
additionalUploaders: [
...(existingChannel.additionalUploaders || []),
uploaderPeerID
]
}
const updatedSharedStr = serializeChannel(updatedSharedChannel)
await this.client.setex(getLongSlugKey(sharedSlug), ttl, updatedSharedStr)
return updatedChannel
}
}
const channel: Channel = {
secret: crypto.randomUUID(),
longSlug,
shortSlug,
uploaderPeerID,
sharedSlug
}
const channelStr = serializeChannel(channel)
await this.client.setex(getLongSlugKey(longSlug), ttl, channelStr)
await this.client.setex(getShortSlugKey(shortSlug), ttl, channelStr)
if (sharedSlug) {
await this.client.setex(getLongSlugKey(sharedSlug), ttl, channelStr)
}
return channel
}
@ -270,6 +373,10 @@ export class RedisChannelRepo implements ChannelRepo {
await this.client.expire(getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(getShortSlugKey(channel.shortSlug), ttl)
if (channel.sharedSlug) {
await this.client.expire(getLongSlugKey(channel.sharedSlug), ttl)
}
return true
}
@ -281,6 +388,10 @@ export class RedisChannelRepo implements ChannelRepo {
await this.client.del(getLongSlugKey(channel.longSlug))
await this.client.del(getShortSlugKey(channel.shortSlug))
if (channel.sharedSlug) {
await this.client.del(getLongSlugKey(channel.sharedSlug))
}
}
}

@ -0,0 +1,47 @@
'use client'
import React, { useState, JSX, useEffect } from 'react'
import Downloader from './Downloader'
export default function MultiDownloader({
primaryUploaderID,
additionalUploaders,
}: {
primaryUploaderID: string
additionalUploaders: string[]
}): JSX.Element {
const [selectedUploader, setSelectedUploader] = useState<string>(primaryUploaderID)
const [key, setKey] = useState<number>(0)
const allUploaders = [primaryUploaderID, ...additionalUploaders]
useEffect(() => {
setKey(prevKey => prevKey + 1)
}, [selectedUploader])
return (
<div className="w-full flex flex-col items-center">
<div className="w-full mb-4">
<h3 className="text-center mb-2 text-stone-700 dark:text-stone-300">
Multiple uploaders available for this shared link:
</h3>
<div className="flex flex-wrap justify-center gap-2 mb-4">
{allUploaders.map((uploader, i) => (
<button
key={uploader}
onClick={() => setSelectedUploader(uploader)}
className={`px-3 py-1 rounded transition-colors duration-200 ${
selectedUploader === uploader
? 'bg-green-500 text-white'
: 'bg-stone-200 dark:bg-stone-700 text-stone-800 dark:text-stone-200 hover:bg-stone-300 dark:hover:bg-stone-600'
}`}
>
Uploader {i + 1}
</button>
))}
</div>
</div>
<Downloader key={key} uploaderPeerID={selectedUploader} />
</div>
)
}

@ -0,0 +1,39 @@
'use client'
import React, { JSX, useCallback } from 'react'
import InputLabel from './InputLabel'
export default function SharedLinkField({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}): 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
tooltip="Enter a shared link to collaborate with other uploaders. If this is filled, others with the same link will be able to provide the same files to downloaders. Leave empty to create a new upload."
>
Shared Link (optional)
</InputLabel>
<input
type="text"
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 border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100"
placeholder="Paste a shared link to collaborate with others..."
value={value}
onChange={handleChange}
/>
<p className="mt-1 text-xs text-stone-500 dark:text-stone-400">
You can paste either a full URL or just the slug. When shared, multiple uploaders can provide the same files, making downloads more reliable.
</p>
</div>
)
}

@ -18,15 +18,17 @@ const QR_CODE_SIZE = 128
export default function Uploader({
files,
password,
sharedSlug,
onStop,
}: {
files: UploadedFile[]
password: string
sharedSlug?: string
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, sharedURL } =
useUploaderChannel(peer.id, 60000, sharedSlug)
const connections = useUploaderConnections(peer, files, password)
const handleStop = useCallback(() => {
@ -59,6 +61,16 @@ export default function Uploader({
<div className="flex-auto flex flex-col justify-center space-y-2">
<CopyableInput label="Long URL" value={longURL ?? ''} />
<CopyableInput label="Short URL" value={shortURL ?? ''} />
{sharedURL && (
<>
<CopyableInput label="Shared URL" value={sharedURL} />
{sharedSlug && (
<div className="text-xs text-green-600 dark:text-green-400">
This upload is part of a collaborative shared link. Multiple uploaders can serve the same files.
</div>
)}
</>
)}
</div>
</div>
<div className="mt-6 pt-4 border-t border-stone-200 dark:border-stone-700 w-full">

@ -13,6 +13,7 @@ function generateURL(slug: string): string {
export function useUploaderChannel(
uploaderPeerID: string,
renewInterval = 60_000,
sharedSlug?: string
): {
isLoading: boolean
error: Error | null
@ -20,18 +21,22 @@ export function useUploaderChannel(
shortSlug: string | undefined
longURL: string | undefined
shortURL: string | undefined
sharedSlug: string | undefined
sharedURL: string | undefined
additionalUploaders: string[] | undefined
} {
const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID],
queryKey: ['uploaderChannel', uploaderPeerID, sharedSlug],
queryFn: async () => {
console.log(
'[UploaderChannel] creating new channel for peer',
uploaderPeerID,
sharedSlug ? `with shared slug ${sharedSlug}` : ''
)
const response = await fetch('/api/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploaderPeerID }),
body: JSON.stringify({ uploaderPeerID, sharedSlug }),
})
if (!response.ok) {
console.error(
@ -44,6 +49,8 @@ export function useUploaderChannel(
console.log('[UploaderChannel] channel created successfully:', {
longSlug: data.longSlug,
shortSlug: data.shortSlug,
sharedSlug: data.sharedSlug,
additionalUploaders: data.additionalUploaders
})
return data
},
@ -56,8 +63,11 @@ export function useUploaderChannel(
const secret = data?.secret
const longSlug = data?.longSlug
const shortSlug = data?.shortSlug
const returnedSharedSlug = data?.sharedSlug
const longURL = longSlug ? generateURL(longSlug) : undefined
const shortURL = shortSlug ? generateURL(shortSlug) : undefined
const sharedURL = returnedSharedSlug ? generateURL(returnedSharedSlug) : undefined
const additionalUploaders = data?.additionalUploaders
const renewMutation = useMutation({
mutationFn: async ({ secret: s }: { secret: string }) => {
@ -130,5 +140,8 @@ export function useUploaderChannel(
shortSlug,
longURL,
shortURL,
sharedSlug: returnedSharedSlug,
sharedURL,
additionalUploaders
}
}
Loading…
Cancel
Save