feat: add secure remote channel media
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user