improvement(buffer): explicit mediaType override instead of guessing on ambiguous media (#5642)

* improvement(buffer): explicit mediaType override; error instead of guessing on ambiguous media

* fix(buffer): forward validated mediaType from routes into the media resolver

* fix(buffer): presign uploaded media for 7 days — Buffer fetches assets at publish time

* docs(buffer): document presign lifetime bound by signing credentials

* fix(buffer): always mint fresh presigned URLs from verified storage keys for media
This commit is contained in:
Waleed
2026-07-13 14:48:16 -07:00
committed by GitHub
parent 2d41360807
commit e7afe45b2d
10 changed files with 133 additions and 29 deletions
@@ -33,7 +33,8 @@ Create a post in Buffer for a channel — add it to the queue, share it immediat
| `schedulingType` | string | No | How the post publishes: automatic \(Buffer publishes it, default\) or notification \(you get a mobile reminder\) |
| `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) |
| `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it |
| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL |
| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Buffer downloads the media at publish time; uploaded files are shared via a link valid for 7 days, so use a public URL for posts scheduled further out |
| `mediaType` | string | No | Force the attachment type when it cannot be detected from the file or URL: image or video \(default auto\) |
| `mediaAltText` | string | No | Alt text for an attached image |
#### Output
@@ -82,7 +83,8 @@ Edit an existing Buffer post — update its text, schedule, or media. Attaching
| `schedulingType` | string | No | How the post publishes: automatic \(Buffer publishes it, default\) or notification \(you get a mobile reminder\) |
| `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) |
| `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it |
| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Replaces existing attachments |
| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Buffer downloads the media at publish time; uploaded files are shared via a link valid for 7 days, so use a public URL for posts scheduled further out. Replaces existing attachments |
| `mediaType` | string | No | Force the attachment type when it cannot be detected from the file or URL: image or video \(default auto\) |
| `mediaAltText` | string | No | Alt text for an attached image |
#### Output
@@ -56,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
dueAt: body.dueAt,
saveToDraft: body.saveToDraft,
media: body.media,
mediaType: body.mediaType,
mediaAltText: body.mediaAltText,
userId: authResult.userId,
requestId,
@@ -56,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
dueAt: body.dueAt,
saveToDraft: body.saveToDraft,
media: body.media,
mediaType: body.mediaType,
mediaAltText: body.mediaAltText,
userId: authResult.userId,
requestId,
+39 -7
View File
@@ -18,6 +18,17 @@ import {
const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.m4v', '.webm', '.avi']
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
const MEDIA_PROBE_TIMEOUT_MS = 5000
/**
* Buffer fetches asset URLs at publish time, which for queued or scheduled
* posts can be days after createPost. Presign stored files for the S3 maximum
* of 7 days so scheduled posts within that window can still be published.
* The effective lifetime is additionally bounded by the signing credentials:
* Sim's storage clients sign with static keys (AWS_ACCESS_KEY_ID /
* AWS_SECRET_ACCESS_KEY), which support the full 7 days; deployments signing
* with temporary session credentials cap every presigned URL at the session
* lifetime platform-wide.
*/
const MEDIA_PRESIGN_EXPIRY_SECONDS = 7 * 24 * 60 * 60
const CREATE_POST_MUTATION = `
mutation CreatePost($input: CreatePostInput!) {
@@ -53,6 +64,7 @@ const EDIT_POST_MUTATION = `
interface ResolveMediaAssetOptions {
media: RawFileInput | string
mediaType?: 'auto' | 'image' | 'video' | null
mediaAltText?: string | null
userId: string
requestId: string
@@ -79,7 +91,8 @@ function mediaKindFromExtension(pathOrName: string): 'image' | 'video' | null {
* Determines whether media should be attached as a video or image asset.
* Prefers the file's MIME type, then the path/URL extension, and for
* extensionless URLs falls back to a DNS-pinned HEAD probe of the resolved
* URL's Content-Type. Defaults to image when nothing is conclusive.
* URL's Content-Type. Returns null when nothing is conclusive so the caller
* can ask for an explicit media type instead of guessing.
*/
async function resolveMediaKind(
mimeType: string | undefined,
@@ -87,7 +100,7 @@ async function resolveMediaKind(
fileUrl: string,
requestId: string,
logger: Logger
): Promise<'image' | 'video'> {
): Promise<'image' | 'video' | null> {
if (mimeType?.startsWith('video/')) return 'video'
if (mimeType?.startsWith('image/')) return 'image'
@@ -106,11 +119,11 @@ async function resolveMediaKind(
if (contentType.startsWith('image/')) return 'image'
}
} catch (error) {
logger.warn(`[${requestId}] Media content-type probe failed, defaulting to image`, {
logger.warn(`[${requestId}] Media content-type probe was inconclusive`, {
error: getErrorMessage(error, 'probe failed'),
})
}
return 'image'
return null
}
/**
@@ -122,7 +135,7 @@ async function resolveMediaKind(
export async function resolveMediaAsset(
options: ResolveMediaAssetOptions
): Promise<ResolvedMediaAsset> {
const { media, mediaAltText, userId, requestId, logger } = options
const { media, mediaType, mediaAltText, userId, requestId, logger } = options
const isFileInput = typeof media === 'object'
const resolution = await resolveFileInputToUrl({
@@ -131,6 +144,7 @@ export async function resolveMediaAsset(
userId,
requestId,
logger,
presignExpirySeconds: MEDIA_PRESIGN_EXPIRY_SECONDS,
})
if (resolution.error || !resolution.fileUrl) {
return {
@@ -143,7 +157,22 @@ export async function resolveMediaAsset(
const mimeType = isFileInput ? media.type : undefined
const pathOrName = isFileInput ? media.name || '' : media
const kind = await resolveMediaKind(mimeType, pathOrName, resolution.fileUrl, requestId, logger)
const kind =
mediaType === 'image' || mediaType === 'video'
? mediaType
: await resolveMediaKind(mimeType, pathOrName, resolution.fileUrl, requestId, logger)
if (!kind) {
return {
errorResponse: NextResponse.json(
{
success: false,
error:
'Could not determine whether the media is an image or a video. Set mediaType to "image" or "video".',
},
{ status: 400 }
),
}
}
if (kind === 'video') {
return { asset: { video: { url: resolution.fileUrl } } }
}
@@ -210,6 +239,7 @@ interface ForwardPostMutationOptions {
dueAt?: string | null
saveToDraft?: boolean | null
media?: RawFileInput | string | null
mediaType?: 'auto' | 'image' | 'video' | null
mediaAltText?: string | null
userId: string
requestId: string
@@ -224,7 +254,8 @@ interface ForwardPostMutationOptions {
export async function forwardPostMutation(
options: ForwardPostMutationOptions
): Promise<NextResponse> {
const { apiKey, postId, channelId, media, mediaAltText, userId, requestId, logger } = options
const { apiKey, postId, channelId, media, mediaType, mediaAltText, userId, requestId, logger } =
options
const input: Record<string, unknown> = {
mode: options.mode,
@@ -243,6 +274,7 @@ export async function forwardPostMutation(
if (media) {
const { asset, errorResponse } = await resolveMediaAsset({
media,
mediaType,
mediaAltText,
userId,
requestId,
+17
View File
@@ -140,6 +140,19 @@ export const BufferBlock: BlockConfig<BufferPostResponse> = {
mode: 'advanced',
condition: { field: 'operation', value: POST_EDIT_OPS },
},
{
id: 'mediaType',
title: 'Media Type',
type: 'dropdown',
options: [
{ label: 'Auto-detect', id: 'auto' },
{ label: 'Image', id: 'image' },
{ label: 'Video', id: 'video' },
],
value: () => 'auto',
mode: 'advanced',
condition: { field: 'operation', value: POST_EDIT_OPS },
},
{
id: 'mediaAltText',
title: 'Media Alt Text',
@@ -321,6 +334,10 @@ export const BufferBlock: BlockConfig<BufferPostResponse> = {
dueAt: { type: 'string', description: 'Publish time (ISO 8601)' },
saveToDraft: { type: 'boolean', description: 'Save the post as a draft' },
media: { type: 'string', description: 'Image or video attachment (file or public URL)' },
mediaType: {
type: 'string',
description: 'Attachment type override: auto, image, or video',
},
mediaAltText: { type: 'string', description: 'Alt text for an attached image' },
channelIds: { type: 'string', description: 'Comma-separated channel IDs filter' },
status: { type: 'string', description: 'Comma-separated post status filter' },
@@ -62,6 +62,7 @@ const postSharedFields = {
.nullable(),
saveToDraft: z.boolean().optional().nullable(),
media: FileInputSchema.optional().nullable(),
mediaType: z.enum(['auto', 'image', 'video']).default('auto'),
mediaAltText: z.string().max(1000, 'mediaAltText is too long').optional().nullable(),
}
+50 -18
View File
@@ -49,6 +49,12 @@ export interface ResolveFileInputOptions {
userId: string
requestId: string
logger: Logger
/**
* Expiry for presigned URLs minted for stored files, in seconds.
* Defaults to 5 minutes; raise it only when the external service fetches
* the URL later than the current request (e.g. scheduled publishing).
*/
presignExpirySeconds?: number
}
/**
@@ -62,7 +68,7 @@ export interface ResolveFileInputOptions {
export async function resolveFileInputToUrl(
options: ResolveFileInputOptions
): Promise<FileResolutionResult> {
const { file, filePath, userId, requestId, logger } = options
const { file, filePath, userId, requestId, logger, presignExpirySeconds = 5 * 60 } = options
if (file) {
let userFile: UserFile
@@ -77,19 +83,11 @@ export async function resolveFileInputToUrl(
}
}
let fileUrl = userFile.url || ''
// Handle internal URLs
if (fileUrl && isInternalFileUrl(fileUrl)) {
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
if (resolution.error) {
return { error: resolution.error }
}
fileUrl = resolution.fileUrl || ''
}
// Generate presigned URL if we have a key but no URL
if (!fileUrl && userFile.key) {
// A stored file always gets a freshly minted presigned URL scoped to the
// requested expiry — an embedded url (internal serve path or a previously
// minted presigned link) may be stale, shorter-lived than required, or
// point at a different object than the verified key.
if (userFile.key) {
const context = resolveTrustedFileContext(userFile.key, userFile.context)
const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false)
@@ -102,7 +100,30 @@ export async function resolveFileInputToUrl(
return { error: { status: 404, message: 'File not found' } }
}
fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60)
const fileUrl = await StorageService.generatePresignedDownloadUrl(
userFile.key,
context,
presignExpirySeconds
)
return { fileUrl }
}
let fileUrl = userFile.url || ''
// Without a key, the schema guarantees the url references an uploaded
// file, so resolve the internal serve path to a presigned URL.
if (fileUrl && isInternalFileUrl(fileUrl)) {
const resolution = await resolveInternalFileUrl(
fileUrl,
userId,
requestId,
logger,
presignExpirySeconds
)
if (resolution.error) {
return { error: resolution.error }
}
fileUrl = resolution.fileUrl || ''
}
return { fileUrl }
@@ -112,7 +133,13 @@ export async function resolveFileInputToUrl(
let fileUrl = filePath
if (isInternalFileUrl(filePath)) {
const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger)
const resolution = await resolveInternalFileUrl(
filePath,
userId,
requestId,
logger,
presignExpirySeconds
)
if (resolution.error) {
return { error: resolution.error }
}
@@ -227,7 +254,8 @@ export async function resolveInternalFileUrl(
filePath: string,
userId: string,
requestId: string,
logger: Logger
logger: Logger,
presignExpirySeconds = 5 * 60
): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> {
if (!isInternalFileUrl(filePath)) {
return { fileUrl: filePath }
@@ -247,7 +275,11 @@ export async function resolveInternalFileUrl(
return { error: { status: 404, message: 'File not found' } }
}
const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
const fileUrl = await StorageService.generatePresignedDownloadUrl(
storageKey,
context,
presignExpirySeconds
)
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
return { fileUrl }
} catch (error) {
+9 -1
View File
@@ -62,7 +62,14 @@ export const bufferCreatePostTool: ToolConfig<BufferCreatePostParams, BufferPost
required: false,
visibility: 'user-or-llm',
description:
'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL',
'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Buffer downloads the media at publish time; uploaded files are shared via a link valid for 7 days, so use a public URL for posts scheduled further out',
},
mediaType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Force the attachment type when it cannot be detected from the file or URL: image or video (default auto)',
},
mediaAltText: {
type: 'string',
@@ -85,6 +92,7 @@ export const bufferCreatePostTool: ToolConfig<BufferCreatePostParams, BufferPost
dueAt: params.dueAt,
saveToDraft: params.saveToDraft,
media: params.media,
mediaType: params.mediaType,
mediaAltText: params.mediaAltText,
}),
},
+9 -1
View File
@@ -62,7 +62,14 @@ export const bufferEditPostTool: ToolConfig<BufferEditPostParams, BufferPostResp
required: false,
visibility: 'user-or-llm',
description:
'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Replaces existing attachments',
'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Buffer downloads the media at publish time; uploaded files are shared via a link valid for 7 days, so use a public URL for posts scheduled further out. Replaces existing attachments',
},
mediaType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Force the attachment type when it cannot be detected from the file or URL: image or video (default auto)',
},
mediaAltText: {
type: 'string',
@@ -85,6 +92,7 @@ export const bufferEditPostTool: ToolConfig<BufferEditPostParams, BufferPostResp
dueAt: params.dueAt,
saveToDraft: params.saveToDraft,
media: params.media,
mediaType: params.mediaType,
mediaAltText: params.mediaAltText,
}),
},
+2
View File
@@ -389,6 +389,7 @@ export interface BufferCreatePostParams extends BufferBaseParams {
dueAt?: string
saveToDraft?: boolean
media?: unknown
mediaType?: 'auto' | 'image' | 'video'
mediaAltText?: string
}
@@ -400,6 +401,7 @@ export interface BufferEditPostParams extends BufferBaseParams {
dueAt?: string
saveToDraft?: boolean
media?: unknown
mediaType?: 'auto' | 'image' | 'video'
mediaAltText?: string
}