[Video] Check upload limits before uploading (#5153)

* DRY up video service auth code

* throw error if over upload limits

* use token

* xmark on toast

* errors with nice translatable error messages

* Update src/state/queries/video/video.ts

---------

Co-authored-by: Hailey <me@haileyok.com>
zio/stable
Samuel Newman 2024-09-07 19:27:32 +01:00 committed by GitHub
parent b7d78fe59b
commit 45a719b256
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 146 additions and 46 deletions

View File

@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
export const MAX_LABELERS = 20 export const MAX_LABELERS = 20
export const VIDEO_SERVICE = 'https://video.bsky.app'
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
export const SUPPORTED_MIME_TYPES = [ export const SUPPORTED_MIME_TYPES = [
'video/mp4', 'video/mp4',
'video/mpeg', 'video/mpeg',

View File

@ -11,3 +11,10 @@ export class ServerError extends Error {
this.name = 'ServerError' this.name = 'ServerError'
} }
} }
export class UploadLimitError extends Error {
constructor(message: string) {
super(message)
this.name = 'UploadLimitError'
}
}

View File

@ -1,15 +1,13 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api' import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes} from '#/lib/constants' import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
export const createVideoEndpointUrl = ( export const createVideoEndpointUrl = (
route: string, route: string,
params?: Record<string, string>, params?: Record<string, string>,
) => { ) => {
const url = new URL(`${UPLOAD_ENDPOINT}`) const url = new URL(VIDEO_SERVICE)
url.pathname = route url.pathname = route
if (params) { if (params) {
for (const key in params) { for (const key in params) {
@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
export function useVideoAgent() { export function useVideoAgent() {
return useMemo(() => { return useMemo(() => {
return new AtpAgent({ return new AtpAgent({
service: UPLOAD_ENDPOINT, service: VIDEO_SERVICE,
}) })
}, []) }, [])
} }

View File

@ -0,0 +1,73 @@
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {useAgent} from '#/state/session'
import {useVideoAgent} from './util'
export function useServiceAuthToken({
aud,
lxm,
exp,
}: {
aud?: string
lxm: string
exp?: number
}) {
const agent = useAgent()
return useCallback(async () => {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
}, [agent, aud, lxm, exp])
}
export function useVideoUploadLimits() {
const agent = useVideoAgent()
const getToken = useServiceAuthToken({
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const {_} = useLingui()
return useCallback(async () => {
const {data: limits} = await agent.app.bsky.video
.getUploadLimits(
{},
{headers: {Authorization: `Bearer ${await getToken()}`}},
)
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}, [agent, _, getToken])
}

View File

@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({ export const useUploadVideoMutation = ({
onSuccess, onSuccess,
@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal signal: AbortSignal
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui() const {_} = useLingui()
return useMutation({ return useMutation({
mutationKey: ['video', 'upload'], mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => { mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
}) })
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!serviceAuthAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
{
aud: serviceAuthAud,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
},
)
const uploadTask = createUploadTask( const uploadTask = createUploadTask(
uri, uri,
video.uri, video.uri,
{ {
headers: { headers: {
'content-type': video.mimeType, 'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`, Authorization: `Bearer ${await getToken()}`,
}, },
httpMethod: 'POST', httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT, uploadType: FileSystemUploadType.BINARY_CONTENT,

View File

@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({ export const useUploadVideoMutation = ({
onSuccess, onSuccess,
@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal signal: AbortSignal
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui() const {_} = useLingui()
return useMutation({ return useMutation({
mutationKey: ['video', 'upload'], mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => { mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
}) })
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!serviceAuthAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
{
aud: serviceAuthAud,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
},
)
let bytes = video.bytes let bytes = video.bytes
if (!bytes) { if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer()) bytes = await fetch(video.uri).then(res => res.arrayBuffer())
} }
const token = await getToken()
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>( const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => { (resolve, reject) => {
@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
} }
xhr.open('POST', uri) xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType) xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes) xhr.send(bytes)
}, },
) )

View File

@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors' import {
ServerError,
UploadLimitError,
VideoTooLargeError,
} from 'lib/media/video/errors'
import {CompressedVideo} from 'lib/media/video/types' import {CompressedVideo} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video' import {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util' import {useVideoAgent} from 'state/queries/video/util'
@ -149,10 +153,40 @@ export function useUploadVideo({
onError: e => { onError: e => {
if (e instanceof AbortError) { if (e instanceof AbortError) {
return return
} else if (e instanceof ServerError) { } else if (e instanceof ServerError || e instanceof UploadLimitError) {
let message
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
message = _(msg`You are not allowed to upload videos.`)
break
case 'Uploading is disabled at the moment':
message = _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
break
case "Failed to get user's upload stats":
message = _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
break
case 'User has exceeded daily upload bytes limit':
message = _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
break
case 'User has exceeded daily upload videos limit':
message = _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
break
default:
message = e.message
break
}
dispatch({ dispatch({
type: 'SetError', type: 'SetError',
error: e.message, error: message,
}) })
} else { } else {
dispatch({ dispatch({

View File

@ -42,7 +42,7 @@ export function VideoPreview({
ref.current.addEventListener( ref.current.addEventListener(
'error', 'error',
() => { () => {
Toast.show(_(msg`Could not process your video`)) Toast.show(_(msg`Could not process your video`), 'xmark')
clear() clear()
}, },
{signal}, {signal},