npm run lint:promises runs typescript-eslint's no-floating-promises and
no-misused-promises over models/lib, server/lib and imports/lib, with type
information read from tsconfig.lint.json (nothing is type-checked). The
eleven findings were promises that never reject (their own catch) and now
say so with void, and three checks that used a promise as a boolean.
Thanks to xet7 !
Admin Panel / Attachments / S3 / Server-side encryption: SSE-S3 (AES256)
or SSE-KMS (aws:kms, with a KMS key or the account's default). S3
encrypts each object as it is written and decrypts it for WeKan on read,
so reads, range requests and moves are unchanged. Every attachment and
avatar upload, every renamed object and every backup written to S3 now
carries the choice; a value that is not one of them, or a malformed key,
adds nothing.
On the filesystem, encryption at rest stays with the disk or volume
(LUKS, an encrypted cloud volume): exports, PDF, thumbnails, virus scans,
backups and WebDAV all read the stored files directly, and encrypting
them inside WeKan would change every one of those and convert every file
already stored.
The new interface strings are pending translation.
Thanks to xet7 !
A streaming tokenizer (models/lib/svgSanitizer.js) replaces the regular
expressions that read an SVG whole: it removes script, foreignObject,
iframe/object/embed, SMIL set/animate, event handlers, javascript:/vbscript:
URIs after decoding character references, dangerous CSS, DOCTYPE/ENTITY XML
loops and processing instructions, wherever they are and however the file is
split into chunks, with bounded memory. UTF-16 is decoded and checked.
Upload sanitizing streams to a temporary file and renames it; validation
checks the whole SVG instead of rejecting any over its 1 MB scan; serving
pipes through the sanitizer with no 8 MB fallback to an unsanitized
download.
Thanks to xet7 !
One executable per platform: that platform's published bundle repackaged
as a Node.js single executable application whose ZIP Node 26.11 mounts as
a virtual file system (useVfs + vfsArchive). Only FerretDB is copied out,
once and verified, to wekan-files/bin; native addons load from the VFS.
Everything WeKan writes is in wekan-files beside the executable, which
WRITABLE_PATH points at: attachments, avatars, db, bin, backup, logs,
versions. WeKan now treats a WRITABLE_PATH named wekan-files as the files
root (models/lib/filesRoot.js, shared by every place that derived it).
Started in a terminal it shows a menu: start, stop, status, version info
as Admin Panel / Version shows it, update to the newest release or change
to any other (checksum verified, backed up first, the old executable
kept), backup to wekan-files/backup/YYYY-MM-DD_HH-MM-SS, run at boot
(systemd, launchd, Task Scheduler, cron, Termux:Boot), log, settings.
Every entry is also a command. start runs the start-wekan.sh loop:
recovery, requested restore, FerretDB, recovery page, WeKan, restart.
Meteor's main.js chdir()s into the bundle, which a VFS cannot be, so the
server role keeps a virtual working directory. Node 26.11.1 breaks
fs.writev/readv while any VFS is mounted; they are rebuilt on fs.write
and fs.read.
single-file.yml builds every target in releases/single-file/targets.json
from its verified bundle (Macs on a Mac, to re-sign), starts it where a
runner exists with browser tests on amd64, and attaches what started.
Release All and Release All Missing call it.
Thanks to xet7 !
- Meteor-Files 3's addFile is async - addFile(path, opts, proceedAfterUpload)
returning the file - and takes no callback. addAttachmentFromStream passed
one as its third argument, which was never called, so every streamed
attachment waited forever: a WeKan or Trello .zip import, a board clone, a
REST raw upload and a storage move stopped at their first file. It now
awaits the promise, as copyFile does; tests/importAttachmentStream checks
that no code hands addFile a callback.
- TrelloCreator rebuilt each attachment from a fixed list of fields that
left out the zip entry the .zip route had matched it to, so every
attachment fell back to downloading its Trello URL, which needs Trello's
own login, and none arrived. The entry and size are kept now
(tests/zipBombBleed); a board that fails in a .zip import is also logged.
- A file record without versions made the file route throw "reading
'original'" and answer 500; it is a missing file, answered 404.
Thanks to xet7 !
On a seeded board of 4,000 cards, 12,000 comments, 20,000 checklist items
and 32,000 activities, served from FerretDB with SQLite as the Snap is:
closing a card took about 4 s and now takes 0.1 s, the board finished
loading in 17.6 s and now in 6.2 s, a card open re-ran about 7,700
computations and now about 1,000, and FerretDB used 13 s of CPU and 724 MB
while the board opened, now 3 s and 280 MB.
- The client data cache queued one more stop callback each time a helper
re-ran, and every check asked Tracker for dependents, which on close was
710 walks of 3 ms; there is now one callback per computation and key and
one timer per key (imports/lib/dataCache.js).
- Opening a card writes the user's last-viewed card, and avatars, @mentions,
list and swimlane headers read the whole user, so everything re-ran; they
read only the fields they show. The comment, checklist and subtask indexes
were one object every minicard depended on and are now per card
(imports/lib/keyedGroupIndex.js).
- The lazy card window kept a full copy of every card of a list per window;
it keeps the id and the sort keys, runs its id query once instead of five
times, and sends comment text only when minicards show comments
(server/publications/cardsWindow.js, models/lib/cardWindowFields.js).
Comment reactions and legacy attachment lookups come with the opened card
only.
- Attachment downloads honour Range for filesystem, GridFS and cloud
storage (206, 416, If-Range); uploads to cloud storage are spooled to a
temporary file and streamed instead of gathered into one Buffer; the API
copy checks the size limit before reading; the legacy attachment
migration streams, and no longer stores "[object Promise]" as a file; the
board export no longer concatenates buffers quadratically.
The FerretDB fork pushes dotted-path filters such as meta.cardId down to
SQLite (they were each a full scan) and decodes only the fields a count
filters on. docs/Features/Admin-Panel/Problems/Large-Boards.md describes how
a large board loads and how to size a server with 8 GB of RAM.
Thanks to markusst1982 and xet7 !
Fixes#6745.
A copyCard rule that copies onto the card's own board now has a saved
command. What the copy contains is decided once, with the ordinary
copy's own helpers: the card as Card.copy builds it (the custom fields
the actor may read, its dependencies on the board, its Scrum metadata,
a new card number, the bottom of the list), its live attachments, its
checklists and items, its subtasks with their checklists, and its
comments. Every document gets an id derived from the rule invocation,
so a replay inserts each once. The copy's and its subtasks' creation
activities are saved and delivered durably; each attachment file is
copied once (copyFile can now be given the id) and its History row is
written from the stored attachment; checklists, items and comments are
direct inserts, as Card.copy makes them. The cover is remapped to the
copied attachment by its derived id. Like the ordinary copy chain
guard, a copy whose own creation triggers the same action is not copied
again. A copy to another board keeps the board on direct Sync.
The server test copies a card with all of those, replays it, checks the
chain guard, and compares the result with the ordinary Card.copy.
Thanks to xet7 !
Replace the separate without-cards action with one selection popup, defaulting to all parts and providing Select All, Select None and Cancel. Validate choices on the server and preserve parent requirements, card relationships, custom fields and existing board-admin permissions.
Repair attachment copying to use explicit imports and the installed Meteor-Files promise API, wait for flushed file bytes, and propagate copy errors. Verify selection combinations, source preservation, attachments, multiselection, cancellation and denied calls with Node and Chromium tests.
Thanks to xet7 !
Remove the progressive HTML4/Legacy Omi middleware, cookieless sessions, page controllers, assets, parity refactors, design documents and associated tests. Restore the standard Meteor interface and operations from before the HTML4 work began.
Preserve intervening translations, backup screenshots, session upgrades, cryptographic test identifiers, local image storage, document previews and lazy browser loading. Extract the GIF utilities still required by image features into imageGif.
Validate the Node suites and add standard-interface and GIF regressions. Two existing failures remain in docsLinksResolve and ruleDetailsLocalization; browser regressions are registered but require a running server.
Thanks to xet7 !
Read whole-board and scoped archives through one bounded path-safe service, stream attachment bytes through the standard Default Storage hook, and preserve stable attachment ids in ZIP exports. Add the signed HTML4 ZIP upload, fix Meteor-Files 3 Promise handling, and verify valid and traversing archives in a cookieless browser.
A Playwright spec found this, and it found more than it was asking about.
"stored HTML is forced to a safe download on the original Meteor-Files route"
expected application/octet-stream and got text/html, status 200 - a stored HTML
attachment served inline, which is the stored XSS that
models/lib/fileResponseSafety.js exists to prevent.
That policy module is correct. It was never reached. Meteor-Files calls the
storage strategy's interceptDownload, and a FALSE return means "not handled,
serve it yourself" - so Meteor-Files served the file from its stored path, with
its stored Content-Type, and none of WeKan's headers.
What makes that a bypass rather than a harmless fallback is WHY the strategy
declines. getReadStream() returns nothing when the file cannot be resolved
INSIDE the storage root, which is the containment check of
GHSA-4mxf-m8pq-xc9p. So `false` means "this is not a file WeKan may serve", and
handing that same file to a server with no containment check of its own answers
the refusal with the file. The safest possible verdict and the most dangerous
outcome, from one boolean.
All three strategies answer 404 and claim the request now. So does the abstract
base, whose body was EMPTY - returning undefined, which Meteor-Files reads
exactly as it reads false, so a strategy that forgot to override it failed open
too. A strategy that forgets should serve nothing rather than serve it
unfiltered.
A file that is genuinely absent gets the same 404 it would have got anyway.
Not logged to Admin Panel / Problems, and that is a decision rather than an
omission: the same path is taken by an attachment deleted while a link to it
survived, which is ordinary use, and interceptDownload cannot tell the two
apart - getReadStream() reports only that it has nothing. Telling "outside the
root" from "not there" is what a key would need first.
The spec had also been writing its file to the wrong place. build.sh exports
WEKAN_FILES_PATH_HOST, which is the path mounted into the browser CONTAINER, so
a local run fell back to the spec's own default of .build/bundle/files while the
server read .tools/test-writable/files. It exports WEKAN_FILES_PATH too now, so
the spec exercises a file the server owns - which is what it meant to test, and
without which it was measuring the bypass instead.
tests/attachmentServeFailsClosed.test.cjs pins that NO interceptDownload
anywhere returns false, that each refuses with a 404, that every serving
strategy still goes through the single policy choke point, and that text/html
is still forced to a download by type and by filename - with an ordinary image
still served inline as the negative.
Thanks to xet7 !
FileStoreStrategyFilesystem builds its candidate paths by joining names onto
the storage root AS WRITTEN, then checked each one against that root with every
symlink RESOLVED. Those are the same string only when nothing in the path is a
symlink.
On macOS nothing is: os.tmpdir() is /var/folders/..., a symlink to
/private/var/folders/..., so every candidate failed containment before it was
looked at and getReadStream() returned undefined - WeKan serving none of its
own attachments. A deployment whose data directory is a symlink, which is an
ordinary arrangement, has the same fault on Linux. The mocha suite has been
reporting it all along as "allows reading a regular file from storage root",
which reads like a fussy assertion rather than like a platform that cannot open
attachments.
The lexical pre-filter now uses the root the candidates were built from. The
security property is untouched, because it was never that check which carried
it: what a caller must not do is reach a file OUTSIDE the root, and that is
decided by resolving the candidate and requiring the result to be inside the
resolved root. GHSA-4mxf-m8pq-xc9p's symlink escape and the plain absolute-path
escape are both still refused, and tests/fileStoreSymlinkRoot.test.cjs pins
that against a real symlinked root - including the exact comparison that caused
the bug.
Bosnian: 2175 untranslated strings to 1. Croatian and Serbian are complete and
Bosnian is the same Stokavian standard, so Croatian is the base, with the
documented Bosnian forms applied - sedmica for tjedan, nivo for razina, server
for posluzitelj, tok for tijek, hiljada for tisuca, historija for povijest, and
tacka/tacno for tocka/tocno. 29 strings needed one; the rest are identical in
both standards.
Two substitutions were REVERTED after reading the output, and that is the part
worth keeping: `poveznica` to `link` produced "iz ove linkove" and "iz bilo koje
linkove na kartu", because `poveznice` is both nominative plural and genitive
singular and one rule cannot be both. `poveznica` is good Bosnian, so it stays -
a correct word left alone beats a more idiomatic one put in the wrong case. The
same blindness left "Najviša nivo", feminine adjective on a masculine noun,
which is fixed by matching the whole phrase.
The last ten were translated directly. The one that remains is "Server", which
is spelled that way in Bosnian too - the tool counts a translation identical to
its source as missing, and that is the tool's limit, not a gap.
Thanks to xet7 !
Reject slash-separated and unquoted event handlers, validate filesystem uploads as well as migrated uploads, and force browser-executable attachment metadata through sandboxed opaque downloads on every storage backend.\n\nThanks to avrlab233 and xet7 !
Reading an archive in the browser and sending its attachments as base64 over
DDP is fine for a card and wrong for a board: 2 GB of attachments become 2.7
GB in one message, in the browser's memory and then in the server's.
A .zip now goes to POST /api/import/zip as the file itself, and nothing is
ever whole in memory on the way in:
- the request body is streamed to a temp file as it arrives;
- unzipper.Open.file reads the archive's central directory, so entries are
opened on demand rather than inflated together - what the backup restore
already does;
- each attachment is piped from the archive into the collection by
addAttachmentFromStream, which writes it to a temp file and hands
Meteor-Files the PATH rather than a Buffer.
That helper is lifted out of copyFile in models/lib/fileStoreStrategy.js,
which already streamed an attachment to a temp path and called addFile - so
there is one way to add an attachment from a stream rather than two.
Where the files end up is not decided here: addFile fires the collection's
onAfterUpload, which validates the file and moves it to the default storage
configured in the Admin Panel, exactly as an ordinary upload does.
The cap is checked as the body ARRIVES rather than after
(WEKAN_IMPORT_ZIP_MAX_BYTES, 5 GB by default), so an oversized archive never
lands, and the temp file is removed in a finally whatever happens. Importing
is a write, so the route asks for board membership, and the Admin Panel's
import switch still applies.
An entry name is data, never a path: only the attachment id before the first
dash is read from it, the temp file is named by WeKan, and the path goes
through the same safeEntryPath containment check the backup restore uses.
tests/fixedVulnerabilityClasses.test.cjs caught this file the moment it read
an archive and built a path - the guard was right to ask, and asking it for
proof is cheaper than arguing that the name was ours.
A .json still travels as a document over DDP, which is what it is.
Thanks to xet7 !
#6591 - "Board Settings -> Export board -> export/Excel didn't work".
Reproduced against a running WeKan: CSV and JSON of the same board answered
200, Excel never answered at all, and the log said nothing.
csv: HTTP 200 4758b
json: HTTP 200 4491b
excel: Operation timed out after 30002 ms with 0 bytes received
Two faults, either of which hangs the browser on its own.
The zip: exceljs 4.7.3's streaming WorkbookWriter calls archiver the way
archiver 7 was called - Archiver('zip', opts) - and WeKan moved to archiver 8
for the low-memory backup zips. archiver 8 is ESM and exports classes, so that
is "TypeError: Archiver is not a function"; the export has been broken since
the bump. Supplying the missing factory does not rescue it: archiver 8's
readable-stream then refuses the objects exceljs appends ("input source must be
valid Stream or Buffer instance"), as an error EVENT. So createWorkbookWriter
asks what archiver exports and, when it cannot stream, returns a buffered
writer with the same API that writes the whole workbook on commit() - the path
the export used before it was made streaming. Bounded memory is what is lost,
not the export.
The silence: the route called exporterExcel.build(res) without awaiting it, so
the rejection went nowhere - no 500, no log, and a response that was never
written or ended. Every sibling route awaits; this one did not. It does now,
and a failure answers 500 with the reason.
#6589 - a .drawio attachment stored and shown as ".bin", unopenable, and
impossible to rename. Also two faults:
The name: a browser sends application/octet-stream for a type it does not
know, and the upload-time "correct the extension to the type" step took that
literally - mime.extension('application/octet-stream') is 'bin', so
"sso-proconnect-keycloak.drawio" became "...drawio.bin". Every unrecognised
format went the same way. An uninformative type now yields no extension at all,
while a type that does say something still corrects the name.
The rename: versions[].path and the file on disk had diverged, and rename used
the recorded path alone -
Error: ENOENT: no such file or directory, rename
'/data/files/attachments/6a7d66369c6aee799e857d36.drawio' -> ...
- while the READER already searched every layout WeKan has used and found the
file. That search is a method now (candidatePaths/resolveExistingPath) and
reading, renaming and deleting all use it, so an attachment that can be read
can also be repaired. When there is genuinely no file, the error names the
attachment instead of a path nobody recognises.
Thanks to rmb82, titver968 and xet7 !
Fixes#6589,
Fixes#6591.
Attachments blocked client-supplied `versions.*.path`; avatars did not. The
avatar allow rule was the single line `update: isOwner` - being the owner let
you write ANY field of your own avatar document, `versions.original.path`
included. That field is server-managed metadata: it says where on disk the
bytes are.
Board export then read it. For every member whose avatar is a local WeKan file,
the exporter opens `versions.original.path` and embeds the bytes as base64 in
`profile.avatarFile`, so an authenticated user could point their own avatar at
/etc/passwd - or at anything else under WRITABLE_PATH - export a board they are
a member of, and decode the file out of the JSON. Arbitrary file read as the
WeKan OS user (CWE-22, CWE-73), from any account that can own an avatar.
Both halves are closed.
The WRITE: the guards that attachments had are now in one module,
models/lib/fileVersionFields.js, and BOTH permission files import it - avatars
had gone without them precisely because each collection wrote its own copy.
Avatars refuse an insert carrying versions.*.path or .storage, refuse any update
touching the versions subtree, and restrict updates to the same field whitelist
attachments use. Being the owner is still required; it is no longer sufficient.
The READ: nothing is read from a stored path unless the path RESOLVES to
somewhere inside WeKan's own storage (models/lib/storagePathContainment.js).
That is the half that also holds when a path is poisoned some other way - a
document written before this fix, a restored backup, a bad migration. The check
sits at getBase64Data, the one place the export turns a stored path into bytes
for attachments and avatars alike, and at both avatar call sites. The download
path in fileStoreStrategy.js has always checked containment this way; its
private copy of the function is gone and it uses the shared one, so the download
and the export ask the same question.
tests/avatarVersionPathTraversal.test.cjs pins the payload being refused on
insert and on update, that renaming an avatar still works, that ..-escapes are
resolved rather than string-matched, that a sibling directory with the root as a
prefix is not inside it, and that no unguarded read of a stored version path
remains in the exporter.
Thanks to Alpastx and xet7 !
Fixes GHSA-4mxf-m8pq-xc9p.
From the dev-server log, on a plain attachment upload:
[onAfterUpload] filename hardening failed: TypeError: Cannot read properties
of undefined (reading 'gridFsFileId')
at FileStoreStrategyFactory.getFileStrategy (models/lib/fileStoreStrategy.js:112)
at detectStoredFileMime (models/lib/fileTypeCorrection.js:120)
at FilesCollection.onAfterUpload (models/attachments.server.js:215)
`getFileStrategy` decided the storage like this:
storage = fileObj.versions[versionName].storage;
if (!storage) {
if (fileObj.meta.source == "import" || fileObj.versions[versionName].meta.gridFsFileId) {
A freshly uploaded file has no `meta` on its version - only a GridFS one does -
so that last read threw on EVERY upload. onAfterUpload catches and logs, so the
upload itself succeeded and looked fine, while the two things that hook does were
silently skipped for every new file: detecting the real mime type and correcting
the stored filename from it. `fileObj.meta` can be absent the same way, and so
can the version itself when a caller asks for one this file does not have.
All three reads are defensive now, and the decision is unchanged where the data
is there: a stated storage wins, an explicit argument wins over that, an import
or a GridFS version is still GridFS, and everything else is the filesystem.
tests/fileTypeCorrection.test.cjs replays that decision table, including the
document shape from the log (a version with no meta), a missing version, and no
file object at all - and fails if the unguarded read comes back.
Thanks to xet7 !
Whenever a filename or file requires sanitization — on upload, migration, the
existing-file corrector, or viewing/serving — record it to the Security event
stream (action:'sanitized'), the companion to the existing action:'blocked'
upload rejections. Each row carries:
- WHEN: the event timestamp.
- WHO: the original uploader (userId), shown as a new clickable username column
in the Problems event report.
- FOR WHAT REASON: sanitizationReasons() — URL-encoded name, invisible
characters, typosquatting (look-alike characters), the specific exploit KIND
(JavaScript code, XML code, XML loop (billion laughs), server-side PHP/ASP,
template injection, HTML code), wrong file type (.txt -> .png), filename too
long, or empty-name-generated.
- THE FILENAME: "from" -> "to".
- WHERE: board > swimlane > list > card, plus the board's organization(s) and
team(s) (server/lib/fileContext.js).
New: classifyExploitKinds() + sanitizationReasons() (isomorphic), the
server-only fileContext resolver, filenameSanitizeLog helpers, catalog keys
file.sanitize / file.content / file.malware, and the uploader column in the
event report. Wired at all fix points (attachments/avatars onAfterUpload,
moveToStorage, correctFileExtensions, httpStreamOutput). EICAR now uses the
file.malware key. Logging is best-effort and never breaks the upload/view path.
Tests: classifyExploitKinds + sanitizationReasons unit tests (positive +
negative) and logging-wiring source guards. CHANGELOG + design doc updated.
One unified filename subsystem, used everywhere a name is shown, uploaded,
served, or migrated. Design: docs/Features/Filename/Filename.md.
Display (imports/lib/fileNameDisplay.js, general cleanFileName + global
{{cleanFilename}}/{{downloadFilename}} helpers): URL-decode, normalize to
generally-used characters (NFKC + confusable-homoglyph folding so typosquatting
"payp<Cyrillic-a>l.exe" shows as "paypal.exe", only in a Latin-majority base so
genuine non-Latin names are preserved), remove invisible/control/bidi chars,
strip HTML/JS/XML/template markup. Removed the earlier invisible-char filter
button, warning triangle, inline descriptions, legend and server invisibleOnly
filter.
Upload (attachments + avatars, all backends): reject exploit-looking filenames
and EICAR virus test files; strip invisible chars; correct the extension to the
real detected type (double-click opens the right app); type-based name when
empty; cap length to a portable max (30, Amiga OS FFS) preserving the extension;
sanitize SVG JS / XML-loop content at the staging location before promoting to
final storage and delete the temp file after; enough-disk-space check with
small-RAM streaming and stop-and-remove-partial on any write error.
Existing files: general storage-agnostic detector streams a small header to
WRITABLE_PATH/files/temp, runs `file`, deletes the temp file; admin-only bounded
batch extension corrector; serve-time on-the-fly exploit sanitizer wired into
httpStreamOutput (sniffs the start, rewrites only dangerous markup, streams the
rest unchanged).
Migrations (moveToStorage/copyFile): sanitize content, finalize the name with
the same general function (detected type, corrected extension, folded
homoglyphs, capped length), save it at the destination, and number
same-name/different-content collisions (document.pdf, document-1.pdf, ...).
Tests: rewrote fileNameDisplay.test.cjs; added uploadFileName, diskSpace,
fileTypeCorrection and fileHardeningGuards unit tests (positive + negative);
updated Playwright spec 41 to the clean-name model. CHANGELOG + design doc.
The #6412 mocha test imported the helpers from fileStoreStrategy.js, which pulls
in Meteor, so it could only run under `meteor test` (and the local harness loaded
0 server tests here) — meaning it could not be negative-tested locally.
Extract the pure, Meteor-free helpers (truncateFilenameToBytes / sanitizeFilename
/ MAX_FILENAME_BYTES) into models/lib/filenameSanitizer.js; fileStoreStrategy.js
requires them. Add tests/filenameSanitizer.test.cjs (plain Node, `npm run
test:unit:node`) and point the mocha test at the new module. The Node test is
negative-tested: 7 passing on the fix, and the length-cap assertion fails when
the truncation is reverted. Verified `meteor build` still compiles (the helper
is bundled into the server).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Export truncateFilenameToBytes / sanitizeFilename from fileStoreStrategy and
add server/lib/tests/filenameTruncation.tests.js (#6412): byte-budget
truncation preserves the extension, never splits a multibyte codepoint, caps
the on-disk component under 255 bytes, and still strips path traversal.
- Spec 36: a guest opening a public board renders lists with no
"isBoardAdmin of null" crash (#3897).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Attachment filenames were sanitized for path traversal but never length-limited,
so a very long name (made worse by multibyte UTF-8 chars like German umlauts,
2 bytes each) produced an on-disk component "<id>-<version>-<name>" exceeding the
filesystem's 255-byte per-path-component limit. Migrating such files from GridFS
to filesystem storage then failed with ENAMETOOLONG (errno -36).
sanitizeFilename now truncates the name to 200 UTF-8 bytes, measured in bytes
(not characters) and never splitting a multibyte codepoint, while preserving the
file extension. 200 bytes leaves headroom for the "<id>-<version>-" prefix so the
full component stays under 255 bytes. Applied centrally in sanitizeFilename so
every path-construction and lookup site stays consistent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- **Admin Panel / Attachments: "Calculate file counts" on every storage backend,
shown right below the "Read" toggle.** Azure Blob Storage and Google Cloud
Storage now have a "Calculate file counts" button (new `getAzureStorageStats` /
`getGcsStorageStats` methods) that reports how many attachments and avatars are
stored on that backend, matching what Filesystem, MongoDB GridFS and S3 already
offer. The count is read from the stored file metadata (fast, no cloud API
calls). The S3 button was also moved up to sit directly under its "Read"
checkbox, so all backends are consistent. Thanks to Claude.
- **Admin Panel / Attachments: bilingual, self-documenting cloud-storage fields
(S3, Azure and Google Cloud Storage).** Each cloud-storage setting now guides
the admin from top to bottom with both English and translated text, so it is
easy to follow along while clicking through the provider's Cloud Console:
- the field **label** uses the provider's own console wording in English
(e.g. *Access key ID*, *Secret access key*, *Storage account key*,
*Service account key (JSON)*), with the translated label shown below it;
- a literal **example** of what the value should look like
(e.g. `eu-west-1`, an example bucket name / endpoint / connection string),
which is intentionally not translated;
- a short **description** of the value, in English and then translated;
- the **Cloud Console menu path** showing exactly where to find or create the
value (e.g. *AWS Console → IAM → Users → your user → Security credentials →
Access keys → Create access key*; *Azure Portal → Storage accounts → your
account → Access keys → key1*; *Google Cloud Console → IAM & Admin →
Service accounts → … → Keys → Add key → Create new key → JSON*), again in
English and then translated.
The provider field names, examples and menu paths are kept in English so they
match what the Cloud Console actually shows, while the descriptions and menu
paths are also translatable; section titles, the enable/read/force-path-style
checkboxes and the Test/Save buttons remain fully translated. Thanks to Claude.
- **Admin Panel / Attachments / Move Attachment: "Repair file locations" button
and a persistent "last move" message.** The new **Repair file locations** button
scans all attachments and avatars and finds any whose recorded storage
(`versions.<v>.storage` / `path` / `meta.gridFsFileId`) no longer matches where
the binary actually is — left inconsistent by an interrupted or failed move —
and fixes the database to point at the real location: it detects the binary in
GridFS (by the `metadata.fileId` stamped on upload, recovering files whose
GridFS id reference was lost) or on the filesystem (using the storage
strategy's own thorough path resolution, so the repair agrees with what a real
download/move would find even when `versions.<v>.path` is stale), then corrects
`storage`, `path` and `meta.gridFsFileId` accordingly. Cloud-stored files are
left untouched, and files found nowhere are reported as "Not found". The scan is
streamed (memory-safe) and shows a per-scope searched / repaired / not-found
summary. Separately, after a move finishes the page now keeps showing
the last move operation — source → destination (scope) and the date/time as
`YYYY-MM-DD HH:MM:SS` — so it is clear what was last done. Thanks to Claude.
New fixes:
- **Fixed moving attachments to S3 (and other cloud storage) failing and
crashing the server.** Uploading to S3 failed two ways, each of which crashed
the whole server because the rejection was unhandled and SyncedCron treats
those as fatal. The `@tweedegolf/sab-adapter-amazon-s3` adapter uploaded with a
`PutObjectCommand` whose `Body` was a live stream: (1) with no `ContentLength`
the AWS SDK fell back to a chunked signed upload that needs a decoded content
length, which was undefined for a stream of unknown size — `Invalid value
"undefined" for header "x-amz-decoded-content-length"`; and (2) if the socket
dropped mid-upload (`socket hang up`), the SDK's body-stream promise rejected
unhandled. Fixed by uploading the file as a complete in-memory **buffer**
(`addFileFromBuffer`) instead of a live stream: a buffer has a known length (no
content-length error) and no socket-bound stream to re-reject. The cloud upload
promise now never rejects — any failure is captured and exposed via
`waitUntilStored()`, which `moveToStorage` checks inside a try/catch so a failed
upload leaves the source file intact and is logged cleanly. (Files are moved one
at a time, so peak memory is one file.) Thanks to Claude.
- **Fixed `TypeError: Cannot read properties of undefined (reading 'on')` when
moving attachments between Meteor-Files/GridFS and Filesystem.** If a file's
source binary was missing at its recorded location (e.g. a file left
half-moved by an earlier interrupted run — storage says "gridfs" but the
GridFS id reference is gone, or a filesystem path that no longer exists),
`getReadStream()`/`getWriteStream()` returned `undefined` and `moveToStorage`
(and `copyFile`) called `.on()` on it, throwing. Both now detect a missing
read/write stream, log a clear message (which file, version, from→to storage,
and why), **skip that file and leave the source intact** so no data is lost,
and continue with the rest. Use the new "Repair file locations" button to fix
the underlying inconsistent records. Thanks to Claude.
- **Fixed moving attachments/avatars from CollectionFS to Meteor-Files crashing
the server, hanging the Admin Panel move at "File 1 / N", and leaving broken
avatars.** Three problems in Admin Panel / Attachments / Move Attachment:
- **Server crash on the first file** (`uncaughtException: TypeError: Cannot read
properties of undefined (reading '_id')` at
`AttachmentStoreStrategyGridFs.writeStreamFinished`). The current mongodb
driver's GridFS `GridFSBucketWriteStream` `'finish'` event no longer passes
the stored file document, so `finishedData._id` threw and killed the process
(which is also why the move appeared stuck at "File 1 / 4" — the background
job died mid-file and its persisted status was frozen). The GridFS strategy
now reads the uploaded file id from the write stream itself (`gridFSFile._id`
/ `id`), the `'finish'` handler is wrapped so it can never crash the process,
and a startup reconciliation clears a stale "running" move status left by a
crashed run so the UI un-sticks and a new move can be started.
- **Broken avatar after migrating avatars.** The bulk move only repointed card
cover references (`cards.coverId`); a user's `profile.avatarUrl` still pointed
at the deleted legacy `/cfs/files/avatars/<oldId>` URL, so the avatar rendered
broken. The move now repoints `profile.avatarUrl` to the migrated avatar
(`remapReferences` handles avatars), and migrated files stamp
`meta.migratedFromId` so references can be repaired to the **exact** new file.
The startup repair in `server/models/users.js` (previously a no-op: it
string-replaced the URL prefix while keeping the now-deleted id and never
saved the document) now points each affected user's avatar at their migrated
Meteor-Files avatar — matched precisely by `meta.migratedFromId`, otherwise
strictly by `userId` (newest migrated avatar first), so a user can only ever
be given their own avatar, never another user's.
- **`ObjectID` deprecation warning** from `models/lib/grid/createObjectId.js`
(`MongoInternals.NpmModule.ObjectID` → `ObjectId`).
Thanks to Claude and xet7 !
Several issues in the bulk move:
- The GridFS source matcher required `meta.gridFsFileId` to be *absent*, but
Meteor-Files always sets it, so selecting "MongoDB Meteor-Files" matched zero
files and "nothing happened". The matcher now recognizes real GridFS files
(`versions.*.storage === 'gridfs'` or a `meta.gridFsFileId` reference), and a
"nothing to move" message is shown when a source is empty instead of silently
doing nothing.
- Moving files crashed the whole server with
`FilesCollection#findOne() not available in server` — `ReactiveCache.getAttachment`
used a synchronous `findOne()`; it now uses `findOneAsync()`. The background
job is also hardened so a single failing file is skipped instead of crashing
the server via an unhandled rejection.
- The attachment **copy** API now honours the admin API upload limits (it
previously skipped them).
- **Fixed the "MongoDB Meteor-Files" file-count statistic** in Admin Panel /
Attachments, which counted *every* attachment metadata document (so files on
the Filesystem were wrongly reported as being in GridFS). It now counts only
attachments actually stored in GridFS, consistent with the move tool. Also
renamed the mislabeled "Mongo-Files" column to "Meteor-Files". Thanks to Claude.
- **Unified attachment/avatar storage migration: move any → any.** Admin Panel /
Attachments / Move Attachment can now move **Attachments, Avatars, or both**,
from **any source to any destination** across Filesystem, Meteor-Files GridFS,
Cloud (S3/Azure/GCS) and legacy CollectionFS GridFS. The default source is
"All Read-enabled storages" (every backend whose Read flag is enabled and whose
settings work), so everything can be consolidated into one destination in a
single run. All metadata is preserved (board / swimlane / list / card / user /
uploaded date / name / type / size), attachment cover references
(`cards.coverId`) are remapped when an id changes, and the legacy source is
deleted only after the new copy is verified. Thanks to Claude.
- **Legacy CollectionFS GridFS is now a first-class storage backend** (read,
migrate-from, and export-to) for both attachments and avatars, via the new
`models/lib/collectionFsStore.js` (binary keyed by `copies.<coll>.key` in the
`cfs_gridfs.<coll>` bucket, metadata in `cfs.<coll>.filerecord`). Thanks to Claude.
- **The storage strategy layer is now collection-aware.** `moveToStorage` and the
GridFS/Cloud strategies previously hard-coded the `Attachments` collection, so
moving avatars to GridFS/cloud would have updated the wrong collection. The
factory now carries its collection (`Attachments` or `Avatars`), so **avatars
can be stored in Meteor-Files GridFS and cloud**, not only on the filesystem.
Attachment behavior is unchanged. Thanks to Claude.
- **Read legacy CollectionFS attachments and avatars in place** (without
migrating). The backward-compatibility layer
(`models/lib/attachmentBackwardCompatibility.js`) was broken — it looked up the
GridFS binary by the filerecord `_id` and by filename instead of by
`ObjectId(copies.<coll>.key)`, so legacy files were never found. It is fixed and
generalized for attachments and avatars. Legacy attachments now appear in the
card attachment gallery (new `legacyBoardAttachments` publication) and stream
from the `cfs_gridfs.attachments` bucket, and legacy avatars
(`/cfs/files/avatars/<id>` URLs) are served from the `cfs_gridfs.avatars`
bucket instead of redirecting to a 404.
Thanks to Claude.
- Fixed one-finger drag-to-scroll not working in the All Boards view and the
Calendar view on Fairphone 4 Firefox and Ubuntu Touch Morph. These browsers
do not emit the synthetic mouse events the `@wekanteam/dragscroll` library
relies on, so scrolling only worked in the Swimlanes/Lists views. Added a new
native touch-scrolling helper `client/lib/dragscrollTouch.js` that scrolls the
nearest `.dragscroll` container with a one-finger drag (iOS is skipped, since
native momentum scrolling already works there), and added the `dragscroll`
class to the All Boards list and the Calendar view.
- Fixed all text, buttons, the All Boards tables and the top bars (header and
the second bar) rendering about 2x too large on Fairphone 4. `getMobileMode()`
defaulted to mobile mode only for iPhone, so every other phone fell back to
desktop mode, whose large desktop sizing looks oversized on a small phone
screen. The default is now mobile mode for any phone-sized touch device
(iPhone, Android/Mobile browsers, Ubuntu Touch, or a coarse-pointer touch
screen with viewport width ≤ 800px); desktop browsers stay in desktop mode and
the user's own Mobile/Desktop toggle still takes priority.
- Fixed not being able to reorder swimlanes and lists by dragging their drag
handle on touch devices. The new touch-scroll helper was hijacking touches
that started on a drag handle; `.handle` / `.ui-sortable-handle` elements are
now excluded from touch-scrolling so dragging the handle reorders as expected.
- Fixed a card duplicating into two cards when it was dragged by its title-row
drag handle while its Card Details panel was open. The card sortable in
`client/components/lists/list.js` was re-initialized on every list re-render
without first destroying the previous instance, so two `stop` handlers fired
per drag. It now destroys any existing sortable before re-initializing, the
same guard already used for swimlanes.
Attachment storage:
- Added configurable attachment storage backends in the admin panel: choose the
default save storage and store attachments on the local filesystem, GridFS, or
cloud object storage — S3-compatible (AWS S3, MinIO, Cloudflare R2, Backblaze
B2, Wasabi, DigitalOcean Spaces), Azure Blob Storage, or Google Cloud Storage
— via the `@tweedegolf/storage-abstraction` adapters (new
`models/lib/cloudStorage.js`). Includes a "Test connection" action and
English translations for the new settings.
- Added a server-side bulk attachment move (new `server/attachmentBulkMove.js`
and `models/attachmentBulkMoveStatus.js`) that moves all attachments from one
storage backend to another as a background job whose progress survives the
admin navigating away from or closing the page.
Thanks to xet7.
- users.js: Replace fibers/future pattern with async/await using
insertAsync/updateAsync for template container creation
- fileStoreStrategy.js: Remove duplicate sanitizeFilename declaration
- wekanCreator.js: Convert attachments.forEach to for...of loop
so await works inside the iteration body