Skip to content

Commit

Permalink
[Video] Check upload limits before uploading (#5153)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
mozzius and haileyok authored Sep 7, 2024
1 parent b7d78fe commit 45a719b
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 46 deletions.
3 changes: 3 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions src/lib/media/video/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
8 changes: 3 additions & 5 deletions src/state/queries/video/util.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
export function useVideoAgent() {
return useMemo(() => {
return new AtpAgent({
service: UPLOAD_ENDPOINT,
service: VIDEO_SERVICE,
})
}, [])
}
Expand Down
73 changes: 73 additions & 0 deletions src/state/queries/video/video-upload.shared.ts
Original file line number Diff line number Diff line change
@@ -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])
}
28 changes: 10 additions & 18 deletions src/state/queries/video/video-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
31 changes: 12 additions & 19 deletions src/state/queries/video/video-upload.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => {
Expand All @@ -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)
},
)
Expand Down
40 changes: 37 additions & 3 deletions src/state/queries/video/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/composer/videos/VideoPreview.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down

0 comments on commit 45a719b

Please sign in to comment.