diff --git a/apps/docs/content/docs/en/integrations/buffer.mdx b/apps/docs/content/docs/en/integrations/buffer.mdx index 6cdb61a38e5..7686fecbbf0 100644 --- a/apps/docs/content/docs/en/integrations/buffer.mdx +++ b/apps/docs/content/docs/en/integrations/buffer.mdx @@ -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 diff --git a/apps/sim/app/api/tools/buffer/create-post/route.ts b/apps/sim/app/api/tools/buffer/create-post/route.ts index ecdf6fcf066..ef0ab658659 100644 --- a/apps/sim/app/api/tools/buffer/create-post/route.ts +++ b/apps/sim/app/api/tools/buffer/create-post/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/buffer/edit-post/route.ts b/apps/sim/app/api/tools/buffer/edit-post/route.ts index d8f2c675170..30fddcd6b22 100644 --- a/apps/sim/app/api/tools/buffer/edit-post/route.ts +++ b/apps/sim/app/api/tools/buffer/edit-post/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/buffer/server-utils.ts b/apps/sim/app/api/tools/buffer/server-utils.ts index 88e03314ad3..fee163e1568 100644 --- a/apps/sim/app/api/tools/buffer/server-utils.ts +++ b/apps/sim/app/api/tools/buffer/server-utils.ts @@ -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 { - 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 { - const { apiKey, postId, channelId, media, mediaAltText, userId, requestId, logger } = options + const { apiKey, postId, channelId, media, mediaType, mediaAltText, userId, requestId, logger } = + options const input: Record = { mode: options.mode, @@ -243,6 +274,7 @@ export async function forwardPostMutation( if (media) { const { asset, errorResponse } = await resolveMediaAsset({ media, + mediaType, mediaAltText, userId, requestId, diff --git a/apps/sim/blocks/blocks/buffer.ts b/apps/sim/blocks/blocks/buffer.ts index 0f04c953b08..4b329a49e45 100644 --- a/apps/sim/blocks/blocks/buffer.ts +++ b/apps/sim/blocks/blocks/buffer.ts @@ -140,6 +140,19 @@ export const BufferBlock: BlockConfig = { 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 = { 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' }, diff --git a/apps/sim/lib/api/contracts/tools/buffer.ts b/apps/sim/lib/api/contracts/tools/buffer.ts index 0cbd891c57d..948b9d708d7 100644 --- a/apps/sim/lib/api/contracts/tools/buffer.ts +++ b/apps/sim/lib/api/contracts/tools/buffer.ts @@ -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(), } diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 1077512cc97..31ce9b6c0b7 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -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 { - 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) { diff --git a/apps/sim/tools/buffer/create_post.ts b/apps/sim/tools/buffer/create_post.ts index b94601b9c61..5d56f916008 100644 --- a/apps/sim/tools/buffer/create_post.ts +++ b/apps/sim/tools/buffer/create_post.ts @@ -62,7 +62,14 @@ export const bufferCreatePostTool: ToolConfig