60 Commits
Author SHA1 Message Date
Lauri Ojansivu f1d2a497bc Lint for promises that are not awaited, handled or marked void.
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 !
2026-11-02 14:30:49 +02:00
Lauri Ojansivu 482c2c29f4 Attachments, avatars and backups in S3 can be encrypted at rest.
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 !
2026-10-31 00:12:09 +02:00
Lauri Ojansivu e748559838 SVG of any size is sanitized whole, streaming: on upload, on validation and on every serve.
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 !
2026-10-25 07:23:31 +02:00
Lauri Ojansivu 31e569a18e Single-file WeKan for every platform with a Node.js bundle.
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 !
2026-10-13 21:08:09 +03:00
Lauri Ojansivu e7be1ed082 Streamed attachments are stored again, and a Trello .zip brings its files.
- 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 !
2026-10-10 12:21:27 +03:00
Lauri Ojansivu d56bb34490 Make large boards fast on 8 GB: load only what is visible, stop re-running every minicard, and stream big attachments.
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.
2026-10-09 13:28:08 +03:00
Lauri Ojansivu 22311feaf4 Run the copyCard rule action through durable Sync on the card's own board.
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 !
2026-10-02 17:09:16 +03:00
Lauri Ojansivu 8bf47d1a9a Choose structure and content when duplicating selected boards
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 !
2026-09-27 14:52:33 +03:00
Lauri Ojansivu 828fc2d718 Revert Legacy HTML4 browser interface and parity changes
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 !
2026-09-07 17:03:59 +03:00
Lauri Ojansivu 39671b13e5 Add accessible HTML4 document previews 2026-09-07 11:53:41 +03:00
Lauri Ojansivu 034d3b9208 Add Legacy HTML4 WeKan ZIP imports
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.
2026-09-06 20:11:20 +03:00
Lauri Ojansivu b87082b3dd Refuse a file WeKan will not serve, instead of letting Meteor-Files serve it.
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 !
2026-09-05 05:14:59 +03:00
Lauri Ojansivu bdb3b15886 Serve attachments when the storage root is a symlink, and finish Bosnian.
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 !
2026-09-05 05:06:37 +03:00
Lauri Ojansivu c8e7b704df Close attachment content-type bypasses
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 !
2026-09-01 10:50:55 +03:00
Lauri Ojansivu fe77b7461e Stream a .zip import through an upload endpoint instead of the browser.
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 !
2026-08-14 01:45:59 +03:00
Lauri Ojansivu a712db9473 Fix the board Excel export, and the attachment stuck as .bin.
#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.
2026-08-13 15:51:25 +03:00
Lauri Ojansivu 0a9eb0ef4f PathBleed: an avatar could name any file on disk, and board export read it.
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.
2026-08-09 02:19:15 +03:00
Lauri Ojansivu c2fa9e0b3d Every upload threw in getFileStrategy, so no new file was mime-checked.
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 !
2026-07-27 21:39:51 +03:00
Lauri Ojansivu fdb683892d Log every filename/content sanitization to Admin Panel / Problems
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.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 81dbecfb87 Safe filename handling: display, upload, existing files, migrations
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.
2026-07-20 03:11:07 +03:00
Lauri OjansivuandClaude Opus 4.8 72e10a81b7 Make #6412 filename test negative-testable: extract pure helpers + Node test
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>
2026-06-22 15:46:43 +03:00
Lauri OjansivuandClaude Opus 4.8 5709854ed5 tests: add #6412 filename-truncation unit test and #3897 guest-board regression
- 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>
2026-06-22 12:09:19 +03:00
Lauri OjansivuandClaude Opus 4.8 5e26cf0048 Fix ENAMETOOLONG on very long attachment filenames (#6412)
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>
2026-06-21 21:08:26 +03:00
Lauri Ojansivu a86a087915 New features:
- **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 !
2026-06-06 07:21:59 +03:00
Lauri Ojansivu d30803276d - **Fixed Admin Panel / Attachments / Move Attachment doing nothing / crashing.**
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.
2026-06-06 02:06:16 +03:00
Lauri Ojansivu 6f021aa387 Mobile / touch fixes (Fairphone 4 postmarketOS Firefox and Fairphone 4 Ubuntu Touch Morph browser; iPhone 12 Mini was already correct):
- 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.
2026-05-31 11:12:41 +03:00
Lauri OjansivuandCopilot Autofix powered by AI 13f78f6207 Potential fix for code scanning alert no. 58: Incomplete multi-character sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-30 22:48:37 +03:00
Lauri Ojansivu fc92b342ce Fix GHSA-g6vm-7757-pr88. Moved Attachment Storage options from board to Admin Panel. Changed API to use Expose Meteor user context to Express endpoints.
Thanks to Jan Kahmen of turingpoint GmbH for reporting GHSA-g6vm-7757-pr88 and xet7 for fixes and attachment improvements.

Thanks to nachocodoner about https://forums.meteor.com/t/expose-meteor-user-context-to-express-endpoints/64384.
2026-05-27 20:54:05 +03:00
Lauri Ojansivu f26a2ac9ad Added fallback so attachments and avatars can be at WRITABLE_PATH or WRITABLE_PATH/files.
Thanks to xet7 !
2026-05-16 13:46:56 +03:00
Lauri Ojansivu b42689a54d Fix attachment rename fails for clipboard uploads and keep extension metadata in sync.
Thanks to snowsky, YosserDerbali and xet7 !

Fixes #6203,
related https://github.com/wekan/wekan/pull/6217
2026-03-27 18:14:10 +02:00
Lauri Ojansivu 5c5ed102ca Export Card to Excel. Part 3. Also, fixed uploading attachments.
Thanks to xet7 !

Fixes #5324
2026-03-25 01:37:42 +02:00
Harry Adel 63ce45c538 Split shared models from Meteor 3 server runtime 2026-03-24 01:50:39 +02:00
Harry Adel 7468b30001 Fix build errors: fibers/future, duplicate sanitizeFilename, forEach await
- 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
2026-03-22 18:24:07 +02:00
Harry Adel b5782c7b86 Replace underscore with native JS in models
Largest changes in cards.js (~76 calls), boards.js (~38 calls),
and activities.js (~31 calls). Key patterns:

- _.contains -> .includes() with null guards where needed
- _.findWhere/_.where -> findWhere()/where() helpers
- _.pluck -> .map()
- _.union -> [...new Set()]
- _.extend -> Object.assign()
- _.each on objects -> Object.entries().forEach() (argument order swap)
- _.debounce -> debounce() helper
- _.groupBy/_.indexBy -> groupBy()/indexBy() helpers
- _.clone -> spread operator
- _.sortBy -> [...arr].sort()
- Type checks -> native typeof
2026-03-18 22:39:06 +02:00
Harry Adel 71eb01e233 Update ReactiveCache call sites to use async/await for Meteor 3.0
Part 3 of ReactiveCache async migration:
- Add await before all ReactiveCache.getX() calls
- Make functions containing ReactiveCache calls async
- Convert forEach/map/filter loops with async callbacks to for...of
- Update model helpers, Meteor methods, JsonRoutes handlers
- Update collection hooks (.before/.after insert/update/remove)
- Update .allow() callbacks to async

Files updated across models/ and server/ directories:
- Model files: cards, boards, lists, swimlanes, activities, users,
  checklists, checklistItems, customFields, attachments, integrations,
  cardComments, settings files, creators, exporters, and more
- Server files: publications, methods, notifications, routes, migrations
2026-02-01 00:54:38 +02:00
Lauri Ojansivu a419d831a4 Fix Filebleed of Floppybleed.
Thanks to Luke Hebenstreit Twitter lheben_ and xet7 !
2026-01-28 13:20:28 +02:00
Lauri Ojansivu e9a727301d Fix SECURITY ISSUE 1: File Attachments enables stored XSS (High).
Thanks to Siam Thanat Hack (STH) !
2025-11-02 08:36:29 +02:00
Lauri Ojansivu 4283b5b0e3 Disable not working minio and s3 support temporarily.
Thanks to xet7 !
2025-10-16 17:49:39 +03:00
Martin Filser 06e374f0ff Move every Cards.findOne(idOrFirstObjectSelector, options) to the ReactiveCache 2023-07-18 21:25:28 +02:00
Martin Filser 2eadd3eb8b attachment copy changes now the boardId, listId and swimlaneId in the collection data
- fixing admin attachment view, copied card attachments were wrong if copied to another board / list / swimlane
2023-06-28 21:21:09 +02:00
Lauri Ojansivu fb37f48a86 Updated dependencies. 2023-04-12 08:23:22 +03:00
Lauri Ojansivu cd1750f368 Forked minio npm package to @wekanteam/minio to update package dependencies. S3/MinIO support In Progress.
Thanks to xet7 !

Related #142
2023-01-16 15:11:31 +02:00
Lauri Ojansivu 391607ec79 Store files to S3. Part 4. In Progress.
Thanks to xet7 !
2022-12-27 21:42:06 +02:00
Lauri Ojansivu 028633b00a Fix some S3 typos. 2022-12-26 05:01:15 +02:00
Lauri Ojansivu 21e2eabd60 Store files to S3. In Progress.
Thanks to xet7 !

Related #142
2022-12-21 23:48:47 +02:00
Martin Filser f6d7377977 Remove duplicate IDs issue 2022-12-19 21:14:08 +01:00
Martin Filser 1516615934 Copy attachment copies now all versions
- for now only the version "original" exists, but as other method already
  consider that other version's can exist
2022-08-18 17:31:41 +02:00
Martin Filser 26e1c1dc4a Attachment file rename is now possible
- Relates to: #2099
2022-05-20 12:38:46 +02:00
Martin Filser 1fc3ed407a Merge remote-tracking branch 'origin/upgrade-meteor' into upgrade-meteor 2022-05-03 17:50:30 +02:00
Martin Filser 000e27a4a1 Board copy moves the attachments to the "copied from board storage" 2022-04-30 21:05:04 +02:00