Files
bsky-social-app/src/lib/media/video/upload.shared.ts
T
2026-08-24 17:34:16 -07:00

116 lines
3.5 KiB
TypeScript

import {type Client} from '@atproto/lex'
import {type DidString, type NsidString} from '@atproto/syntax'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {app, com} from '#/lexicons'
import {createVideoServiceClient} from './util'
export async function getServiceAuthToken({
client,
dispatchUrl,
aud,
lxm,
exp,
}: {
client: Client
/**
* The account's dispatch URL (the old `agent.dispatchUrl`: its PDS, falling
* back to the account service). Only needed when `aud` is omitted, so the
* default audience can be derived from the PDS host. A lex {@link Client} does
* not expose this - it resolves the PDS per request internally - so the caller,
* which holds the session, passes it in.
*/
dispatchUrl?: string | URL
aud?: string
lxm: NsidString
/**
* Unix timestamp in *seconds* at which the token expires. Fractional values
* are floored - see {@link toIntegerExp}. Defaults to the server's own
* short expiry when omitted.
*/
exp?: number
}) {
let resolvedAud = aud
if (!resolvedAud) {
if (!dispatchUrl) {
throw new Error('Missing service auth audience: no aud or dispatchUrl')
}
const pdsAud = getServiceAuthAudFromUrl(dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
resolvedAud = pdsAud
}
const {token} = await client.call(com.atproto.server.getServiceAuth, {
aud: resolvedAud as DidString,
lxm,
exp: exp === undefined ? undefined : toIntegerExp(exp),
})
return token
}
/**
* Default lifetime for the video upload service auth token. Long enough to
* cover a slow upload of a large file, short enough to limit the damage if the
* token leaks.
*/
export const SERVICE_AUTH_TTL_SEC = 60 * 30
/**
* Build a service auth `exp` claim `ttlSec` seconds from now.
*
* Always use this instead of hand-rolling the arithmetic: `Date.now()` is in
* milliseconds, and dividing by 1000 without flooring yields a fractional
* timestamp that the endpoint rejects.
*/
export function serviceAuthExp(ttlSec: number = SERVICE_AUTH_TTL_SEC) {
return Math.floor(Date.now() / 1000) + Math.floor(ttlSec)
}
/**
* The lexicon types `exp` as an integer and it is serialized straight into the
* query string, so a fractional value fails validation and the upload dies
* before it starts. Floor here, at the single chokepoint every caller goes
* through, so a call site that forgets to cannot reintroduce the bug.
*/
function toIntegerExp(exp: number) {
if (!Number.isFinite(exp)) {
throw new Error(`Invalid service auth exp: ${exp}`)
}
return Math.floor(exp)
}
export async function getVideoUploadLimits(client: Client, i18n: I18n) {
const token = await getServiceAuthToken({
client,
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const videoClient = createVideoServiceClient(token)
const limits = await videoClient
.call(app.bsky.video.getUploadLimits)
.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(
i18n._(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}