Skip to content

Commit 45a719b

Browse files
mozziushaileyok
andauthored
[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 <[email protected]>
1 parent b7d78fe commit 45a719b

File tree

8 files changed

+146
-46
lines changed

8 files changed

+146
-46
lines changed

src/lib/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
137137

138138
export const MAX_LABELERS = 20
139139

140+
export const VIDEO_SERVICE = 'https://video.bsky.app'
141+
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
142+
140143
export const SUPPORTED_MIME_TYPES = [
141144
'video/mp4',
142145
'video/mpeg',

src/lib/media/video/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ export class ServerError extends Error {
1111
this.name = 'ServerError'
1212
}
1313
}
14+
15+
export class UploadLimitError extends Error {
16+
constructor(message: string) {
17+
super(message)
18+
this.name = 'UploadLimitError'
19+
}
20+
}

src/state/queries/video/util.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import {useMemo} from 'react'
22
import {AtpAgent} from '@atproto/api'
33

4-
import {SupportedMimeTypes} from '#/lib/constants'
5-
6-
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
4+
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
75

86
export const createVideoEndpointUrl = (
97
route: string,
108
params?: Record<string, string>,
119
) => {
12-
const url = new URL(`${UPLOAD_ENDPOINT}`)
10+
const url = new URL(VIDEO_SERVICE)
1311
url.pathname = route
1412
if (params) {
1513
for (const key in params) {
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
2220
export function useVideoAgent() {
2321
return useMemo(() => {
2422
return new AtpAgent({
25-
service: UPLOAD_ENDPOINT,
23+
service: VIDEO_SERVICE,
2624
})
2725
}, [])
2826
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {useCallback} from 'react'
2+
import {msg} from '@lingui/macro'
3+
import {useLingui} from '@lingui/react'
4+
5+
import {VIDEO_SERVICE_DID} from '#/lib/constants'
6+
import {UploadLimitError} from '#/lib/media/video/errors'
7+
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
8+
import {useAgent} from '#/state/session'
9+
import {useVideoAgent} from './util'
10+
11+
export function useServiceAuthToken({
12+
aud,
13+
lxm,
14+
exp,
15+
}: {
16+
aud?: string
17+
lxm: string
18+
exp?: number
19+
}) {
20+
const agent = useAgent()
21+
22+
return useCallback(async () => {
23+
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
24+
25+
if (!pdsAud) {
26+
throw new Error('Agent does not have a PDS URL')
27+
}
28+
29+
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
30+
aud: aud ?? pdsAud,
31+
lxm,
32+
exp,
33+
})
34+
35+
return serviceAuth.token
36+
}, [agent, aud, lxm, exp])
37+
}
38+
39+
export function useVideoUploadLimits() {
40+
const agent = useVideoAgent()
41+
const getToken = useServiceAuthToken({
42+
lxm: 'app.bsky.video.getUploadLimits',
43+
aud: VIDEO_SERVICE_DID,
44+
})
45+
const {_} = useLingui()
46+
47+
return useCallback(async () => {
48+
const {data: limits} = await agent.app.bsky.video
49+
.getUploadLimits(
50+
{},
51+
{headers: {Authorization: `Bearer ${await getToken()}`}},
52+
)
53+
.catch(err => {
54+
if (err instanceof Error) {
55+
throw new UploadLimitError(err.message)
56+
} else {
57+
throw err
58+
}
59+
})
60+
61+
if (!limits.canUpload) {
62+
if (limits.message) {
63+
throw new UploadLimitError(limits.message)
64+
} else {
65+
throw new UploadLimitError(
66+
_(
67+
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
68+
),
69+
)
70+
}
71+
}
72+
}, [agent, _, getToken])
73+
}

src/state/queries/video/video-upload.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
99
import {ServerError} from '#/lib/media/video/errors'
1010
import {CompressedVideo} from '#/lib/media/video/types'
1111
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
12-
import {useAgent, useSession} from '#/state/session'
13-
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
12+
import {useSession} from '#/state/session'
13+
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
1414

1515
export const useUploadVideoMutation = ({
1616
onSuccess,
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
2424
signal: AbortSignal
2525
}) => {
2626
const {currentAccount} = useSession()
27-
const agent = useAgent()
27+
const getToken = useServiceAuthToken({
28+
lxm: 'com.atproto.repo.uploadBlob',
29+
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
30+
})
31+
const checkLimits = useVideoUploadLimits()
2832
const {_} = useLingui()
2933

3034
return useMutation({
3135
mutationKey: ['video', 'upload'],
3236
mutationFn: cancelable(async (video: CompressedVideo) => {
37+
await checkLimits()
38+
3339
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
3440
did: currentAccount!.did,
3541
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
3642
})
3743

38-
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
39-
40-
if (!serviceAuthAud) {
41-
throw new Error('Agent does not have a PDS URL')
42-
}
43-
44-
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
45-
{
46-
aud: serviceAuthAud,
47-
lxm: 'com.atproto.repo.uploadBlob',
48-
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
49-
},
50-
)
51-
5244
const uploadTask = createUploadTask(
5345
uri,
5446
video.uri,
5547
{
5648
headers: {
5749
'content-type': video.mimeType,
58-
Authorization: `Bearer ${serviceAuth.token}`,
50+
Authorization: `Bearer ${await getToken()}`,
5951
},
6052
httpMethod: 'POST',
6153
uploadType: FileSystemUploadType.BINARY_CONTENT,

src/state/queries/video/video-upload.web.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
88
import {ServerError} from '#/lib/media/video/errors'
99
import {CompressedVideo} from '#/lib/media/video/types'
1010
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
11-
import {useAgent, useSession} from '#/state/session'
12-
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
11+
import {useSession} from '#/state/session'
12+
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
1313

1414
export const useUploadVideoMutation = ({
1515
onSuccess,
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
2323
signal: AbortSignal
2424
}) => {
2525
const {currentAccount} = useSession()
26-
const agent = useAgent()
26+
const getToken = useServiceAuthToken({
27+
lxm: 'com.atproto.repo.uploadBlob',
28+
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
29+
})
30+
const checkLimits = useVideoUploadLimits()
2731
const {_} = useLingui()
2832

2933
return useMutation({
3034
mutationKey: ['video', 'upload'],
3135
mutationFn: cancelable(async (video: CompressedVideo) => {
36+
await checkLimits()
37+
3238
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
3339
did: currentAccount!.did,
3440
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
3541
})
3642

37-
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
38-
39-
if (!serviceAuthAud) {
40-
throw new Error('Agent does not have a PDS URL')
41-
}
42-
43-
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
44-
{
45-
aud: serviceAuthAud,
46-
lxm: 'com.atproto.repo.uploadBlob',
47-
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
48-
},
49-
)
50-
5143
let bytes = video.bytes
52-
5344
if (!bytes) {
5445
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
5546
}
5647

48+
const token = await getToken()
49+
5750
const xhr = new XMLHttpRequest()
5851
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
5952
(resolve, reject) => {
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
7669
}
7770
xhr.open('POST', uri)
7871
xhr.setRequestHeader('Content-Type', video.mimeType)
79-
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
72+
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
8073
xhr.send(bytes)
8174
},
8275
)

src/state/queries/video/video.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
99
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
1010
import {logger} from '#/logger'
1111
import {isWeb} from '#/platform/detection'
12-
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
12+
import {
13+
ServerError,
14+
UploadLimitError,
15+
VideoTooLargeError,
16+
} from 'lib/media/video/errors'
1317
import {CompressedVideo} from 'lib/media/video/types'
1418
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
1519
import {useVideoAgent} from 'state/queries/video/util'
@@ -149,10 +153,40 @@ export function useUploadVideo({
149153
onError: e => {
150154
if (e instanceof AbortError) {
151155
return
152-
} else if (e instanceof ServerError) {
156+
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
157+
let message
158+
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
159+
switch (e.message) {
160+
case 'User is not allowed to upload videos':
161+
message = _(msg`You are not allowed to upload videos.`)
162+
break
163+
case 'Uploading is disabled at the moment':
164+
message = _(
165+
msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
166+
)
167+
break
168+
case "Failed to get user's upload stats":
169+
message = _(
170+
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
171+
)
172+
break
173+
case 'User has exceeded daily upload bytes limit':
174+
message = _(
175+
msg`You've reached your daily limit for video uploads (too many bytes)`,
176+
)
177+
break
178+
case 'User has exceeded daily upload videos limit':
179+
message = _(
180+
msg`You've reached your daily limit for video uploads (too many videos)`,
181+
)
182+
break
183+
default:
184+
message = e.message
185+
break
186+
}
153187
dispatch({
154188
type: 'SetError',
155-
error: e.message,
189+
error: message,
156190
})
157191
} else {
158192
dispatch({

src/view/com/composer/videos/VideoPreview.web.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function VideoPreview({
4242
ref.current.addEventListener(
4343
'error',
4444
() => {
45-
Toast.show(_(msg`Could not process your video`))
45+
Toast.show(_(msg`Could not process your video`), 'xmark')
4646
clear()
4747
},
4848
{signal},

0 commit comments

Comments
 (0)