mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 09:40:20 -05:00
Preview ZIP contents from cards and Files Report
This commit is contained in:
1 parent
f38e152e80
commit
2016d15f2a
10 files changed
+184
-43
No files matched your search
@@ -322,6 +322,21 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#zip-viewer {
|
||||
background: white;
|
||||
color: #222;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
text-align: start;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#zip-viewer li {
|
||||
padding: 0.3em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.pdf-preview-error {
|
||||
margin-top: 180px;
|
||||
display: block;
|
||||
@@ -361,6 +376,7 @@
|
||||
100vh - 155px
|
||||
); /* Full height - height of top and bottom bars */
|
||||
}
|
||||
#zip-viewer,
|
||||
#txt-viewer,
|
||||
#office-viewer {
|
||||
width: 100%;
|
||||
|
||||
@@ -48,6 +48,7 @@ template(name="attachmentViewer")
|
||||
object#pdf-viewer.hidden(type="application/pdf")
|
||||
span.pdf-preview-error {{_ 'preview-pdf-not-supported' }}
|
||||
object#txt-viewer.hidden(type="text/plain")
|
||||
#zip-viewer.hidden(aria-live="polite")
|
||||
#office-viewer.hidden
|
||||
button.fa.fa-caret-right#next-attachment(type="button" aria-label="{{_ 'next'}}")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { attachmentKind } from '/models/lib/attachmentKind';
|
||||
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';
|
||||
import { openZipAttachment } from '/client/lib/zipAttachmentViewer';
|
||||
import { openOfficeAttachment } from '/client/lib/officeAttachmentViewer';
|
||||
import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
|
||||
@@ -32,6 +33,7 @@ let slideshowAttachmentIds = null;
|
||||
let touchStartCoords = null;
|
||||
let touchEndCoords = null;
|
||||
|
||||
let zipPreviewAbortController = null;
|
||||
let officePreview = null;
|
||||
let officePreviewAbortController = null;
|
||||
let officePreviewGeneration = 0;
|
||||
@@ -110,6 +112,7 @@ function attachmentCanBeOpened(attachment) {
|
||||
kind.isText ||
|
||||
kind.isJSON ||
|
||||
kind.isOffice ||
|
||||
kind.isZIP ||
|
||||
kind.isVideo ||
|
||||
kind.isAudio
|
||||
);
|
||||
@@ -166,6 +169,21 @@ function openAttachmentViewer(attachmentId) {
|
||||
$("#txt-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#txt-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (kind.isZIP): {
|
||||
const container = document.getElementById('zip-viewer');
|
||||
zipPreviewAbortController = new AbortController();
|
||||
const { signal } = zipPreviewAbortController;
|
||||
container.classList.remove('hidden');
|
||||
container.textContent = TAPi18n.__('loading');
|
||||
openZipAttachment({ container, signal, size: attachment.size,
|
||||
url: getAttachmentUrl(attachment), emptyMessage: TAPi18n.__('no-results') })
|
||||
.catch(error => {
|
||||
if (signal.aborted) return;
|
||||
container.textContent = TAPi18n.__('error');
|
||||
console.error('Could not preview ZIP attachment:', error);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case (kind.isOffice): {
|
||||
const generation = ++officePreviewGeneration;
|
||||
const container = document.getElementById('office-viewer');
|
||||
@@ -216,6 +234,11 @@ export function openAttachmentSlideshow(attachmentId, attachmentIds = []) {
|
||||
}
|
||||
|
||||
function closeAttachmentViewer(restoreFocus = true) {
|
||||
zipPreviewAbortController?.abort();
|
||||
zipPreviewAbortController = null;
|
||||
const zipViewer = document.getElementById('zip-viewer');
|
||||
zipViewer?.replaceChildren();
|
||||
zipViewer?.classList.add('hidden');
|
||||
officePreviewGeneration++;
|
||||
if (officePreviewAbortController) {
|
||||
officePreviewAbortController.abort();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export const MAX_SOURCE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
function assertSourceSize(size) {
|
||||
if (Number.isFinite(size) && size > MAX_SOURCE_BYTES) {
|
||||
throw new RangeError('Attachment exceeds the preview size limit');
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponse(response) {
|
||||
if (!response.body || typeof response.body.getReader !== 'function') {
|
||||
throw new Error('Streaming attachment downloads are required for previews');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
assertSourceSize(total);
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export { assertSourceSize, readBoundedResponse };
|
||||
@@ -1,4 +1,4 @@
|
||||
const MAX_SOURCE_BYTES = 32 * 1024 * 1024;
|
||||
import { MAX_SOURCE_BYTES, assertSourceSize, readBoundedResponse } from './attachmentPreviewSource.js';
|
||||
|
||||
const VIEWER_OPTIONS = Object.freeze({
|
||||
mode: 'worker',
|
||||
@@ -16,42 +16,6 @@ const VIEWER_OPTIONS = Object.freeze({
|
||||
}),
|
||||
});
|
||||
|
||||
function assertSourceSize(size) {
|
||||
if (Number.isFinite(size) && size > MAX_SOURCE_BYTES) {
|
||||
throw new RangeError('Office attachment exceeds the preview size limit');
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponse(response) {
|
||||
if (!response.body || typeof response.body.getReader !== 'function') {
|
||||
throw new Error('Streaming attachment downloads are required for Office previews');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
assertSourceSize(total);
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function viewerFor(extension, container) {
|
||||
switch (extension) {
|
||||
case 'docx': {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { unzipSync } from 'fflate';
|
||||
import { assertSourceSize, readBoundedResponse } from './attachmentPreviewSource.js';
|
||||
|
||||
export const MAX_ZIP_ENTRIES = 2048;
|
||||
|
||||
export function listZipEntries(bytes) {
|
||||
assertSourceSize(bytes.byteLength);
|
||||
if (bytes.byteLength < 22) throw new Error('Invalid ZIP archive');
|
||||
const entries = [];
|
||||
// fflate's filter reads the central directory. Returning false for EVERY
|
||||
// entry means no payload is inflated, including encrypted or huge entries.
|
||||
unzipSync(bytes, { filter(entry) {
|
||||
if (entries.length >= MAX_ZIP_ENTRIES) throw new RangeError('Too many ZIP entries');
|
||||
entries.push(entry.name);
|
||||
return false;
|
||||
} });
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function openZipAttachment({ container, signal, size, url, emptyMessage }) {
|
||||
assertSourceSize(size);
|
||||
const response = await fetch(url, { credentials: 'same-origin', signal });
|
||||
if (!response.ok) throw new Error(`ZIP attachment fetch failed: ${response.status}`);
|
||||
assertSourceSize(Number(response.headers.get('content-length')));
|
||||
const entries = listZipEntries(new Uint8Array(await readBoundedResponse(response)));
|
||||
if (signal.aborted) return;
|
||||
const list = container.ownerDocument.createElement('ul');
|
||||
for (const name of entries) {
|
||||
const item = container.ownerDocument.createElement('li');
|
||||
// Archive paths are inert text: never HTML, links or extraction targets.
|
||||
item.textContent = name;
|
||||
list.appendChild(item);
|
||||
}
|
||||
container.replaceChildren();
|
||||
if (entries.length) container.appendChild(list);
|
||||
else container.textContent = emptyMessage;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const EXTENSION_MIME = {
|
||||
m4v: 'video/x-m4v', mkv: 'video/x-matroska', avi: 'video/x-msvideo',
|
||||
mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav',
|
||||
flac: 'audio/flac', m4a: 'audio/mp4', aac: 'audio/aac', opus: 'audio/opus',
|
||||
zip: 'application/zip',
|
||||
pdf: 'application/pdf', json: 'application/json',
|
||||
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', log: 'text/plain',
|
||||
ini: 'text/plain', conf: 'text/plain', yml: 'text/yaml', yaml: 'text/yaml',
|
||||
@@ -108,6 +109,8 @@ function attachmentKind(doc) {
|
||||
isJSON: flag(doc && doc.isJSON, type === 'application/json' || (!stated && extension === 'json')),
|
||||
isText: flag(doc && doc.isText,
|
||||
type.startsWith('text/') || byName(TEXT_EXTENSIONS)),
|
||||
isZIP: ['application/zip', 'application/x-zip-compressed'].includes(type) ||
|
||||
(extension === 'zip' && (!stated || type === 'application/octet-stream')),
|
||||
isOffice: OFFICE_EXTENSIONS.includes(extension) &&
|
||||
(!stated || type.startsWith('application/vnd.openxmlformats-officedocument.')),
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ let attachmentKindFix;
|
||||
assert.deepStrictEqual(attachmentKind(uploaded), {
|
||||
extension: 'jpg', type: 'image/jpeg',
|
||||
isImage: true, isVideo: false, isAudio: false,
|
||||
isPDF: false, isJSON: false, isText: false, isOffice: false,
|
||||
isPDF: false, isJSON: false, isText: false, isOffice: false, isZIP: false,
|
||||
});
|
||||
// ...and nothing to repair.
|
||||
assert.strictEqual(attachmentKindFix(uploaded), null);
|
||||
@@ -73,7 +73,7 @@ let attachmentKindFix;
|
||||
['a.mp4', 'isVideo'], ['a.webm', 'isVideo'], ['a.mov', 'isVideo'],
|
||||
['a.mp3', 'isAudio'], ['a.ogg', 'isAudio'], ['a.flac', 'isAudio'],
|
||||
['a.pdf', 'isPDF'], ['a.json', 'isJSON'], ['a.txt', 'isText'],
|
||||
['a.docx', 'isOffice'], ['a.xlsx', 'isOffice'], ['a.pptx', 'isOffice'],
|
||||
['a.zip', 'isZIP'], ['a.docx', 'isOffice'], ['a.xlsx', 'isOffice'], ['a.pptx', 'isOffice'],
|
||||
];
|
||||
for (const [name, expected] of cases) {
|
||||
const kind = attachmentKind({ name });
|
||||
|
||||
@@ -6,8 +6,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { zipSync, strToU8 } = require('fflate');
|
||||
|
||||
for (const extension of ['docx', 'png', 'pdf']) {
|
||||
test(`Files Report previews a private ${extension} file for a non-member administrator only`, async ({ page, request, adminUser, user2, board }) => {
|
||||
for (const extension of ['docx', 'png', 'pdf', 'zip']) {
|
||||
test(`Files Report previews a private ${extension} file for a non-member administrator only`, async ({ page, request, adminUser, user, user2, board }) => {
|
||||
const previewErrors = [];
|
||||
page.on('console', message => { if (message.type() === 'error' && message.text().includes('preview Office')) previewErrors.push(message.text()); });
|
||||
const id = db.uid('officepreview');
|
||||
@@ -16,7 +16,10 @@ test(`Files Report previews a private ${extension} file for a non-member adminis
|
||||
'_rels/.rels': '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
|
||||
'word/document.xml': '<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Private report preview</w:t></w:r></w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>',
|
||||
}).map(([name, text]) => [name, strToU8(text)]))));
|
||||
if (extension === 'png') {
|
||||
const zipNames = ['folder/hello.txt', '日本語.txt', '<img onerror=alert(1)>.txt'];
|
||||
if (extension === 'zip') {
|
||||
bytes = Buffer.from(zipSync(Object.fromEntries(zipNames.map(name => [name, strToU8('hello')]))));
|
||||
} else if (extension === 'png') {
|
||||
bytes = fs.readFileSync(path.resolve(__dirname, '../../../public/favicon-32x32.png'));
|
||||
} else if (extension === 'pdf') {
|
||||
const PDFDocument = require('pdfkit');
|
||||
@@ -36,7 +39,7 @@ test(`Files Report previews a private ${extension} file for a non-member adminis
|
||||
try {
|
||||
await loginWithToken(page, adminUser.id, adminUser.token);
|
||||
const storage = await page.evaluate(() => Meteor.callAsync('getAttachmentStoragePaths'));
|
||||
const type = { docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', png: 'image/png', pdf: 'application/pdf' }[extension];
|
||||
const type = { zip: 'application/zip', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', png: 'image/png', pdf: 'application/pdf' }[extension];
|
||||
db.insertOne('attachments', { _id: id, name: `${id}.${extension}`, extension, type, size: bytes.length,
|
||||
meta: { boardId: board.boardId, cardId: db.findOne('cards', { boardId: board.boardId })._id },
|
||||
versions: { original: { storage: 'fs', path: `${storage.attachments}/${id}.${extension}`, size: bytes.length, extension, type } } });
|
||||
@@ -60,6 +63,9 @@ test(`Files Report previews a private ${extension} file for a non-member adminis
|
||||
await expect.poll(() => page.locator('#image-viewer').evaluate(image => image.naturalWidth)).toBeGreaterThan(0);
|
||||
const thumbnail = page.locator(`.table-page-attachment-preview[data-attachment-id="${id}"] img`);
|
||||
await expect.poll(() => thumbnail.evaluate(image => image.naturalWidth)).toBeGreaterThan(0);
|
||||
} else if (extension === 'zip') {
|
||||
await expect(page.locator('#zip-viewer li')).toHaveText(zipNames);
|
||||
await expect(page.locator('#zip-viewer img')).toHaveCount(0);
|
||||
} else {
|
||||
expect(download.headers()['content-type']).toContain('application/pdf');
|
||||
await expect(page.locator('#pdf-viewer')).toBeVisible();
|
||||
@@ -75,6 +81,15 @@ test(`Files Report previews a private ${extension} file for a non-member adminis
|
||||
for await (const chunk of await file.createReadStream()) chunks.push(chunk);
|
||||
expect(Buffer.concat(chunks)).toEqual(bytes);
|
||||
expect(db.getBoard(board.boardId).members.some(member => member.userId === adminUser.id)).toBe(false);
|
||||
if (extension === 'zip') {
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
const card = db.findOne('cards', { boardId: board.boardId });
|
||||
await navigateInApp(page, `/b/${board.boardId}/${board.slug}/${card._id}`);
|
||||
await page.locator(`.open-preview[data-attachment-id="${id}"]`).click();
|
||||
await expect(page.locator('#zip-viewer li')).toHaveText(zipNames);
|
||||
await page.locator('#viewer-close').click();
|
||||
await expect(page.locator('#zip-viewer li')).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
db.deleteOne('attachments', { _id: id });
|
||||
fs.rmSync(filename, { force: true });
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const { zipSync, strToU8 } = require('fflate');
|
||||
|
||||
(async () => {
|
||||
const { listZipEntries, openZipAttachment, MAX_ZIP_ENTRIES } = await import('../client/lib/zipAttachmentViewer.js');
|
||||
const { attachmentKind } = await import('../models/lib/attachmentKind.js');
|
||||
const names = ['folder/', 'folder/hello.txt', '日本語.txt', '../outside', '<img onerror=alert(1)>'];
|
||||
const bytes = zipSync(Object.fromEntries(names.map(name => [name, strToU8('hello')])));
|
||||
assert.deepEqual(listZipEntries(bytes), names);
|
||||
assert.deepEqual(listZipEntries(zipSync({})), []);
|
||||
assert.throws(() => listZipEntries(new Uint8Array(21)));
|
||||
assert.throws(() => listZipEntries(new Uint8Array(100)));
|
||||
assert.throws(() => listZipEntries(new Uint8Array(32 * 1024 * 1024 + 1)), RangeError);
|
||||
assert.throws(() => listZipEntries(zipSync(Object.fromEntries(
|
||||
Array.from({ length: MAX_ZIP_ENTRIES + 1 }, (_, i) => [`${i}`, new Uint8Array()])))), RangeError);
|
||||
// Corrupt DEFLATE bytes: listing must never attempt to inflate the payload.
|
||||
const corrupt = zipSync({ bomb: strToU8('a'.repeat(10000)) });
|
||||
corrupt.fill(255, 34, 40);
|
||||
assert.deepEqual(listZipEntries(corrupt), ['bomb']);
|
||||
for (const doc of [{ name: 'a.ZIP' }, { type: 'application/zip' },
|
||||
{ name: 'a.zip', type: 'application/octet-stream' }, { type: 'application/x-zip-compressed' }]) {
|
||||
assert.equal(attachmentKind(doc).isZIP, true);
|
||||
}
|
||||
assert.equal(attachmentKind({ name: 'a.zip', type: 'image/png' }).isZIP, false);
|
||||
assert.equal(attachmentKind({ name: 'a.docx' }).isZIP, false);
|
||||
const originalFetch = global.fetch;
|
||||
try {
|
||||
global.fetch = async () => new Response('', { status: 403 });
|
||||
await assert.rejects(openZipAttachment({ size: 0, url: '/private' }), /403/);
|
||||
global.fetch = async () => new Response(bytes);
|
||||
// A response finishing after close must not touch the detached viewer.
|
||||
await openZipAttachment({ url: '/zip', signal: { aborted: true } });
|
||||
const node = () => ({ children: [], appendChild(child) { this.children.push(child); },
|
||||
replaceChildren() { this.children = []; } });
|
||||
const container = node();
|
||||
container.ownerDocument = { createElement: node };
|
||||
await openZipAttachment({ container, url: '/zip', signal: { aborted: false } });
|
||||
assert.deepEqual(container.children[0].children.map(item => item.textContent), names);
|
||||
assert.ok(container.children[0].children.every(item => !('innerHTML' in item)));
|
||||
} finally { global.fetch = originalFetch; }
|
||||
console.log('zipAttachmentViewer: listing, limits, inert names, authorization and cancellation pass');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user