[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
parent
b7d78fe59b
commit
45a719b256
|
@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
|
|||
|
||||
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 = [
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
|
|
|
@ -11,3 +11,10 @@ export class ServerError extends Error {
|
|||
this.name = 'ServerError'
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'UploadLimitError'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import {useMemo} from 'react'
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
|
||||
import {SupportedMimeTypes} from '#/lib/constants'
|
||||
|
||||
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
||||
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
||||
|
||||
export const createVideoEndpointUrl = (
|
||||
route: string,
|
||||
params?: Record<string, string>,
|
||||
) => {
|
||||
const url = new URL(`${UPLOAD_ENDPOINT}`)
|
||||
const url = new URL(VIDEO_SERVICE)
|
||||
url.pathname = route
|
||||
if (params) {
|
||||
for (const key in params) {
|
||||
|
@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
|
|||
export function useVideoAgent() {
|
||||
return useMemo(() => {
|
||||
return new AtpAgent({
|
||||
service: UPLOAD_ENDPOINT,
|
||||
service: VIDEO_SERVICE,
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
|||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
|
@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
|
|||
signal: AbortSignal
|
||||
}) => {
|
||||
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()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
await checkLimits()
|
||||
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
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(
|
||||
uri,
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'content-type': video.mimeType,
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
Authorization: `Bearer ${await getToken()}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
|
|
|
@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
|||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
|
@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
|
|||
signal: AbortSignal
|
||||
}) => {
|
||||
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()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
await checkLimits()
|
||||
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
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
|
||||
|
||||
if (!bytes) {
|
||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
}
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||
(resolve, reject) => {
|
||||
|
@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
|
|||
}
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.send(bytes)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
|
|||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
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 {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||
import {useVideoAgent} from 'state/queries/video/util'
|
||||
|
@ -149,10 +153,40 @@ export function useUploadVideo({
|
|||
onError: e => {
|
||||
if (e instanceof AbortError) {
|
||||
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! We’re gradually giving access to video, and you’re 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({
|
||||
type: 'SetError',
|
||||
error: e.message,
|
||||
error: message,
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
|
|
|
@ -42,7 +42,7 @@ export function VideoPreview({
|
|||
ref.current.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
Toast.show(_(msg`Could not process your video`))
|
||||
Toast.show(_(msg`Could not process your video`), 'xmark')
|
||||
clear()
|
||||
},
|
||||
{signal},
|
||||
|
|
Loading…
Reference in New Issue