Fix board export to .zip (with attachments) answering a bare 500 error.

models/server/ExporterZip.js still called archiver the v7 way -
require('archiver')('zip', {...}). archiver@8 is ESM-only and exports
classes with no callable default, so that call threw synchronously before
exportZip's route wrote any response header, and safeRoute answered a bare
500 with no detail - every "export board -> .zip" request, for every
board. server/methods/backup.js hit the identical break earlier and
already fixed it with `new ZipArchive({...})`; this call site was missed.
Fixed the same way, with a regression test that also scans the tree for
the same dead API shape.

Thanks to the reporter and xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-09-10 03:13:37 +03:00
1 parent f3c39f3b2c
commit f383fe9b3a
3 files changed
+114 -7

No files matched your search

+30 -4
View File
@@ -311,10 +311,36 @@ the Markdown commit as the template.
# Upcoming WeKan ® release
**In short:** issue #6681 (OIDC redirect-style login loop) is confirmed
already fixed and closed - the reporter's follow-up server log matches the
exact race already corrected by commit 89682c251, released starting v11.62.
No application code changed.
**In short:** **Board export to .zip (with attachments)** answered a bare 500
error on every request; the archiver dependency's v8 API change was missed in
one of the two places WeKan builds a zip on the server. Issue #6681 (OIDC
redirect-style login loop) is confirmed already fixed and closed.
This release fixes the following bug:
<details>
<summary>Board export to .zip (with attachments) answered a bare 500 error</summary>
`models/server/ExporterZip.js` still called the archiver package the v7 way -
`const archiver = require('archiver'); archiver('zip', {...})`. archiver@8
(package.json pins `^8.0.0`) is ESM-only and exports classes - `{ Archiver,
ZipArchive, TarArchive, JsonArchive }` - with no callable default, so that
call threw `TypeError: archiver is not a function` synchronously, before the
`exportZip` route (models/export.js) had written any response header.
`safeRoute` (server/apiMiddleware.js) then answered a bare 500 with no
board-specific detail - every "export board -> .zip (with attachments)"
request, for every board, since archiver was bumped to v8.
`server/methods/backup.js` hit the identical break earlier and already fixed
it with `import { ZipArchive } from 'archiver'; new ZipArchive({...})`;
`ExporterZip.js` was the one call site that was missed. Fixed the same way.
`tests/exportZipArchiverApi.test.cjs` pins the correct API shape, that the
dead factory call is gone, and scans every server-side source file so a
second call site cannot reintroduce the same break unnoticed.
</details>
and closes the following already-fixed issue:
<details>
<summary>Confirm #6681 (OIDC redirect-style login loop) stays fixed</summary>
+8 -3
View File
@@ -1,4 +1,5 @@
import { PassThrough } from 'stream';
import { ZipArchive } from 'archiver';
import { Exporter } from '/models/exporter';
import { fileStoreStrategyFactory } from '/models/attachments.server';
const { sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
@@ -39,13 +40,17 @@ class ExporterZip {
}
async build(res, filename = 'export.zip') {
const archiver = require('archiver');
// archiver@8 is ESM: use the ZipArchive class instead of the old
// archiver('zip', …) factory (which no longer exists) - the same fix
// server/methods/backup.js already made. The old call threw synchronously,
// before any response header was written, so every .zip export answered a
// bare 500 with nothing in it saying why.
//
// store, not deflate, for the JSON? No: a board's JSON compresses to a
// fraction of itself, and the attachments are usually already-compressed
// formats where level 1 costs nothing and saves little. One level for both,
// chosen for the JSON, which is the part that is worth compressing.
const archive = archiver('zip', { zlib: { level: 6 } });
const archive = new ZipArchive({ zlib: { level: 6 } });
filename = sanitizeDownloadFileName(filename);
res.writeHead(200, {
+76
View File
@@ -0,0 +1,76 @@
'use strict';
// Board export -> .zip (with attachments) answered a bare "500 Internal
// Server Error" with no body, on every request, for every board.
//
// Root cause: models/server/ExporterZip.js called the archiver package the
// v7 way - `const archiver = require('archiver'); archiver('zip', {...})`.
// archiver@8 (package.json pins ^8.0.0) is ESM-only and exports the classes
// { Archiver, ZipArchive, TarArchive, JsonArchive } - no callable default -
// so `archiver('zip', {...})` threw `TypeError: archiver is not a function`
// synchronously, before models/export.js' exportZip route had written any
// response header. safeRoute (server/apiMiddleware.js) then answered a plain
// 500 with no board-specific detail, matching the reporter's browser-level
// "500 Internal Server Error" page.
//
// server/methods/backup.js hit the identical break earlier (see its own
// "archiver@8 is ESM" comment) and already fixed it with
// `import { ZipArchive } from 'archiver'; new ZipArchive({...})`.
// ExporterZip.js was missed. Fixed the same way here.
//
// Run: node tests/exportZipArchiverApi.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('exportZipArchiverApi:');
const exporterZip = read('models/server/ExporterZip.js');
test('ExporterZip imports the ZipArchive class from archiver', () => {
assert.match(exporterZip, /import\s*\{\s*ZipArchive\s*\}\s*from\s*'archiver'/);
});
test('ExporterZip constructs `new ZipArchive(...)`, not the old factory call', () => {
assert.match(exporterZip, /new ZipArchive\(\s*\{\s*zlib:/);
});
test('the old `require(\'archiver\')(...)` factory call is gone (negative)', () => {
assert.doesNotMatch(exporterZip, /require\(\s*['"]archiver['"]\s*\)\s*\(/);
});
// The same break could exist anywhere else that zips a stream server-side.
// Scan every server-side source file (not node_modules, not the rspack/Meteor
// build output, not this test itself) for the same dead API shape, so a
// second call site cannot reintroduce the bug this test would otherwise miss.
test('nothing else in the tree still calls archiver as a v7 factory (negative)', () => {
const skipDirs = new Set([
'node_modules', '.git', '.tools', '.build', '_build', '.meteor',
]);
const offenders = [];
(function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (skipDirs.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!/\.(js|cjs|mjs)$/.test(entry.name)) continue;
if (full === path.join(root, 'tests', 'exportZipArchiverApi.test.cjs')) continue;
const text = fs.readFileSync(full, 'utf8');
// A real call, not a comment naming the dead shape (both existing fixes
// document it in prose - match the call syntax, require parens included).
if (/require\(\s*['"]archiver['"]\s*\)\s*\(\s*['"]zip['"]/.test(text)) {
offenders.push(full);
}
}
})(root);
assert.deepStrictEqual(offenders, [], `still call archiver('zip', ...) via require(): ${offenders.join(', ')}`);
});
console.log(`\nexportZipArchiverApi: ${passed} tests passed`);