import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { fetch as undiciFetch } from 'undici'; import sharp from 'sharp'; import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_WECHAT_MEDIA_URL = 'https://api.weixin.qq.com/cgi-bin/media/get'; const DEFAULT_WECHAT_MEDIA_UPLOAD_URL = 'https://api.weixin.qq.com/cgi-bin/media/upload'; const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_OUTBOUND_IMAGE_BYTES = 2 * 1024 * 1024; const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024; const ALLOWED_IMAGE_MIME_TYPES = new Map([ ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/webp', 'webp'], ['image/gif', 'gif'], ]); const ALLOWED_ATTACHMENT_EXTENSIONS = new Map([ ['.doc', 'application/msword'], ['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], ['.xls', 'application/vnd.ms-excel'], ['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], ]); function resolveImageExtension(contentType = '', fallbackUrl = '') { const normalized = String(contentType ?? '') .split(';')[0] .trim() .toLowerCase(); if (ALLOWED_IMAGE_MIME_TYPES.has(normalized)) { return { mimeType: normalized, extension: ALLOWED_IMAGE_MIME_TYPES.get(normalized), }; } const ext = path.extname(String(fallbackUrl ?? '').split('?')[0]).replace(/^\./, '').toLowerCase(); if (ext === 'jpg' || ext === 'jpeg') return { mimeType: 'image/jpeg', extension: 'jpg' }; if (ext === 'png') return { mimeType: 'image/png', extension: 'png' }; if (ext === 'webp') return { mimeType: 'image/webp', extension: 'webp' }; if (ext === 'gif') return { mimeType: 'image/gif', extension: 'gif' }; return null; } function ensureImageWithinLimit(buffer, maxBytes) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw new Error('微信图片内容为空'); } if (buffer.length > maxBytes) { throw new Error(`图片超过大小限制(${maxBytes} bytes)`); } } function sanitizeAttachmentFilename(filename = '') { const basename = path.basename(String(filename ?? '').trim()).replace(/[\u0000-\u001f\u007f]/g, ''); if (!basename || basename === '.' || basename === '..') { throw new Error('微信文件缺少有效文件名'); } const extension = path.extname(basename).toLowerCase(); const mimeType = ALLOWED_ATTACHMENT_EXTENSIONS.get(extension); if (!mimeType) { throw new Error('当前服务号文件仅支持 Word(doc/docx)和 Excel(xls/xlsx)'); } return { filename: basename.slice(0, 160), extension, mimeType, }; } function ensureAttachmentWithinLimit(buffer, maxBytes) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw new Error('微信文件内容为空'); } if (buffer.length > maxBytes) { throw new Error(`文件超过大小限制(${maxBytes} bytes)`); } } export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch = undiciFetch } = {}) { if (!accessToken) throw new Error('缺少微信 access_token'); if (!mediaId) throw new Error('缺少微信 mediaId'); const url = new URL(DEFAULT_WECHAT_MEDIA_URL); url.searchParams.set('access_token', accessToken); url.searchParams.set('media_id', mediaId); const response = await wechatFetch(url.toString(), { method: 'GET', headers: { Accept: '*/*' }, }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `微信临时素材下载失败 (${response.status})`); } const contentType = response.headers.get('content-type') || ''; const buffer = Buffer.from(await response.arrayBuffer()); return { buffer, contentType }; } async function normalizeWechatOutboundImage(buffer, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES) { ensureImageWithinLimit(buffer, DEFAULT_MAX_IMAGE_BYTES * 2); const attempts = [ { width: 2048, quality: 86 }, { width: 1600, quality: 74 }, { width: 1280, quality: 62 }, ]; for (const attempt of attempts) { const normalized = await sharp(buffer, { sequentialRead: true }) .rotate() .resize({ width: attempt.width, height: attempt.width, fit: 'inside', withoutEnlargement: true, }) .flatten({ background: '#ffffff' }) .jpeg({ quality: attempt.quality, progressive: true, mozjpeg: true }) .toBuffer(); if (normalized.length <= maxBytes) { return { buffer: normalized, contentType: 'image/jpeg', filename: 'memind-generated.jpg' }; } } throw new Error(`生成图片压缩后仍超过微信图片限制(${maxBytes} bytes)`); } export async function uploadWechatGeneratedImage( accessToken, publicUrl, { wechatFetch = undiciFetch, publicBaseUrl = '', allowedPublicBaseUrls = [], uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES, } = {}, ) { if (!accessToken) throw new Error('缺少微信 access_token'); if (!publicUrl) throw new Error('缺少生成图片公网地址'); const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString(); const allowedOrigins = new Set(); for (const baseUrl of [publicBaseUrl, ...allowedPublicBaseUrls]) { if (!baseUrl) continue; try { allowedOrigins.add(new URL(String(baseUrl)).origin); } catch { // Ignore invalid optional bases; at least one valid configured origin is required below. } } if (allowedOrigins.size > 0 && !allowedOrigins.has(new URL(resolvedUrl).origin)) { throw new Error('生成图片地址不属于当前 MindSpace 可信公网域名'); } const sourceResponse = await wechatFetch(resolvedUrl, { method: 'GET', headers: { Accept: 'image/*,*/*;q=0.8' }, }); if (!sourceResponse.ok) { const text = await sourceResponse.text().catch(() => ''); throw new Error(text || `生成图片下载失败 (${sourceResponse.status})`); } const declaredBytes = Number(sourceResponse.headers.get('content-length') ?? 0); if (Number.isFinite(declaredBytes) && declaredBytes > DEFAULT_MAX_IMAGE_BYTES * 2) { throw new Error('生成图片下载体积超过安全限制'); } const sourceBuffer = Buffer.from(await sourceResponse.arrayBuffer()); const normalized = await normalizeWechatOutboundImage(sourceBuffer, maxBytes); const form = new FormData(); form.append( 'media', new Blob([normalized.buffer], { type: normalized.contentType }), normalized.filename, ); const endpoint = new URL(uploadUrl); endpoint.searchParams.set('access_token', accessToken); endpoint.searchParams.set('type', 'image'); const response = await wechatFetch(endpoint.toString(), { method: 'POST', body: form, }); const text = await response.text().catch(() => ''); let payload = {}; try { payload = text ? JSON.parse(text) : {}; } catch { payload = {}; } if (!response.ok || Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) { const detail = String(payload?.errmsg ?? text ?? '').trim() || `HTTP ${response.status}`; throw new Error(`微信生成图片素材上传失败:${detail}`); } return { mediaId: String(payload.media_id), bytes: normalized.buffer.length, contentType: normalized.contentType, }; } export function buildWechatImagePublicUrl({ publicBaseUrl, publishKey, filename, publicBasePath = `${PUBLIC_ZONE_DIR}/wechat-mp`, }) { if (!publicBaseUrl) throw new Error('缺少图片公网基础地址'); if (!publishKey) throw new Error('缺少用户 publish key'); if (!filename) throw new Error('缺少图片文件名'); return buildPublicUrl(publicBaseUrl, publishKey, `${publicBasePath}/${filename}`); } export async function persistWechatImage( { userId, appId, openid, msgId, mediaId, picUrl, publicBaseUrl, maxImageBytes = DEFAULT_MAX_IMAGE_BYTES, }, { wechatFetch = undiciFetch, accessToken, h5Root = __dirname, } = {}, ) { if (!userId) throw new Error('缺少 userId'); const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, 'wechat-mp'); fs.mkdirSync(publishDir, { recursive: true }); let source = 'wechat_media'; let buffer; let contentType = ''; if (mediaId && accessToken) { try { const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch }); buffer = downloaded.buffer; contentType = downloaded.contentType; } catch (error) { if (!picUrl) throw error; } } if (!buffer && picUrl) { source = 'wechat_pic_url'; const response = await wechatFetch(picUrl, { method: 'GET', headers: { Accept: 'image/*,*/*;q=0.8' }, }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `微信图片下载失败 (${response.status})`); } contentType = response.headers.get('content-type') || contentType; buffer = Buffer.from(await response.arrayBuffer()); } ensureImageWithinLimit(buffer, maxImageBytes); const resolved = resolveImageExtension(contentType, picUrl); if (!resolved) { throw new Error(`暂不支持的图片类型:${contentType || 'unknown'}`); } const timestamp = Date.now(); const hash = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 12); const fileBase = [appId || 'wx', openid || 'openid', msgId || timestamp, mediaId || hash] .filter(Boolean) .join('-') .replace(/[^a-zA-Z0-9._-]+/g, '_') .slice(0, 120); const filename = `${fileBase}.${resolved.extension}`; const absolutePath = path.join(publishDir, filename); fs.writeFileSync(absolutePath, buffer); return { absolutePath, bytes: buffer.length, contentType: resolved.mimeType, filename, publicUrl: buildWechatImagePublicUrl({ publicBaseUrl, publishKey: String(userId), filename, }), source, }; } export async function persistWechatAttachment( { userId, appId, openid, msgId, mediaId, filename, publicBaseUrl, maxFileBytes = DEFAULT_MAX_ATTACHMENT_BYTES, }, { wechatFetch = undiciFetch, accessToken, h5Root = __dirname, } = {}, ) { if (!userId) throw new Error('缺少 userId'); const resolved = sanitizeAttachmentFilename(filename); const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch }); ensureAttachmentWithinLimit(downloaded.buffer, maxFileBytes); const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, 'wechat-mp'); fs.mkdirSync(publishDir, { recursive: true }); const timestamp = Date.now(); const hash = crypto.createHash('sha1').update(downloaded.buffer).digest('hex').slice(0, 12); const originalStem = path.basename(resolved.filename, resolved.extension) .replace(/[^\p{L}\p{N}._-]+/gu, '_') .replace(/^_+|_+$/g, '') .slice(0, 80) || 'attachment'; const identity = [appId || 'wx', openid || 'openid', msgId || timestamp, mediaId || hash] .filter(Boolean) .join('-') .replace(/[^a-zA-Z0-9._-]+/g, '_') .slice(0, 100); const publicFilename = `${originalStem}-${identity}-${hash}${resolved.extension}`; const absolutePath = path.join(publishDir, publicFilename); fs.writeFileSync(absolutePath, downloaded.buffer); return { absolutePath, bytes: downloaded.buffer.length, contentType: resolved.mimeType, filename: resolved.filename, publicFilename, publicUrl: buildWechatImagePublicUrl({ publicBaseUrl, publishKey: String(userId), filename: publicFilename, }), source: 'wechat_media', }; }