Sfoglia il codice sorgente

fix: preview team background text files

Developer 3 giorni fa
parent
commit
e628a86449
2 ha cambiato i file con 230 aggiunte e 14 eliminazioni
  1. 106 8
      http/teamBackgroundFile.js
  2. 124 6
      tests/teamBackgroundFile.test.js

+ 106 - 8
http/teamBackgroundFile.js

@@ -1,6 +1,10 @@
 import { BaseApi } from './baseApi.js'
 import api from './index.js'
 
+const OPEN_DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
+const TEXT_PREVIEW_EXTENSIONS = ['txt', 'md', 'csv']
+const MAX_TEXT_PREVIEW_SIZE = 256 * 1024
+
 function parseResult(response) {
 	let result = response && response.data
 	if (typeof result === 'string') {
@@ -33,7 +37,7 @@ function getHeader(headers, name) {
 	return ''
 }
 
-function isDocumentDownload(headers) {
+function isSafeAttachment(headers) {
 	const contentType = getHeader(headers, 'content-type').split(';')[0].trim().toLowerCase()
 	const disposition = getHeader(headers, 'content-disposition').trim()
 	if (!contentType || !/^attachment(?:\s*;|$)/i.test(disposition)) return false
@@ -43,6 +47,89 @@ function isDocumentDownload(headers) {
 	return true
 }
 
+function getDownloadFileName(headers) {
+	const disposition = getHeader(headers, 'content-disposition')
+	const encodedMatch = disposition.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]*)/i)
+	if (encodedMatch) {
+		try {
+			const decoded = decodeURIComponent(encodedMatch[1].trim())
+			if (decoded) return decoded
+		} catch (error) {
+			// Fall back to the compatible ASCII filename below.
+		}
+	}
+	const fallbackMatch = disposition.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i)
+	return fallbackMatch ? String(fallbackMatch[1] || fallbackMatch[2] || '').trim() : ''
+}
+
+function getFileExtension(fileName) {
+	const extensionIndex = fileName.lastIndexOf('.')
+	if (extensionIndex < 0 || extensionIndex === fileName.length - 1) return ''
+	return fileName.slice(extensionIndex + 1).toLowerCase()
+}
+
+function readTextPreview(filePath, fileName) {
+	return new Promise((resolve, reject) => {
+		const fail = () => reject(new Error('文件读取失败,请重试'))
+		let fileSystem
+		try {
+			fileSystem = typeof wx !== 'undefined' && typeof wx.getFileSystemManager === 'function'
+				? wx.getFileSystemManager()
+				: null
+		} catch (error) {
+			fail()
+			return
+		}
+		if (!fileSystem || typeof fileSystem.getFileInfo !== 'function'
+			|| typeof fileSystem.readFile !== 'function') {
+			fail()
+			return
+		}
+		try {
+			fileSystem.getFileInfo({
+				filePath,
+				success: info => {
+					const size = Number(info && info.size)
+					if (!Number.isFinite(size) || size < 0) {
+						fail()
+						return
+					}
+					if (size === 0) {
+						resolve({ mode: 'text', fileName, content: '', truncated: false })
+						return
+					}
+					try {
+						fileSystem.readFile({
+							filePath,
+							encoding: 'utf8',
+							position: 0,
+							length: Math.min(size, MAX_TEXT_PREVIEW_SIZE),
+							success: result => {
+								if (!result || typeof result.data !== 'string') {
+									fail()
+									return
+								}
+								resolve({
+									mode: 'text',
+									fileName,
+									content: result.data,
+									truncated: size > MAX_TEXT_PREVIEW_SIZE
+								})
+							},
+							fail
+						})
+					} catch (error) {
+						fail()
+					}
+				},
+				fail
+			})
+		} catch (error) {
+			fail()
+		}
+	})
+}
+
 export function uploadTeamBackgroundFile(teamId, filePath, onProgress = () => {}) {
 	return new Promise((resolve, reject) => {
 		const task = uni.uploadFile({
@@ -83,17 +170,28 @@ export function downloadTeamBackgroundFile(teamId, fileId) {
 			header: { token: uni.getStorageSync('token') },
 			success: result => {
 				const resultHeaders = result && (result.header || result.headers)
+				const headers = responseHeaders || resultHeaders
 				if (!result || result.statusCode !== 200
-					|| !isDocumentDownload(responseHeaders || resultHeaders)) {
+					|| !isSafeAttachment(headers)) {
 					reject(new Error('下载失败,请重试'))
 					return
 				}
-				wx.openDocument({
-					filePath: result.tempFilePath,
-					showMenu: true,
-					success: resolve,
-					fail: reject
-				})
+				const fileName = getDownloadFileName(headers)
+				const extension = getFileExtension(fileName)
+				if (OPEN_DOCUMENT_EXTENSIONS.includes(extension)) {
+					wx.openDocument({
+						filePath: result.tempFilePath,
+						showMenu: true,
+						success: resolve,
+						fail: reject
+					})
+					return
+				}
+				if (TEXT_PREVIEW_EXTENSIONS.includes(extension)) {
+					readTextPreview(result.tempFilePath, fileName).then(resolve, reject)
+					return
+				}
+				reject(new Error('文件类型不支持预览'))
 			},
 			fail: reject
 		})

+ 124 - 6
tests/teamBackgroundFile.test.js

@@ -153,10 +153,18 @@ async function testApiTransport() {
 	assert.equal(transport.downloadTeamBackgroundFile.length, 2)
 }
 
+const TEXT_PREVIEW_LIMIT = 256 * 1024
+
 async function createDownloadHarness({ result, responseHeaders, downloadError, openError,
-	withHeadersListener = true } = {}) {
+	fileSize, fileContent = '', fileInfoError, readError, withHeadersListener = true } = {}) {
 	let request
 	let openRequest
+	let fileInfoRequest
+	let readRequest
+	let fileSystemManagerCalls = 0
+	const resolvedFileSize = fileSize === undefined
+		? Buffer.byteLength(fileContent, 'utf8')
+		: fileSize
 	const uni = {
 		getStorageSync(key) {
 			assert.equal(key, 'token')
@@ -183,6 +191,25 @@ async function createDownloadHarness({ result, responseHeaders, downloadError, o
 				if (openError) options.fail(openError)
 				else options.success('opened')
 			})
+		},
+		getFileSystemManager() {
+			fileSystemManagerCalls += 1
+			return {
+				getFileInfo(options) {
+					fileInfoRequest = options
+					setImmediate(() => {
+						if (fileInfoError) options.fail(fileInfoError)
+						else options.success({ size: resolvedFileSize })
+					})
+				},
+				readFile(options) {
+					readRequest = options
+					setImmediate(() => {
+						if (readError) options.fail(readError)
+						else options.success({ data: fileContent })
+					})
+				}
+			}
 		}
 	}
 	const transport = await loadTransport({ uni, wx })
@@ -190,6 +217,9 @@ async function createDownloadHarness({ result, responseHeaders, downloadError, o
 	return {
 		request: () => request,
 		openRequest: () => openRequest,
+		fileInfoRequest: () => fileInfoRequest,
+		readRequest: () => readRequest,
+		fileSystemManagerCalls: () => fileSystemManagerCalls,
 		promise: transport.downloadTeamBackgroundFile(12, 34)
 	}
 }
@@ -221,19 +251,104 @@ async function testDownloadTransport() {
 		withHeadersListener: false
 	})
 	assert.equal(await resultHeaderHarness.promise, 'opened')
+	assert.equal(resultHeaderHarness.fileSystemManagerCalls(), 0)
 
-	const resultHeadersHarness = await createDownloadHarness({
+	const txtHarness = await createDownloadHarness({
 		result: {
 			statusCode: 200,
-			tempFilePath: '/tmp/result-headers.txt',
+			tempFilePath: '/tmp/text-preview.txt',
 			headers: {
 				'Content-Type': 'text/plain; charset=UTF-8',
-				'Content-Disposition': 'attachment; filename="result-headers.txt"'
+				'Content-Disposition': 'attachment; filename="fallback.txt"; '
+					+ "filename*=UTF-8''%E8%83%8C%E6%99%AF%20%E8%B5%84%E6%96%99.txt"
 			}
 		},
+		fileSize: 5,
+		fileContent: 'hello',
 		withHeadersListener: false
 	})
-	assert.equal(await resultHeadersHarness.promise, 'opened')
+	const txtPreview = await txtHarness.promise
+	assert.equal(txtHarness.openRequest(), undefined, 'txt must not call openDocument')
+	assert.equal(txtPreview.mode, 'text')
+	assert.equal(txtPreview.fileName, '背景 资料.txt')
+	assert.equal(txtPreview.content, 'hello')
+	assert.equal(txtPreview.truncated, false)
+	assert.equal(txtHarness.fileInfoRequest().filePath, '/tmp/text-preview.txt')
+	assert.equal(txtHarness.readRequest().filePath, '/tmp/text-preview.txt')
+	assert.equal(txtHarness.readRequest().encoding, 'utf8')
+	assert.equal(txtHarness.readRequest().position, 0)
+	assert.equal(txtHarness.readRequest().length, 5)
+
+	const mdContent = 'm'.repeat(TEXT_PREVIEW_LIMIT)
+	const mdHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/guide.md' },
+		responseHeaders: {
+			'Content-Type': 'text/markdown; charset=UTF-8',
+			'Content-Disposition': 'attachment; filename="guide.md"'
+		},
+		fileSize: 50 * 1024 * 1024,
+		fileContent: mdContent
+	})
+	const mdPreview = await mdHarness.promise
+	assert.equal(mdHarness.openRequest(), undefined)
+	assert.equal(mdPreview.mode, 'text')
+	assert.equal(mdPreview.fileName, 'guide.md')
+	assert.equal(mdPreview.content.length, TEXT_PREVIEW_LIMIT)
+	assert.equal(mdPreview.truncated, true)
+	assert.equal(mdHarness.readRequest().length, TEXT_PREVIEW_LIMIT)
+
+	const csvContent = 'c'.repeat(TEXT_PREVIEW_LIMIT)
+	const csvHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/data.csv' },
+		responseHeaders: {
+			'Content-Type': 'text/csv',
+			'Content-Disposition': 'attachment; filename=data.csv'
+		},
+		fileSize: TEXT_PREVIEW_LIMIT,
+		fileContent: csvContent
+	})
+	const csvPreview = await csvHarness.promise
+	assert.equal(csvHarness.openRequest(), undefined)
+	assert.equal(csvPreview.mode, 'text')
+	assert.equal(csvPreview.fileName, 'data.csv')
+	assert.equal(csvPreview.content.length, TEXT_PREVIEW_LIMIT)
+	assert.equal(csvPreview.truncated, false)
+	assert.equal(csvHarness.readRequest().length, TEXT_PREVIEW_LIMIT)
+
+	const fileInfoFailureHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/file-info-failure.txt' },
+		responseHeaders: {
+			'Content-Type': 'text/plain',
+			'Content-Disposition': 'attachment; filename="file-info-failure.txt"'
+		},
+		fileInfoError: new Error('getFileInfo failed')
+	})
+	await assert.rejects(fileInfoFailureHarness.promise, /文件读取失败,请重试/)
+	assert.equal(fileInfoFailureHarness.openRequest(), undefined)
+	assert.equal(fileInfoFailureHarness.readRequest(), undefined)
+
+	const readFailureHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/read-failure.md' },
+		responseHeaders: {
+			'Content-Type': 'text/markdown',
+			'Content-Disposition': 'attachment; filename="read-failure.md"'
+		},
+		fileSize: 10,
+		readError: new Error('readFile failed')
+	})
+	await assert.rejects(readFailureHarness.promise, /文件读取失败,请重试/)
+	assert.equal(readFailureHarness.openRequest(), undefined)
+
+	const unknownExtensionHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/unknown-file' },
+		responseHeaders: {
+			'Content-Type': 'application/octet-stream',
+			'Content-Disposition': 'attachment; filename="archive.zip"'
+		}
+	})
+	await assert.rejects(unknownExtensionHarness.promise, /文件类型不支持预览/)
+	assert.equal(unknownExtensionHarness.openRequest(), undefined)
+	assert.equal(unknownExtensionHarness.fileSystemManagerCalls(), 0)
 
 	const jsonHarness = await createDownloadHarness({
 		result: { statusCode: 200, tempFilePath: '/tmp/error.json' },
@@ -247,7 +362,10 @@ async function testDownloadTransport() {
 
 	const htmlHarness = await createDownloadHarness({
 		result: { statusCode: 200, tempFilePath: '/tmp/error.html' },
-		responseHeaders: { 'content-type': 'text/html; charset=UTF-8' }
+		responseHeaders: {
+			'content-type': 'text/html; charset=UTF-8',
+			'content-disposition': 'attachment; filename="error.html"'
+		}
 	})
 	await assert.rejects(htmlHarness.promise, /下载失败,请重试/)
 	assert.equal(htmlHarness.openRequest(), undefined)