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