import { useEffect, useRef, useState } from 'react' import type { BackendChallenge, BackendLimit, UploadEventType } from '../../functions/BackendTypes' import { API_BASE, BackendDebounce, BackendFetch } from '../../functions/Backend' import { KEY_PREFIX_UPLOAD, routeTo, setTitle } from '../../functions/Route' import './styles/Upload.css' import HeaderMessage from '../layout/HeaderMessage' import InputFile, { type HandleForInputFile } from '../inputs/File' import InputText, { type HandleForInputText } from '../inputs/Text' import InputTags, { type HandleForInputTags } from '../inputs/Tags' import InputButton from '../inputs/Button' import FooterLoading from '../layout/FooterLoading' import FooterError from '../layout/FooterError' import FooterText from '../layout/FooterText' import { wtEvent } from '../../functions/Watchtower' export default function ViewUpload() { const solveChallengeRef = useRef<() => void>(null) const fileRef = useRef(null) const titleRef = useRef(null) const tagsRef = useRef(null) const [workCounter, setWorkCounter] = useState(0) const [workNonce, setWorkNonce] = useState('') const [limits, setLimits] = useState() const [loading, setLoading] = useState(true) const [loadingReason, setLoadingReason] = useState('') const [formError, setFormError] = useState('') const [error, setError] = useState('') async function onSubmit() { setFormError('') // Validate Client if (workCounter === 0) { wtEvent('upload_validation_fail', { reason: 'pow_unsolved' }) return setFormError('Waiting for worker to complete') } if (!limits) { wtEvent('upload_validation_fail', { reason: 'restrictions_loading' }) return setFormError('Limits not loaded yet') } // Validate Form const restrict = limits.upload const preview = fileRef.current?.getPreview() const file = fileRef.current?.getValue() const tags = tagsRef.current?.getValue() const title = titleRef.current?.getValue() if (!file || !preview) { wtEvent('upload_validation_fail', { reason: 'missing_file' }) return setFormError('Please select a file.') } if (!tags || tags.length === 0) { wtEvent('upload_validation_fail', { reason: 'missing_tags' }) return setFormError('Please add at least one tag.') } if (!title || title.trim().length === 0) { wtEvent('upload_validation_fail', { reason: 'missing_title' }) return setFormError('Please enter a title.') } // Validate Media if (!restrict.mime_types.includes(file.type)) { wtEvent('upload_validation_fail', { reason: 'mime_type', value: file.type }) return setFormError(`File type is not supported`) } if (file.size > restrict.filesize) { wtEvent('upload_validation_fail', { reason: 'file_size', value: file.size }) return setFormError(`File exceeds ${Math.floor(restrict.filesize / 1024 / 1024)}MB limit`) } if (preview instanceof HTMLVideoElement) { if (preview.videoWidth < restrict.input_width_min || preview.videoHeight < restrict.input_height_min) { wtEvent('upload_validation_fail', { reason: 'dimension_min', width: preview.videoWidth, height: preview.videoHeight, }) return setFormError( `Animation is too small (Min: ${restrict.input_width_min}x${restrict.input_height_min})`, ) } if (preview.videoWidth > restrict.video_width_max || preview.videoHeight > restrict.video_height_max) { wtEvent('upload_validation_fail', { reason: 'dimension_max', width: preview.videoWidth, height: preview.videoHeight, }) return setFormError( `Animation is too large (Max: ${restrict.video_width_max}x${restrict.video_height_max})`, ) } if (preview.duration > restrict.duration) { wtEvent('upload_validation_fail', { reason: 'duration', width: preview.duration }) return setFormError(`Animation is too long (Max: ${restrict.duration} seconds)`) } } if (preview instanceof HTMLImageElement) { if (preview.naturalWidth < restrict.input_width_min || preview.naturalHeight < restrict.input_height_min) { wtEvent('upload_validation_fail', { reason: 'dimension_min', width: preview.naturalWidth, height: preview.naturalHeight, }) return setFormError( `Sticker is too small (Min: ${restrict.input_width_min}x${restrict.input_height_min})`, ) } if (preview.naturalWidth > restrict.image_width_max || preview.naturalHeight > restrict.image_height_max) { wtEvent('upload_validation_fail', { reason: 'dimension_max', width: preview.naturalWidth, height: preview.naturalHeight, }) return setFormError( `Sticker is too large (Max: ${restrict.image_width_max}x${restrict.image_height_max})`, ) } } // Upload User Content setLoading(true) setLoadingReason('Uploading Content') const form = new FormData() form.append('file', file) form.append( 'data', new Blob( [ JSON.stringify({ title: title.trim(), tags: tags.map((t) => t.label), }), ], { type: 'application/json' }, ), ) fetch(`${API_BASE}/uploads`, { body: form, method: 'POST', headers: { 'X-Challenge-Counter': String(workCounter), 'X-Challenge-Nonce': workNonce, }, }) .then(async (resp) => { let requestID = 'N/A' if (!resp.ok) { solveChallengeRef.current?.() // refresh consumed pow let reason = `Request failed: ${resp.status} ${resp.statusText}` try { const raw = await resp.text() const dat = JSON.parse(raw) if (dat.message) reason = `${dat.message} (${requestID})` } catch {} wtEvent('upload_fail', { reason }) setFormError(reason) setLoading(false) return } const reader = resp.body!.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let parts = buffer.split('\n\n') buffer = parts.pop()! for (const part of parts) { const lines = part.split('\n') for (const line of lines) { if (!line.startsWith('data:')) continue const text = line.slice(5).trim() if (!text) continue const body: UploadEventType = JSON.parse(text) switch (body.name) { // Simple Messages case 'id': requestID = body.data break case 'step': setLoadingReason(body.data.message) break case 'progress': setLoadingReason(`Processing: ${body.data.percent}%`) break // Request Failed, Most likely due to NSFW case 'error': wtEvent('upload_fail', { reason: body.data.message }) setFormError(`${body.data.message} (${requestID})`) setLoading(false) break // Request Complete case 'finish': wtEvent('upload_success', { id: requestID, type: file.type, size: file.size, width: preview instanceof HTMLVideoElement ? preview.videoWidth : preview.naturalWidth, height: preview instanceof HTMLVideoElement ? preview.videoHeight : preview.naturalHeight, duration: preview instanceof HTMLVideoElement ? preview.duration : 0, }) // Little hack to send the user to their personal gifs instead of the upload pane // when they click back... it just feels more logical. localStorage.setItem(`${KEY_PREFIX_UPLOAD}${requestID}`, body.data.edit_token) routeTo(`/personal`) routeTo(`/art/${requestID}`) break } // For debugging purposes console.log(`[EVENT] ${body.name} => ${text}`) } } } }) .catch((err) => { console.error('Network Error:', err) setFormError('Network Error') setLoading(false) }) .finally(() => { solveChallengeRef.current?.() }) } useEffect(() => { let iteration = 1 async function handler() { const resp = await BackendFetch('/limits') if (resp.error) { console.log('Failed to fetch limits:', resp.error) const d = BackendDebounce(resp, iteration) if (!d.ratelimit) { setError(resp.error) } await d.sleep setError('') handler() return } setLimits(resp.json) } handler() }, []) useEffect(() => { let timeout: number | undefined let worker: Worker let iteration = 1 async function handler() { console.log('[WORKER] Starting Challenge') setLoadingReason('Retrieving Settings') // Request Challenge const resp = await BackendFetch('/challenge?difficulty=20') if (resp.error) { console.log('[WORKER] Fetch Error:', resp.error) const d = BackendDebounce(resp, iteration) if (!d.ratelimit) { setError(resp.error) } await d.sleep setError('') handler() return } setLoadingReason('Solving Anti-Spam Challenge') // Complete Challenge const { nonce, difficulty, expires } = resp.json const t = Date.now() worker.postMessage({ nonce, difficulty }) worker.onmessage = (m: MessageEvent<{ counter: number }>) => { const tt = Date.now() - t wtEvent('upload_pow_solve', { difficulty, time: tt }) console.log(`[WORKER] Work completed in ${tt}ms`) worker.onmessage = null setWorkCounter(m.data.counter) setWorkNonce(nonce) const tl = Math.max(expires * 1000 - Date.now(), 0) timeout = setTimeout(handler, tl) console.log(`[WORKER] Next Challenge in ${tl}ms`) setLoadingReason('Ready') setLoading(false) } } try { setLoadingReason('Spawning Worker') worker = new Worker('/worker-pow.js') solveChallengeRef.current = handler handler() // shouldn't fail } catch (error) { console.error('[WORKER] Startup Error:', error) setError('Init Error') } return () => { clearTimeout(timeout) worker.onmessage = null worker.terminate() console.log('[WORKER] Cleanup') } }, []) setTitle('Upload') return ( <>
{!error && !loading && !formError && } {!error && loading && } {!error && formError && } {error && }
) }