feat: add secure remote channel media

This commit is contained in:
lofyer
2026-08-09 20:33:35 +08:00
parent 6c891f3522
commit 66b098ae36
46 changed files with 4403 additions and 1594 deletions
+105 -4
View File
@@ -7,7 +7,11 @@ export const CHANNEL_LIMITS = {
maximumTextLength: 32_000,
maximumResultLength: 16_000,
maximumErrorLength: 1_000,
maximumStatusLength: 64
maximumStatusLength: 64,
maximumAttachmentCount: 4,
maximumAttachmentBytes: 12 * 1024 * 1024,
maximumAttachmentNameLength: 240,
maximumAttachmentMimeTypeLength: 128
} as const
const channelIdentifierSchema = z
@@ -19,6 +23,81 @@ const channelIdentifierSchema = z
export const channelWorkModeSchema = z.enum(['ask', 'plan'])
export type ChannelWorkMode = z.infer<typeof channelWorkModeSchema>
const attachmentBase64Schema = z
.string()
.max(
Math.ceil(CHANNEL_LIMITS.maximumAttachmentBytes / 3) * 4 + 4
)
.regex(/^(?:[a-z0-9+/]{4})*(?:[a-z0-9+/]{2}==|[a-z0-9+/]{3}=)?$/iu)
export function decodedBase64Size(value: string): number {
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0
return (value.length / 4) * 3 - padding
}
export const channelMediaAttachmentSchema = z
.object({
name: z
.string()
.trim()
.min(1)
.max(CHANNEL_LIMITS.maximumAttachmentNameLength),
mimeType: z
.string()
.trim()
.min(1)
.max(CHANNEL_LIMITS.maximumAttachmentMimeTypeLength)
.regex(/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu),
size: z
.number()
.int()
.positive()
.max(CHANNEL_LIMITS.maximumAttachmentBytes),
kind: z.enum(['image', 'file']),
dataBase64: attachmentBase64Schema
})
.strict()
.superRefine((attachment, context) => {
const decodedSize = decodedBase64Size(attachment.dataBase64)
if (decodedSize !== attachment.size) {
context.addIssue({
code: 'custom',
path: ['dataBase64'],
message: '附件大小与内容不匹配'
})
}
if (
attachment.kind === 'image' &&
!attachment.mimeType.startsWith('image/')
) {
context.addIssue({
code: 'custom',
path: ['mimeType'],
message: '图片附件类型无效'
})
}
})
export type ChannelMediaAttachment = z.infer<
typeof channelMediaAttachmentSchema
>
export const channelAttachmentsSchema = z
.array(channelMediaAttachmentSchema)
.max(CHANNEL_LIMITS.maximumAttachmentCount)
.superRefine((attachments, context) => {
const total = attachments.reduce(
(sum, attachment) => sum + attachment.size,
0
)
if (total > CHANNEL_LIMITS.maximumAttachmentBytes) {
context.addIssue({
code: 'custom',
message: '附件总大小超过限制'
})
}
})
export const channelInboundTextSchema = z
.object({
channel: z
@@ -35,15 +114,35 @@ export const channelInboundTextSchema = z
conversationId: channelIdentifierSchema,
conversationType: z.enum(['direct', 'group']),
text: z
.string()
.trim()
.max(CHANNEL_LIMITS.maximumTextLength)
.default(''),
attachments: channelAttachmentsSchema.optional(),
attachmentError: z
.string()
.trim()
.min(1)
.max(CHANNEL_LIMITS.maximumTextLength),
.max(CHANNEL_LIMITS.maximumErrorLength)
.optional(),
mentioned: z.boolean().default(false),
workMode: channelWorkModeSchema.default('ask'),
receivedAt: z.number().int().nonnegative().optional()
})
.strict()
.superRefine((message, context) => {
if (
message.text.length === 0 &&
!message.attachments?.length &&
!message.attachmentError
) {
context.addIssue({
code: 'custom',
path: ['text'],
message: '消息内容不能为空'
})
}
})
export type ChannelInboundText = z.infer<
typeof channelInboundTextSchema
@@ -57,7 +156,8 @@ export const channelExecutorResultSchema = z
.min(1)
.max(CHANNEL_LIMITS.maximumStatusLength),
output: z.string().optional(),
error: z.string().optional()
error: z.string().optional(),
attachments: channelAttachmentsSchema.optional()
})
.strict()
@@ -91,7 +191,8 @@ export const channelResultMessageSchema = z
error: z
.string()
.max(CHANNEL_LIMITS.maximumErrorLength)
.optional()
.optional(),
attachments: channelAttachmentsSchema.optional()
})
.strict()