363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
|
|
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<HandleForInputFile>(null)
|
||
|
|
const titleRef = useRef<HandleForInputText>(null)
|
||
|
|
const tagsRef = useRef<HandleForInputTags>(null)
|
||
|
|
|
||
|
|
const [workCounter, setWorkCounter] = useState(0)
|
||
|
|
const [workNonce, setWorkNonce] = useState('')
|
||
|
|
const [limits, setLimits] = useState<BackendLimit>()
|
||
|
|
const [loading, setLoading] = useState(true)
|
||
|
|
const [loadingReason, setLoadingReason] = useState<string>('')
|
||
|
|
const [formError, setFormError] = useState<string>('')
|
||
|
|
const [error, setError] = useState<string>('')
|
||
|
|
|
||
|
|
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<BackendLimit>('/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<BackendChallenge>('/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 (
|
||
|
|
<>
|
||
|
|
<HeaderMessage label="View: Upload" />
|
||
|
|
<div className="view-upload">
|
||
|
|
<InputFile ref={fileRef} limits={limits?.upload} />
|
||
|
|
<InputText ref={titleRef} label="Metadata: Title" placeholder="My Creation" />
|
||
|
|
<InputTags ref={tagsRef} label="Metadata: Tags" allowCustom={true} onChange={undefined} />
|
||
|
|
|
||
|
|
{!error && !loading && !formError && <FooterText label="" /* lazy divider */ />}
|
||
|
|
{!error && loading && <FooterLoading reason={loadingReason} />}
|
||
|
|
{!error && formError && <FooterError reason={formError} />}
|
||
|
|
{error && <FooterError reason={error} />}
|
||
|
|
|
||
|
|
<InputButton
|
||
|
|
id="action-upload"
|
||
|
|
label="Upload"
|
||
|
|
onClick={onSubmit}
|
||
|
|
disabled={loading || !!error}
|
||
|
|
rainbow={false}
|
||
|
|
selected={false}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|