From a311ccdc310a94db75f70dbeb7bcc1fccbc55651 Mon Sep 17 00:00:00 2001 From: abawi Date: Mon, 14 Apr 2025 18:19:29 +0200 Subject: [PATCH] Added support for "sharedSlug" where multiple uploaders can share files --- src/app/api/create/route.ts | 6 +- src/app/download/[...slug]/page.tsx | 12 ++- src/app/page.tsx | 43 ++++++++-- src/channel.ts | 121 ++++++++++++++++++++++++++-- src/components/MultiDownloader.tsx | 47 +++++++++++ src/components/SharedLinkField.tsx | 39 +++++++++ src/components/Uploader.tsx | 18 ++++- src/hooks/useUploaderChannel.ts | 19 ++++- 8 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 src/components/MultiDownloader.tsx create mode 100644 src/components/SharedLinkField.tsx diff --git a/src/app/api/create/route.ts b/src/app/api/create/route.ts index d6794d7d..8604924b 100644 --- a/src/app/api/create/route.ts +++ b/src/app/api/create/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import { getOrCreateChannelRepo } from '../../../channel' export async function POST(request: Request): Promise { - 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 { ) } - const channel = await getOrCreateChannelRepo().createChannel(uploaderPeerID) + const channel = await getOrCreateChannelRepo().createChannel(uploaderPeerID, undefined, sharedSlug) return NextResponse.json(channel) -} +} \ No newline at end of file diff --git a/src/app/download/[...slug]/page.tsx b/src/app/download/[...slug]/page.tsx index a8c5aaf9..deecaed2 100644 --- a/src/app/download/[...slug]/page.tsx +++ b/src/app/download/[...slug]/page.tsx @@ -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({ - + {channel.additionalUploaders && channel.additionalUploaders.length > 0 ? ( + + ) : ( + + )} ) -} +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index f8d06d28..1226b8bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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({ +
@@ -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 ( @@ -101,7 +122,7 @@ function UploadingState({ - + ) @@ -110,6 +131,7 @@ function UploadingState({ export default function UploadPage(): JSX.Element { const [uploadedFiles, setUploadedFiles] = useState([]) 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 { ) -} +} \ No newline at end of file diff --git a/src/channel.ts b/src/channel.ts index d2fc79d7..cd87ee08 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -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 + createChannel(uploaderPeerID: string, ttl?: number, sharedSlug?: string): Promise fetchChannel(slug: string): Promise renewChannel(slug: string, secret: string, ttl?: number): Promise destroyChannel(slug: string): Promise @@ -85,13 +89,11 @@ export class MemoryChannelRepo implements ChannelRepo { private timeouts: Map = 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 { 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 { 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)) + } } } @@ -297,4 +408,4 @@ export function getOrCreateChannelRepo(): ChannelRepo { } } return _channelRepo -} +} \ No newline at end of file diff --git a/src/components/MultiDownloader.tsx b/src/components/MultiDownloader.tsx new file mode 100644 index 00000000..24f2504c --- /dev/null +++ b/src/components/MultiDownloader.tsx @@ -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(primaryUploaderID) + const [key, setKey] = useState(0) + const allUploaders = [primaryUploaderID, ...additionalUploaders] + + useEffect(() => { + setKey(prevKey => prevKey + 1) + }, [selectedUploader]) + + return ( +
+
+

+ Multiple uploaders available for this shared link: +

+
+ {allUploaders.map((uploader, i) => ( + + ))} +
+
+ + +
+ ) +} \ No newline at end of file diff --git a/src/components/SharedLinkField.tsx b/src/components/SharedLinkField.tsx new file mode 100644 index 00000000..2fc3ca02 --- /dev/null +++ b/src/components/SharedLinkField.tsx @@ -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): void { + onChange(e.target.value) + }, + [onChange], + ) + + return ( +
+ + Shared Link (optional) + + +

+ You can paste either a full URL or just the slug. When shared, multiple uploaders can provide the same files, making downloads more reliable. +

+
+ ) +} \ No newline at end of file diff --git a/src/components/Uploader.tsx b/src/components/Uploader.tsx index 1c0e997c..a38a5b4a 100644 --- a/src/components/Uploader.tsx +++ b/src/components/Uploader.tsx @@ -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({
+ {sharedURL && ( + <> + + {sharedSlug && ( +
+ This upload is part of a collaborative shared link. Multiple uploaders can serve the same files. +
+ )} + + )}
@@ -74,4 +86,4 @@ export default function Uploader({
) -} +} \ No newline at end of file diff --git a/src/hooks/useUploaderChannel.ts b/src/hooks/useUploaderChannel.ts index dba70920..753522e3 100644 --- a/src/hooks/useUploaderChannel.ts +++ b/src/hooks/useUploaderChannel.ts @@ -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 } -} +} \ No newline at end of file