Skip to content

Commit e7afe45

Browse files
authored
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
1 parent 2d41360 commit e7afe45

10 files changed

Lines changed: 133 additions & 29 deletions

File tree

apps/docs/content/docs/en/integrations/buffer.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ Create a post in Buffer for a channel — add it to the queue, share it immediat
3333
| `schedulingType` | string | No | How the post publishes: automatic \(Buffer publishes it, default\) or notification \(you get a mobile reminder\) |
3434
| `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) |
3535
| `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it |
36-
| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL |
36+
| `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 |
37+
| `mediaType` | string | No | Force the attachment type when it cannot be detected from the file or URL: image or video \(default auto\) |
3738
| `mediaAltText` | string | No | Alt text for an attached image |
3839

3940
#### Output
@@ -82,7 +83,8 @@ Edit an existing Buffer post — update its text, schedule, or media. Attaching
8283
| `schedulingType` | string | No | How the post publishes: automatic \(Buffer publishes it, default\) or notification \(you get a mobile reminder\) |
8384
| `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) |
8485
| `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it |
85-
| `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 |
86+
| `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 |
87+
| `mediaType` | string | No | Force the attachment type when it cannot be detected from the file or URL: image or video \(default auto\) |
8688
| `mediaAltText` | string | No | Alt text for an attached image |
8789

8890
#### Output

apps/sim/app/api/tools/buffer/create-post/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5656
dueAt: body.dueAt,
5757
saveToDraft: body.saveToDraft,
5858
media: body.media,
59+
mediaType: body.mediaType,
5960
mediaAltText: body.mediaAltText,
6061
userId: authResult.userId,
6162
requestId,

apps/sim/app/api/tools/buffer/edit-post/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5656
dueAt: body.dueAt,
5757
saveToDraft: body.saveToDraft,
5858
media: body.media,
59+
mediaType: body.mediaType,
5960
mediaAltText: body.mediaAltText,
6061
userId: authResult.userId,
6162
requestId,

apps/sim/app/api/tools/buffer/server-utils.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ import {
1818
const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.m4v', '.webm', '.avi']
1919
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
2020
const MEDIA_PROBE_TIMEOUT_MS = 5000
21+
/**
22+
* Buffer fetches asset URLs at publish time, which for queued or scheduled
23+
* posts can be days after createPost. Presign stored files for the S3 maximum
24+
* of 7 days so scheduled posts within that window can still be published.
25+
* The effective lifetime is additionally bounded by the signing credentials:
26+
* Sim's storage clients sign with static keys (AWS_ACCESS_KEY_ID /
27+
* AWS_SECRET_ACCESS_KEY), which support the full 7 days; deployments signing
28+
* with temporary session credentials cap every presigned URL at the session
29+
* lifetime platform-wide.
30+
*/
31+
const MEDIA_PRESIGN_EXPIRY_SECONDS = 7 * 24 * 60 * 60
2132

2233
const CREATE_POST_MUTATION = `
2334
mutation CreatePost($input: CreatePostInput!) {
@@ -53,6 +64,7 @@ const EDIT_POST_MUTATION = `
5364

5465
interface ResolveMediaAssetOptions {
5566
media: RawFileInput | string
67+
mediaType?: 'auto' | 'image' | 'video' | null
5668
mediaAltText?: string | null
5769
userId: string
5870
requestId: string
@@ -79,15 +91,16 @@ function mediaKindFromExtension(pathOrName: string): 'image' | 'video' | null {
7991
* Determines whether media should be attached as a video or image asset.
8092
* Prefers the file's MIME type, then the path/URL extension, and for
8193
* extensionless URLs falls back to a DNS-pinned HEAD probe of the resolved
82-
* URL's Content-Type. Defaults to image when nothing is conclusive.
94+
* URL's Content-Type. Returns null when nothing is conclusive so the caller
95+
* can ask for an explicit media type instead of guessing.
8396
*/
8497
async function resolveMediaKind(
8598
mimeType: string | undefined,
8699
pathOrName: string,
87100
fileUrl: string,
88101
requestId: string,
89102
logger: Logger
90-
): Promise<'image' | 'video'> {
103+
): Promise<'image' | 'video' | null> {
91104
if (mimeType?.startsWith('video/')) return 'video'
92105
if (mimeType?.startsWith('image/')) return 'image'
93106

@@ -106,11 +119,11 @@ async function resolveMediaKind(
106119
if (contentType.startsWith('image/')) return 'image'
107120
}
108121
} catch (error) {
109-
logger.warn(`[${requestId}] Media content-type probe failed, defaulting to image`, {
122+
logger.warn(`[${requestId}] Media content-type probe was inconclusive`, {
110123
error: getErrorMessage(error, 'probe failed'),
111124
})
112125
}
113-
return 'image'
126+
return null
114127
}
115128

116129
/**
@@ -122,7 +135,7 @@ async function resolveMediaKind(
122135
export async function resolveMediaAsset(
123136
options: ResolveMediaAssetOptions
124137
): Promise<ResolvedMediaAsset> {
125-
const { media, mediaAltText, userId, requestId, logger } = options
138+
const { media, mediaType, mediaAltText, userId, requestId, logger } = options
126139

127140
const isFileInput = typeof media === 'object'
128141
const resolution = await resolveFileInputToUrl({
@@ -131,6 +144,7 @@ export async function resolveMediaAsset(
131144
userId,
132145
requestId,
133146
logger,
147+
presignExpirySeconds: MEDIA_PRESIGN_EXPIRY_SECONDS,
134148
})
135149
if (resolution.error || !resolution.fileUrl) {
136150
return {
@@ -143,7 +157,22 @@ export async function resolveMediaAsset(
143157

144158
const mimeType = isFileInput ? media.type : undefined
145159
const pathOrName = isFileInput ? media.name || '' : media
146-
const kind = await resolveMediaKind(mimeType, pathOrName, resolution.fileUrl, requestId, logger)
160+
const kind =
161+
mediaType === 'image' || mediaType === 'video'
162+
? mediaType
163+
: await resolveMediaKind(mimeType, pathOrName, resolution.fileUrl, requestId, logger)
164+
if (!kind) {
165+
return {
166+
errorResponse: NextResponse.json(
167+
{
168+
success: false,
169+
error:
170+
'Could not determine whether the media is an image or a video. Set mediaType to "image" or "video".',
171+
},
172+
{ status: 400 }
173+
),
174+
}
175+
}
147176
if (kind === 'video') {
148177
return { asset: { video: { url: resolution.fileUrl } } }
149178
}
@@ -210,6 +239,7 @@ interface ForwardPostMutationOptions {
210239
dueAt?: string | null
211240
saveToDraft?: boolean | null
212241
media?: RawFileInput | string | null
242+
mediaType?: 'auto' | 'image' | 'video' | null
213243
mediaAltText?: string | null
214244
userId: string
215245
requestId: string
@@ -224,7 +254,8 @@ interface ForwardPostMutationOptions {
224254
export async function forwardPostMutation(
225255
options: ForwardPostMutationOptions
226256
): Promise<NextResponse> {
227-
const { apiKey, postId, channelId, media, mediaAltText, userId, requestId, logger } = options
257+
const { apiKey, postId, channelId, media, mediaType, mediaAltText, userId, requestId, logger } =
258+
options
228259

229260
const input: Record<string, unknown> = {
230261
mode: options.mode,
@@ -243,6 +274,7 @@ export async function forwardPostMutation(
243274
if (media) {
244275
const { asset, errorResponse } = await resolveMediaAsset({
245276
media,
277+
mediaType,
246278
mediaAltText,
247279
userId,
248280
requestId,

apps/sim/blocks/blocks/buffer.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,19 @@ export const BufferBlock: BlockConfig<BufferPostResponse> = {
140140
mode: 'advanced',
141141
condition: { field: 'operation', value: POST_EDIT_OPS },
142142
},
143+
{
144+
id: 'mediaType',
145+
title: 'Media Type',
146+
type: 'dropdown',
147+
options: [
148+
{ label: 'Auto-detect', id: 'auto' },
149+
{ label: 'Image', id: 'image' },
150+
{ label: 'Video', id: 'video' },
151+
],
152+
value: () => 'auto',
153+
mode: 'advanced',
154+
condition: { field: 'operation', value: POST_EDIT_OPS },
155+
},
143156
{
144157
id: 'mediaAltText',
145158
title: 'Media Alt Text',
@@ -321,6 +334,10 @@ export const BufferBlock: BlockConfig<BufferPostResponse> = {
321334
dueAt: { type: 'string', description: 'Publish time (ISO 8601)' },
322335
saveToDraft: { type: 'boolean', description: 'Save the post as a draft' },
323336
media: { type: 'string', description: 'Image or video attachment (file or public URL)' },
337+
mediaType: {
338+
type: 'string',
339+
description: 'Attachment type override: auto, image, or video',
340+
},
324341
mediaAltText: { type: 'string', description: 'Alt text for an attached image' },
325342
channelIds: { type: 'string', description: 'Comma-separated channel IDs filter' },
326343
status: { type: 'string', description: 'Comma-separated post status filter' },

apps/sim/lib/api/contracts/tools/buffer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const postSharedFields = {
6262
.nullable(),
6363
saveToDraft: z.boolean().optional().nullable(),
6464
media: FileInputSchema.optional().nullable(),
65+
mediaType: z.enum(['auto', 'image', 'video']).default('auto'),
6566
mediaAltText: z.string().max(1000, 'mediaAltText is too long').optional().nullable(),
6667
}
6768

apps/sim/lib/uploads/utils/file-utils.server.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export interface ResolveFileInputOptions {
4949
userId: string
5050
requestId: string
5151
logger: Logger
52+
/**
53+
* Expiry for presigned URLs minted for stored files, in seconds.
54+
* Defaults to 5 minutes; raise it only when the external service fetches
55+
* the URL later than the current request (e.g. scheduled publishing).
56+
*/
57+
presignExpirySeconds?: number
5258
}
5359

5460
/**
@@ -62,7 +68,7 @@ export interface ResolveFileInputOptions {
6268
export async function resolveFileInputToUrl(
6369
options: ResolveFileInputOptions
6470
): Promise<FileResolutionResult> {
65-
const { file, filePath, userId, requestId, logger } = options
71+
const { file, filePath, userId, requestId, logger, presignExpirySeconds = 5 * 60 } = options
6672

6773
if (file) {
6874
let userFile: UserFile
@@ -77,19 +83,11 @@ export async function resolveFileInputToUrl(
7783
}
7884
}
7985

80-
let fileUrl = userFile.url || ''
81-
82-
// Handle internal URLs
83-
if (fileUrl && isInternalFileUrl(fileUrl)) {
84-
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
85-
if (resolution.error) {
86-
return { error: resolution.error }
87-
}
88-
fileUrl = resolution.fileUrl || ''
89-
}
90-
91-
// Generate presigned URL if we have a key but no URL
92-
if (!fileUrl && userFile.key) {
86+
// A stored file always gets a freshly minted presigned URL scoped to the
87+
// requested expiry — an embedded url (internal serve path or a previously
88+
// minted presigned link) may be stale, shorter-lived than required, or
89+
// point at a different object than the verified key.
90+
if (userFile.key) {
9391
const context = resolveTrustedFileContext(userFile.key, userFile.context)
9492
const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false)
9593

@@ -102,7 +100,30 @@ export async function resolveFileInputToUrl(
102100
return { error: { status: 404, message: 'File not found' } }
103101
}
104102

105-
fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60)
103+
const fileUrl = await StorageService.generatePresignedDownloadUrl(
104+
userFile.key,
105+
context,
106+
presignExpirySeconds
107+
)
108+
return { fileUrl }
109+
}
110+
111+
let fileUrl = userFile.url || ''
112+
113+
// Without a key, the schema guarantees the url references an uploaded
114+
// file, so resolve the internal serve path to a presigned URL.
115+
if (fileUrl && isInternalFileUrl(fileUrl)) {
116+
const resolution = await resolveInternalFileUrl(
117+
fileUrl,
118+
userId,
119+
requestId,
120+
logger,
121+
presignExpirySeconds
122+
)
123+
if (resolution.error) {
124+
return { error: resolution.error }
125+
}
126+
fileUrl = resolution.fileUrl || ''
106127
}
107128

108129
return { fileUrl }
@@ -112,7 +133,13 @@ export async function resolveFileInputToUrl(
112133
let fileUrl = filePath
113134

114135
if (isInternalFileUrl(filePath)) {
115-
const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger)
136+
const resolution = await resolveInternalFileUrl(
137+
filePath,
138+
userId,
139+
requestId,
140+
logger,
141+
presignExpirySeconds
142+
)
116143
if (resolution.error) {
117144
return { error: resolution.error }
118145
}
@@ -227,7 +254,8 @@ export async function resolveInternalFileUrl(
227254
filePath: string,
228255
userId: string,
229256
requestId: string,
230-
logger: Logger
257+
logger: Logger,
258+
presignExpirySeconds = 5 * 60
231259
): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> {
232260
if (!isInternalFileUrl(filePath)) {
233261
return { fileUrl: filePath }
@@ -247,7 +275,11 @@ export async function resolveInternalFileUrl(
247275
return { error: { status: 404, message: 'File not found' } }
248276
}
249277

250-
const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
278+
const fileUrl = await StorageService.generatePresignedDownloadUrl(
279+
storageKey,
280+
context,
281+
presignExpirySeconds
282+
)
251283
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
252284
return { fileUrl }
253285
} catch (error) {

apps/sim/tools/buffer/create_post.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@ export const bufferCreatePostTool: ToolConfig<BufferCreatePostParams, BufferPost
6262
required: false,
6363
visibility: 'user-or-llm',
6464
description:
65-
'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL',
65+
'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',
66+
},
67+
mediaType: {
68+
type: 'string',
69+
required: false,
70+
visibility: 'user-or-llm',
71+
description:
72+
'Force the attachment type when it cannot be detected from the file or URL: image or video (default auto)',
6673
},
6774
mediaAltText: {
6875
type: 'string',
@@ -85,6 +92,7 @@ export const bufferCreatePostTool: ToolConfig<BufferCreatePostParams, BufferPost
8592
dueAt: params.dueAt,
8693
saveToDraft: params.saveToDraft,
8794
media: params.media,
95+
mediaType: params.mediaType,
8896
mediaAltText: params.mediaAltText,
8997
}),
9098
},

apps/sim/tools/buffer/edit_post.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@ export const bufferEditPostTool: ToolConfig<BufferEditPostParams, BufferPostResp
6262
required: false,
6363
visibility: 'user-or-llm',
6464
description:
65-
'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Replaces existing attachments',
65+
'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',
66+
},
67+
mediaType: {
68+
type: 'string',
69+
required: false,
70+
visibility: 'user-or-llm',
71+
description:
72+
'Force the attachment type when it cannot be detected from the file or URL: image or video (default auto)',
6673
},
6774
mediaAltText: {
6875
type: 'string',
@@ -85,6 +92,7 @@ export const bufferEditPostTool: ToolConfig<BufferEditPostParams, BufferPostResp
8592
dueAt: params.dueAt,
8693
saveToDraft: params.saveToDraft,
8794
media: params.media,
95+
mediaType: params.mediaType,
8896
mediaAltText: params.mediaAltText,
8997
}),
9098
},

apps/sim/tools/buffer/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ export interface BufferCreatePostParams extends BufferBaseParams {
389389
dueAt?: string
390390
saveToDraft?: boolean
391391
media?: unknown
392+
mediaType?: 'auto' | 'image' | 'video'
392393
mediaAltText?: string
393394
}
394395

@@ -400,6 +401,7 @@ export interface BufferEditPostParams extends BufferBaseParams {
400401
dueAt?: string
401402
saveToDraft?: boolean
402403
media?: unknown
404+
mediaType?: 'auto' | 'image' | 'video'
403405
mediaAltText?: string
404406
}
405407

0 commit comments

Comments
 (0)