[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 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',
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {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,
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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! 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({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: e.message,
|
error: message,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -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},
|
||||||
|
|
Loading…
Reference in New Issue