The report was still stuck on the loading spinner: an await in the publish body
never resolved, so this.ready() in the `finally` never ran and the subscription
never became ready (the report template only renders once ready). Two changes make
the publish always reach readiness:
- Call this.ready() UP FRONT, before any await, then stream the page rows with
this.added — the table populates reactively. The spinner can no longer get stuck
regardless of how slow/stuck the fetch is.
- Compute accessibleCardIds() by querying Boards / Cards directly with fetchAsync
instead of through ReactiveCache, and fetch the attachments page from
Attachments.collection directly — raw async fetches always resolve, avoiding the
ostrio FilesCollection cursor / backward-compatibility fallback that can hang.
Thanks to xet7 !
After keeping the board icons in their own right-hand column, the left menu
(Starred / Templates / Remaining / Workspaces / Add board) still could not be
scrolled: the fixed-height wrapper clips at 100vh, and only the board list had its
own scroll, so a menu taller than the screen had its lower items unreachable.
Give the left menu its own overflow-y:auto / bounded max-height so you can
drag-scroll down to its lower items, just like the boards column on the right.
Both columns are reachable in LTR and RTL — the layout uses a direction-aware grid
and logical inline borders, so under a dir="rtl" document the menu is on the right
and the boards on the left automatically. Spec 43 now also asserts the left menu
is a bounded scroll container.
Thanks to xet7 !
On phone-sized touch screens the All Boards layout collapsed to a single column,
so the board icons ended up BELOW the left menu and search bar. dragscroll does
not work over the menu / search area, so the boards below the fold could not be
reached by dragging — you could not scroll down to see more boards.
Remove the small-screen `grid-template-columns: 1fr !important` collapse: keep the
two-column layout (narrow minmax(84px,30%) left menu, board icons in their own 1fr
column on the right) in both mobile and desktop mode. The narrow minmax() menu
already prevents the old fixed-260px overflow that clipping-collapse was meant to
fix, and the board list keeps its own overflow-y:auto / dragscroll. Spec 43 now
also asserts the board list sits to the right of the menu, not stacked below it.
Also fix two WebKit-only test assertions (not app bugs): read the WebKit-prefixed
getComputedStyle(...).webkitUserSelect in the Statistics-view test.
Thanks to xet7 !
The 'attachmentsList' publication fetched via ReactiveCache.getAttachments(),
which reads through the ostrio FilesCollection cursor; when that returns no rows
it falls back to getAttachmentsWithBackwardCompatibility(), whose old-CFS lookups
can hang. The hang is INSIDE the publish's try block, so the finally this.ready()
never runs — the subscription never becomes ready and the Files report (Admin
Panel / Reports / Files) is stuck on the loading spinner, its content pane never
rendering (Playwright: the report table never appears in any browser). The other
admin reports query plain collections and were unaffected.
Query the plain Mongo collection directly (Attachments.collection.find) in both
the publication and the getAttachmentsReportCount method. The attachment rows live
in that same 'attachments' collection, so a direct find returns the page and always
completes: this.ready() fires and the report renders its table.
Thanks to xet7 !
The filesReport content pane rendered EMPTY in the newest Playwright run (no
title, no search box) — a throwing helper aborts the whole Blaze template. Guard
Attachments.collection access and never throw so the report UI always renders.
The core symptom (no attachments listed) is unverified/unfixed — it points at the
attachmentsList publication returning 0 rows, which needs a running server + DB to
diagnose. Correct the v10.07 changelog to stop claiming the report is fixed.
Issue #5659 reported that list-width defaults did not apply on public boards
(logged-out viewers) and that width settings only affected logged-in users. This
is already fixed by the models/lib/listWidth.js single-source-of-truth work:
resolveListWidth() returns the one DEFAULT_LIST_WIDTH (220) for every
uncustomized list and every viewer — members and logged-out public-board
visitors alike; the shared lists.width (#6409) applies to everyone; anonymous
viewers customize via localStorage. Verified against current source:
- resolveListWidth fallback order (mirrored, all cases incl. negatives pass)
- client list.js / listHeader.js and models/users.js import the shared module
with no stale 272/270 defaults; models/lists.js schema defaultValue is 220
The tests/listWidthDefaults.test.cjs regression test already existed but was not
wired into `npm run test:unit:node`, so it never ran. Wire it in so the #5659
fix stays verified.
Thanks to NadavTasher and xet7 !
Closes#5659,
The mobile board list is meant to be a BOUNDED, scrollable region
(height: calc(100vh - 120px)) so boards below the fold are reachable inside the
overflow:hidden wrapper (#6488). Two leftover .board-list.mobile-view rules
re-introduced the exact old bug: `min-height: 100vh` and a phantom
`::after { height: 100px }` forced the list to grow to its full content height,
so nothing ever overflowed — the list was as tall as its content and did not
scroll, and the lower boards were clipped and unreachable.
These non-media rules have higher specificity than the media-query block that
already sets the bounded height (whose comment notes min-height:100vh + ::after
was the bug), so they won this fight. Remove both; the bounded height now takes
effect and the two-column list scrolls.
Thanks to xet7 !
statsView.jade / statsView.js / statsView.css existed but were never imported
anywhere in the client build, so the `statsView` Blaze template was never
registered. The board body's `+statsView` include (selected when the user's
boardView is 'board-view-stats') therefore rendered nothing — the board header
showed "Statistics" (that only reflects the persisted boardView) while the
board canvas stayed empty. The Statistics view had never actually rendered.
Import the three files in client/features/boards.js alongside the other board
views (tableView), so the template, its helpers and its styles are in the bundle.
Thanks to xet7 !
The Files report (Admin Panel / Reports / Files) built its rows and count from
the ostrio FilesCollection wrapper (Attachments.find() / .count()). The
'attachmentsList' publication delivers the page via this.added('attachments', ...),
but the ostrio FilesCursor does not reliably re-run in a Blaze helper when those
docs arrive, so resultsCount stayed 0 and the `if resultsCount` table never
appeared (the report showed only its controls, no rows).
Read the underlying minimongo collection (Attachments.collection) instead, a
plain reactive Mongo.Collection cursor that reacts to the published adds and
yields plain docs — the same pattern the rest of the codebase already uses for
reactive attachment reads (sidebar backgrounds, backgrounds publication).
Thanks to xet7 !
The #6488 mobile All Boards test asserted the first two li.js-board tiles share
a row, but the list's leading '+ Add board' tile takes the first grid cell, so
the first two boards legitimately land on different rows in a 2-column grid
(the measured 194px is exactly one row). Measure every tile and assert the top
row holds >=2 tiles at two distinct x columns instead — a direct 2-per-row check.
The Statistics-view test switched via the board-view popup, which calls
Utils.setBoardView -> window.location.reload(); reloading the board cold mid-test
races the assertion (the board-canvas is still empty when it fires). Pre-seed
profile.boardView instead and load the board once, so statsView renders on the
first load without a mid-test reload.
FerretDB (v1 SQLite fork) does not implement MongoDB change streams: a
$changeStream aggregate returns "not implemented" and Meteor busy-loops
retrying it (high FerretDB CPU, cards never open). The snap fix 383b3dd43
already forced changeStreams out of METEOR_REACTIVITY_ORDER for FerretDB;
apply the same strip to the remaining FerretDB run-paths so changeStreams
can never enter the order however it was passed in:
- releases/ferretdb/start-wekan.sh (standalone FerretDB release)
- releases/ferretdb/wekan-entrypoint.sh (FerretDB Docker image)
- releases/ferretdb/start-wekan.bat (Windows, stripped at top level so
each set sees the previous line's result)
The Docker compose files already hardcode safe static values
(oplog,polling / polling, no changeStreams). start-wekan.sh and
start-wekan.bat at the repo root are the real-MongoDB path (they
initialise a replica set), where change streams are correct.
Thanks to uusijani, mueschel and xet7 !
Fixes#6492, #6493, #6480,
FerretDB v1 does not implement MongoDB change streams. The snap's general
default METEOR_REACTIVITY_ORDER is "changeStreams,oplog,polling", and it
arrives in wekan-control already set, so the FerretDB `:-oplog,polling`
fallback did not apply. Meteor then issued $changeStream aggregates that
FerretDB rejected as "not implemented" thousands of times per second - a
busy-loop that pinned FerretDB CPU (100-390%, 168% seen on 8 cores) and
starved board/card loading, so cards would not open (almost all commands
were those failed aggregates).
For FerretDB, force changeStreams OUT of the reactivity order whatever it
was set to, leaving oplog + polling; real MongoDB is unchanged and keeps
change streams. Polling-disabled path is polling only.
Thanks to xet7 !
Fixes#6492,
Fixes#6493.
setBoardView Meteor method did not await user.setBoardView(view), so it
resolved (and the client reloaded) before profile.boardView was written
and a rejected write never surfaced — switching the board view (e.g. to
the new Statistics view) was unreliable. Await it.
All Boards on phones now lays the board icons out as a real 2-column CSS
grid (repeat(2, minmax(0,1fr))) instead of float+width:50%, so at least 2
icons per row is guaranteed regardless of float-logical-value support or
how narrow the boards column is (#6488).
Thanks to xet7 !
Restructure the Upcoming WeKan release section like the released sections
and per docs/v10/Claude.txt: a new-features list and a fixes-the-following-bugs
list, each entry a link to its commit ending with Thanks to X and xet7,
closing with the thanks-to-translators line. Attribute the reported bugs
(mueschel for 6479 and 6480, mimZD for 6488).
Thanks to xet7 !
A moveCard trigger has no oldListName (the wizard chooses a destination
list, not a source), but findMatchingRules built the trigger query with
{ oldListName: { $in: [value, '*'] } }. Mongo's $in does not match a
document that is MISSING the field unless null is in the list, so the
trigger never matched and the rule's action silently never ran, with no
error. Add null to the match list for every field (a $in with null also
matches a missing field), exactly like the cardTitle handling (#2345).
Thanks to xet7 !
Fixes#6491.
From ../log/2026-07-20_15-26-39 (4 chromium failures; #6490 rules test
and spec 42 pass):
- Mobile All Boards (#6488): the real 1-per-row rule was
.board-list.mobile-view li { width:100% } (added via the isMiniScreen
class, more specific than the media-query rule) — set it to 50% too so
there are at least 2 icons per row. Seed 12 boards in the test so the
list definitely overflows and the scroll assertion is meaningful.
- Statistics view test: switch to the view through the actual board-view
switcher (js-open-stats-view) instead of pre-seeding profile.boardView,
which was flaky (localStorage vs profile / board-ready timing).
- Rules Workflow test (#6489): assert the palette renders with labelled
chips (the exact bug signature) instead of driving HTML5 drag-and-drop,
which Playwright's dragTo does not fire through Blaze's delegated
handlers here (the Add Rule button stayed disabled).
- attachmentsList: wrap the manual publish so this.ready() always runs
(finally) and drop the server-side name sort (no index; the client
re-sorts) — defensive against the still-unresolved Files-report hang.
Spec 44 exercises the two rules fixes end-to-end:
- #6490 (list view): seeds two rules, views the SECOND and asserts its
details show the second rule's trigger/action (not the first), then
deletes the second and asserts exactly it is removed (Alpha remains) -
client and server side.
- #6489 (workflow view): asserts the trigger/action palette renders (it
rendered nothing before the TAPi18n import fix), then builds a rule by
dragging a trigger + action into the slots and clicking Add Rule, and
asserts the rule appears and is persisted.
The mobile board list could not be scrolled: the CSS forced it
min-height:100vh (plus a phantom ::after), so it grew to fit all content
and, clipped by the overflow:hidden wrapper, boards below the fold were
unreachable. Give it a bounded height + overflow-y:auto so it scrolls
(natively on iOS; via the touch-drag handler elsewhere, which only
engages once content overflows).
Also improve the phone layout: keep the menu on the left but narrow it
(grid-template-columns minmax(84px,30%) 1fr) so the board icons on the
right show at least 2 per row (was 1 per row), on iPhone 12 mini and
similar. Add a phone-viewport Playwright test.
The Delete and View buttons are in the rulesList child template's each,
but their handlers are on the parent rulesMain and used
Template.currentData() — which returned the PARENT data context, not the
clicked rule. So the rule id was null: deleting failed with
'Match error: Expected string, got null', and 'View rule' always opened
the first rule. Add explicit data-rule-id to the buttons and read it in
the handlers so both operate on the rule actually clicked.
rulesWorkflow.js used TAPi18n without importing it, so paletteLabel()
threw 'ReferenceError: TAPi18n is not defined' and the Add Rule click
handler aborted before saving — creating a rule in the Rules -> Workflow
view silently did nothing. Add the missing import.
Move the board Status from a Board Settings sidebar popup into a
full-width board VIEW alongside Swimlanes / Lists / Calendar / Gantt /
Table in the board-view switcher. Named 'Statistics' (Finnish
'Tilastot'), translated to major languages (rest fall back to English).
New statsView template/js/css rendered from boardBody (isViewStats),
selectable via the switcher (js-open-stats-view -> board-view-stats,
added to the profile.boardView schema allowedValues). Reuses the server
boardStatus method so counts are accurate in lazy mode; shows the same
counts + time-spent summary as before. Text is selectable with mouse
(drag) and touch (long-press): the view stops pointer/touch events from
reaching the canvas drag-to-scroll so native selection + copy work.
Removed the old boardStatusPopup (template/handler/helpers/css) and its
Board menu item. Added a Playwright test for the view. Design doc
updated.
Apply the same fix as attachmentsList to the sibling paginated reports
rulesReport, boardsReport, cardsReport and impersonationReport: a
returned sorted+limited cursor makes Meteor set up a LIMITED live
observe that hangs on FerretDB's OpLog, so the report stays stuck on the
loading spinner. Each now fetches its page and the referenced docs and
publishes them with this.added + this.ready (into rules/actions/
triggers/boards, boards/users/team/org, cards/boards/lists/swimlanes/
users, impersonatedUsers/users respectively). No live cursor is needed
since the reports re-subscribe on every page/search change.
Add a Playwright test (spec 41) that opens each report and asserts it
loads (its search input renders once the subscription is ready).
attachmentsList returned a sorted+limited live cursor, so Meteor set up
a LIMITED live observe, which hangs on FerretDB's OpLog for this query:
the subscription never became ready and the Files report was stuck on
the loading spinner forever (spec 41). Publish the page manually (fetch
+ this.added + this.ready) so ready always fires; the report re-
subscribes on paging/search, so it needs no live cursor.
Also fix the two new #6479 Playwright tests: they located the member
avatar via '.membersWidget' which is a Blaze template NAME, not a DOM
class, so it never matched. Use the real sidebar container
'.board-sidebar' instead.
Move the flat feature docs and feature subdirectories under category
parents: Admin-Panel/ (+ Problems/), Board/, Cards/, Editor/, Reports/,
Automation/, Lists/, Troubleshooting/. Every relative markdown link in
docs/ (and the docs GitHub URLs in CHANGELOG.md and the code-span path
in Undo.md) is recomputed so it still resolves to the same target after
the move.
Done with a scripted move + relative-link rewriter that verifies link
integrity: all 160 rewritten links resolve, and a full scan confirms the
reorg introduces zero new broken docs-internal links (the pre-existing
53 unrelated to the move are unchanged).
New 'Board status' item in the board menu opens a larger, mouse-
selectable popup showing this board's card-loading mode (lazy vs all,
and whether chosen automatically), counts (swimlanes, lists, cards,
archived cards, labels, members, custom fields) and a time-spent summary
in the style of the task time reports (total hours, cards with time,
overtime cards).
Counts come from a server boardStatus(boardId) method so they are
accurate even in lazy mode, where minimongo only holds the visible card
window; the time summary fetches only cards with spentTime > 0 so it
stays cheap on big boards. New i18n keys translated to major languages
(rest fall back to English). Design doc at
docs/Features/Board/Sidebar/Status.md.
Move CPU-usage design under docs/Features/Admin-Panel/Problems/ and add
RAM-usage and Disk-usage design docs (siblings of CPU-usage): both log
how much RAM+swap / disk is used and record only the START and END of
each sustained high-usage period to Admin Panel / Problems, mirroring the
CPU-usage monitor's shape (hysteresis tracker, event stream, env vars).
Link them from the Admin-Panel index and update the now-automatic card
loading description. Update the CHANGELOG link to the moved CPU-usage.
Card loading is no longer an admin-configurable toggle. WeKan decides
per board by size ('auto', the default): a board over the threshold
(default 500 cards, CARDS_LOADING_LAZY_THRESHOLD) loads only visible
cards via windows; smaller boards keep loading all cards (unchanged,
fully featured). The server computes the mode per board and publishes it
(boardCardsLoadingMode -> boardCardLoadingModes) so client rendering
agrees; isLazyCards is now per-board.
Lazy windows now publish comments/attachments/checklists/checklistItems
with ONE cursor per window instead of one per card (same N+1 fix as
eager mode), and an open card subscribes to its own live children
(openCardData) so it is complete even when outside/just-added-to a
window. Operators can still force CARDS_LOADING=all|lazy|auto.
Removed the Admin/Features card-loading dropdown; pure decision logic in
models/lib/cardsLoading.js (resolveCardsLoadingMode now defaults to
'auto', plus effectiveBoardCardsMode + threshold) with updated unit
tests. New i18n key cards-loading-auto across locales.
#6479: the existing removal test only checked the DB, which is why the
bug shipped. Add a strict UI test that the removed member's avatar
disappears from the sidebar (plus a negative test that a non-admin is
offered no remove option).
#6480: add spec 42 verifying the board publication still shows a card
comment carrying boardId after the per-card -> board-level cursor
refactor (positive), and that a comment on a different board does not
leak in (negative — proves the board-level cursor stays boardId-scoped).
Removing a member keeps the entry with isActive:false (#5122, for role
history / re-activation), but membersForSidebar still listed inactive
entries, so Remove from board looked like it did nothing - the avatar
stayed. Show only active members (plus imported placeholders pending
reconciliation); removed members disappear from the list immediately.
Removed people still show greyed where they remain referenced (e.g. a
card assignee) via the existing inactive-member avatar styling.
Admin Panel / Problems / CPU usage could stay empty while FerretDB
pegged the machine: both WeKan and FerretDB measured only system-wide
CPU, and FerretDB at 170-300% (2-3 cores) is only ~40-75% system-wide on
a many-core host, below the high threshold.
Read FerretDB's new processCpuPercent from the throttle response and
surface it: high-CPU rows include it, and a new watch (a pure status
read that never slows FerretDB down, so it cannot make a slow board load
worse) records a start/end episode when FerretDB alone monopolises cores
even though system-wide CPU stays moderate. Tunable via
WEKAN_FERRETDB_WATCH_PERCENT / WEKAN_FERRETDB_PROC_HIGH_PERCENT.
Opening a board opened one live CardComments cursor AND one Attachments
cursor PER CARD. On a board with many cards that spun up thousands of
live observers; under FerretDB's poll-and-diff that pinned FerretDB/
SQLite CPU (170-300%) and made boards take minutes to open.
Publish comments and attachments with a single board-level cursor each,
on their denormalized boardId / meta.boardId (exactly like checklists) -
one cursor per collection instead of one per card, still reacting to
newly added cards. Add supporting indexes so the board-level queries are
not full scans: cards {boardId, archived}, cardComments {boardId},
attachments {meta.boardId}.
Lazy mode (boardCardsWindow) intentionally keeps per-visible-card
children (bounded by the window), so it is unchanged.
The paginated admin reports (Files, Rules, Boards, Cards, Impersonation)
only handled the subscription onReady callback, so if a report
publication errored on the server the report was stuck showing the
loading spinner forever, with no feedback. Handle onStop as well:
clear the spinner on stop and, on a real error, log it and surface the
message. This is defensive; it does not change the happy path.
The spec 41 "Files report shows clean filenames" test failed in all browsers
with `E11000 duplicate key error ... _id: "e2e-att-normal"`: it seeded
attachments with fixed _ids but never cleared leftovers from a previous run (or
another browser project sharing the DB), so insertMany hit a duplicate key.
- Clear the fixed attachment _ids with deleteMany({ _id: { $in: [...] } }) before
insertMany, so the seed is idempotent across re-runs and browsers.
- Also fix a latent bug: findCardIdByTitle was called with a string
('FilesCard') but it takes { boardId, title } and is synchronous. With a
string, EJSON drops the undefined fields and it matched an arbitrary card, so
the attachments would land on the wrong card and the report assertions would
fail once seeding succeeded. Now called as
db.findCardIdByTitle({ boardId: board.boardId, title: 'FilesCard' }).
Complete the high-CPU → FerretDB-slow-down → measure → report loop, and make it
robust when WeKan itself is CPU-starved or FerretDB is unresponsive.
- Adaptive feedback (server/lib/cpuMonitor.js): while CPU stays high WeKan now
INCREASES (doubles) the delay FerretDB adds between operations, up to a cap,
until system CPU drops below a headroom target (WEKAN_CPU_TARGET_PERCENT,
default 70) — enough CPU for other processes — then holds. Each escalation, the
headroom result, and the "even max delay not enough (load is elsewhere)" case
are logged.
- FerretDB self-regulates too: WeKan reads the throttle response's autoSlowDownMs,
hostCpuPercent and operationsSummary (what FerretDB has been doing) and logs
them — so the feature works even if WeKan is too starved to drive the loop.
- No flooding / no hammering (server/lib/ferretdbGovernor.js): every call has a
timeout (WEKAN_FERRETDB_TIMEOUT_MS); WeKan talks to FerretDB at most once per
WEKAN_FERRETDB_ASK_INTERVAL_MS (30s); a non-answer starts a cooldown
(WEKAN_FERRETDB_COOLDOWN_MS) instead of retrying. WeKan records WHEN FerretDB
became unresponsive and, on recovery, logs the full start -> end span plus
FerretDB's status and operations summary. "command not found" (plain MongoDB)
is still treated as permanently unsupported.
- i18n: cpuReportTitle ("CPU usage") translated to all 154 languages.
Guard tests (tests/cpuMonitorWiring.test.cjs) extended for escalation, rate-limit,
timeout/cooldown, outage span, and FerretDB self-regulation/summary. Docs
(docs/Features/CPU-usage/CPU-usage.md) and CHANGELOG updated.
FerretDB de-branded its custom command to the general name `throttle` (it is a
general-purpose MongoDB replacement, usable by any MongoDB-wire client, not only
WeKan). Update WeKan's caller to match: server/lib/ferretdbGovernor.js now sends
{ throttle: 1, ... } and the comments/guard test refer to the `throttle` command.
No behaviour change. WeKan-side names (ferretdbGovernor, slowDownFerretDb) are
WeKan's own and unchanged.
FerretDB is the other big CPU user on the same host. When WeKan's CPU monitor
detects a sustained high-CPU period it now asks FerretDB (via the custom
wekanThrottle command added to the bundled wekan/FerretDB v1 fork) what it is
doing and to slow down, and writes it to Admin Panel / Problems / CPU usage.
- server/lib/ferretdbGovernor.js: runs the wekanThrottle command over the raw
Mongo driver. slowDownFerretDb(slowDownMs, durationMs) asks FerretDB to pause a
few ms before each command for a short, self-expiring window and returns its
activity (commandsProcessed); resumeFerretDb() turns it off. Best-effort — on
plain MongoDB or an older FerretDB the command fails once and is never retried.
- server/lib/cpuMonitor.js: on 'start' asks FerretDB to slow down and logs a row
with FerretDB's reported activity and what it was asked; renews the
self-expiring throttle each sample while CPU stays high; on 'end' asks FerretDB
to resume and notes it. Tunable via WEKAN_FERRETDB_SLOWDOWN_MS.
Guard tests (cpuMonitorWiring) extended for the FerretDB governor. CHANGELOG +
docs/Features/CPU-usage/CPU-usage.md updated.
When CPU is high, the report now also records what automatic mitigation was
taken and whether it noticeably lowered CPU:
- Once the governor actually pauses a governed operation during a high-CPU
period, write ONE 'rate-limited' row: slowing down "<activity>" — pausing
200ms between steps to yield the CPU (also lowers FerretDB query load), with
the CPU% at slow-down start.
- Track the lowest CPU% seen after slowing down; the 'end' row reports the
effect: "slowed down <activity> (paused N times, Xs total). After slowing
down, CPU went A% -> B% — noticeably lower (about -Z points)" (or "not
noticeably lower"). When nothing was running to slow down, it says so.
Still at most three rows per episode (start, mitigation, end). The effect is a
correlation over the sampling window, reported as observed.
Also rename the design doc to docs/Features/CPU-usage/CPU-usage.md (CHANGELOG
link updated). Guard test extended for the mitigation logging.
WeKan and FerretDB on one machine can drive CPU high enough to starve other
software. Add a monitor, a governor, and a report. Design: docs/Features/CPU/CPU.md.
Monitor (server/lib/cpuMonitor.js): samples system-wide CPU% from os.cpus()
idle/total deltas on an interval (so it includes WeKan, FerretDB and every other
process). A pure, hysteresis state machine (models/lib/cpuHighTracker.js) turns
the samples into ONLY the START and END of each sustained high-CPU period — two
rows per episode, never a flood. Enters high after 3 consecutive samples >= 85%,
leaves after 3 consecutive < 70%. Each row carries duration, peak, system load,
and what WeKan was doing (setActivity label). Recorded to a new 'cpu' event
stream (server/lib/cpuLog.js).
Governor: cpuMonitor exports isHighCpu() and pauseIfBusy(), so long batch
operations await a short pause and yield the CPU only while the system is busy
(no cost when idle). Wired into the existing-file extension corrector. Tunable
via WEKAN_CPU_* env vars.
Report: 'cpu' added to EVENT_STREAMS; reuses the generic eventStreamReport
(stream="cpu") with search + pagination; a "CPU usage" item is added to the
Admin Panel / Problems side menu (cpuReportTitle i18n).
Tests: cpuHighTracker unit (start/end, duration/peak, no-flap hysteresis;
positive + negative) and cpuMonitorWiring source guards. CHANGELOG + design doc.
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.
Made the Files-report filename rendering a GENERAL, reusable component and used it
everywhere filenames are shown:
- New client/components/main/safeFilename.{jade,js,css}: +safeFilename(name=...)
URL-decodes the name, shows a red warning triangle when it hides invisible
characters, and replaces each invisible character inline with its red Unicode
name (all escaped plain text — never markdown/HTML). Plus two global helpers:
{{filenamePlain name}} (decoded name, invisible chars named — for title="" / JS
text) and {{downloadFilename name}} (decoded + invisible chars REMOVED).
- Used it in the Admin Panel → Files report and the card attachments (visible
name via +safeFilename; thumbnail title via filenamePlain; the download="" attr
via downloadFilename; the attachment viewer title via fileNamePlain()).
- Downloads now save a CLEAN name: the Content-Disposition sanitiser in both
server/routes/universalFileServer.js and legacyAttachments.js runs the name
through sanitizeDownloadFileName (URL-decode + remove all invisible/format/bidi
characters), never empty (falls back to "download").
New fileNamePlain()/sanitizeDownloadFileName() in imports/lib/fileNameDisplay.js;
unit + negative tests in tests/fileNameDisplay.test.cjs.
Thanks to xet7 !
Instead of colouring the entire filename red, the Files report now:
- shows a red warning triangle (fa-exclamation-triangle) to the left of any name
that hides invisible characters; and
- replaces each invisible character IN PLACE with its red Unicode description,
e.g. "evil[U+200B ZERO WIDTH SPACE].png" — the visible parts keep the normal
text colour.
New fileNameSegments()/describeInvisibleChar() in imports/lib/fileNameDisplay.js
split the (decoded) name into visible runs + one named segment per invisible
character (bracketed "U+XXXX NAME", falling back to the code point for unnamed C1
controls). Everything is still rendered as escaped plain text (Blaze `{{ }}`) —
never markdown/HTML. Unit + negative tests in tests/fileNameDisplay.test.cjs and
the Playwright spec updated to assert the warning + inline names.
Thanks to xet7 !
New spec tests/playwright/specs/41-admin-newest-features.e2e.js:
- Files report: seeds attachments (normal, URL-encoded "%D0%93%D1%80" -> "Гр", and
one hiding a zero-width space) and asserts the encoded name is DECODED, the
invisible-char name is flagged red (.filename-invisible), the invisible-only
filter lists only it (and a NEGATIVE toggle-off brings the others back), there is
NO Search button, and the search field + pagination controls + legend are shown.
- Version (Information) page: shows the live Reactivity mode plus the configured
METEOR_REACTIVITY_ORDER and DDP_TRANSPORT rows.
- NEGATIVE: the board Table view has no clickable-sortable headers (removed).
Thanks to xet7 !
The archived-boards list published EVERY archived board the user can delete at
once (unbounded, grows on long-lived instances). It now pages server-side
(searchTerm + limit/skip, 30 per page) with a getArchivedBoardsCount method, and
the template gained a search field + prev/next controls in one row (matching the
Admin report tables). Only the current page is ever loaded into the browser.
Guarded in tests/adminReportsPagination.test.cjs.
Thanks to xet7 !
Per request, clickable column-header sorting is removed everywhere it existed:
- Board Table view: the sortable `th.js-table-view-sort` headers become plain
headers; the sortField/sortDirection state, the sort click handler and the
sortIndicator arrow are gone. The table now always shows a stable order (card
title ascending). Search + pagination are unchanged.
- Admin Panel → People → Domains: same removal; the getDomainsWithUserCountsPage
method no longer takes sortField/sortDirection and returns a fixed domain-
ascending order (paginateDomains' default). Server-side search + pagination stay.
Dead sort-header CSS removed. Guarded in tests/adminReportsPagination.test.cjs.
Thanks to xet7 !
Translated filter-invisible-filenames and filename-invisible-legend (the
invisible-only filter button + the red legend) to all 153 languages.
Thanks to translators and xet7 !
The Translation report subscribed with a hardcoded limit of 0, which Mongo/
FerretDB treat as "no limit", so the page loaded EVERY translation string across
all languages into the browser at once. It now passes the infinite-scroll window
(page * 25), so it loads a page at a time and grows as you scroll — the same
bounded pattern the activity feed uses. Guarded in
tests/adminReportsPagination.test.cjs.
Thanks to xet7 !
WeKan buttons hardcoded their colors, so a Member Settings → Change color theme
override never reached most of them (customTheme.css only themed
`button[type=submit].primary`, not plain `button.primary`, and the global button
base was solid black). Now the global button base and the primary buttons in
client/components/forms/forms.css use `var(--theme-accent, <original>)`, and the
admin action buttons (settingBody.css) and People-panel buttons (peopleBody.css,
incl. ext-box / add-remove-team / unlock-all) do the same. The fallback keeps the
default look identical, but once the user picks a theme accent, the buttons across
the app follow it.
Guarded in tests/buttonThemeColors.test.cjs.
Thanks to xet7 !
At Admin Panel → Problems → Files report:
- Percent-encoded filenames (e.g. "%D0%93%D1%80" -> "Гр") are URL-decoded for
display when they decode cleanly; malformed/partial encodings are left as-is.
- Filenames are always shown as PLAIN TEXT — Blaze `{{ }}` HTML-escapes, so a
name is never rendered as markdown or HTML.
- A filename hiding invisible / control / bidi / zero-width characters (after
decoding) is shown in RED. A translated legend on the right of the pagination
controls explains that red = invisible characters.
- A board-view-style filter toggle lists ONLY filenames containing invisible
characters (server-side, so it works across the whole paginated set); clicking
it again removes the filter.
New isomorphic helper imports/lib/fileNameDisplay.js (decodeFileNameSafe,
hasInvisibleChars, INVISIBLE_CHARS_SOURCE) is used by both the client (display +
red flag) and the server (the invisibleOnly query on attachmentsList /
getAttachmentsReportCount). Unit + source-guard tests in
tests/fileNameDisplay.test.cjs. English strings added; translations follow.
Thanks to xet7 !
Across the Admin Panel → Problems report tables (Files/Rules/Boards/Cards/
Impersonation) and the People/Org/Team/Domain tables:
- Removed the redundant "Search" button — typing in the field + Enter already
searches (the keydown handler was already there); dropped the now-dead
js-*-search-button click handlers.
- Put the pagination controls on the RIGHT of the search field, in one row
(margin-inline-start:auto, so it mirrors correctly in RTL), matching the
Security/Speed/Tests event table which already had this layout.
- Made the pagination controls follow the current theme: they now use
var(--theme-accent, #01628c) instead of hardcoded blue/grey/white, so they
honour Member Settings → Change color (which sets --theme-accent on :root) and
fall back to WeKan blue when no custom theme is chosen. The event-stream
pagination links are themed too.
Guarded in tests/adminReportsPagination.test.cjs.
Thanks to xet7 !
The Admin Panel → Problems → Cards report already paginates (limit/skip), but it
sorted by { boardId:1, sort:1 } which has NO index, so every page load full-sorted
all cards in memory — the spinner on a site with 11761 cards. Switched both the
cardsReport publication and the client mirror sort to { boardId:1, createdAt:-1 },
the existing index (server/models/cards.js), so one page is a bounded index scan.
Also added a { stream:1, at:-1 } index on the eventlog collection (and { stream:1 }
on eventlogAcks) so the Security/Speed/Tests report pages stay an index scan as the
log grows, instead of a full scan + in-memory sort.
Guarded in tests/adminReportsPagination.test.cjs.
Thanks to xet7 !
The Admin Panel → Problems detail methods eventLogCount/eventLogPage awaited
requireAdmin() BEFORE check()ing their arguments. When requireAdmin throws (a
non-admin call, or before admin status resolves on the client), Meteor's
audit-argument-checks runs and throws "Did not check() all arguments during call
to '<method>'", masking the real error and spamming the server log. Moved the
check() calls to the top of both methods, before any await/throw — matching
acknowledgeEventLog, which was already correct. Guarded in
tests/securityLog.test.cjs (check() must precede requireAdmin in every
argument-taking method).
Thanks to xet7 !
Sandstorm defaults to FerretDB but its grain launcher (sandstorm-src/start.js) set
neither --repl-set-name nor MONGO_OPLOG_URL / METEOR_REACTIVITY_ORDER, so it stayed
on poll-and-diff. Now the STEADY-STATE FerretDB is started with --repl-set-name=rs0
and WeKan is pointed at it with METEOR_REACTIVITY_ORDER=oplog,polling (OpLog only
when tailing works, polling fallback). The transient migration-target FerretDB stays
OpLog-free so bulk inserts are not slowed. WEKAN_FERRETDB_OPLOG=false forces polling.
This makes every platform that defaults to FerretDB — Snap, Sandstorm, Docker and
the prebuilt bundle — behave the same. Guarded in tests/ferretdbOplog.test.cjs.
Thanks to uusijani, Nissulya and xet7 !
Fixes#6480,
Fixes#6481.
The FerretDB OpLog work added two Admin Panel → Version row labels
(Reactivity_order, DDP_transport). Translated both to all 153 languages, keeping
the environment-variable names METEOR_REACTIVITY_ORDER and DDP_TRANSPORT literal
inside the parentheses in every language (including RTL and non-Latin scripts).
Thanks to translators and xet7 !
Below the live "Reactivity mode" (oplog / changeStreams / polling — what is
actually running) and "MongoDB Oplog enabled" rows, the Version page now also
shows what was REQUESTED via the environment: the reactivity driver priority
order (METEOR_REACTIVITY_ORDER, falling back to DEFAULT_METEOR_REACTIVITY_ORDER)
and the DDP transport (DDP_TRANSPORT). Together they let an admin see at a glance
"I asked for oplog,polling and it is actually running oplog" vs "it fell back to
polling". The values are returned by the existing getStatistics() server method
(reading process.env), not exposed as public settings. Empty env shows '(unset)'.
New i18n keys Reactivity_order and DDP_transport; guard in tests/ferretdbOplog.test.cjs.
Thanks to xet7 !
Follow-up to enabling the FerretDB OpLog by default (#6480, #6481). Setting
MONGO_OPLOG_URL alone can make Meteor fail to start if OpLog tailing does not
actually work (the same way WeKan on Windows with MongoDB falls back to polling).
Fix: every FerretDB launch path now also sets
METEOR_REACTIVITY_ORDER=oplog,polling — Meteor tries the drivers left-to-right and
uses OpLog ONLY when tailing works, otherwise polling, so a broken/absent OpLog
never blocks startup. Previously only docker-compose set this; the snap
(wekan-control), the bundle (start-wekan.sh/.bat) and the docker entrypoint
(wekan-entrypoint.sh) set MONGO_OPLOG_URL WITHOUT the polling fallback. The
kill-switch path (WEKAN_FERRETDB_OPLOG=false) now selects polling-only.
Admin Panel / Version already reports which driver is actually live: server/
statistics.js derives "Reactivity mode" (oplog / changeStreams / polling) and
"MongoDB Oplog enabled" from Meteor's live _oplogHandle, so it shows `polling`
whenever OpLog did not come up — the direct way to confirm whether FerretDB is
on OpLog or polling.
Tests: tests/ferretdbOplog.test.cjs now asserts the oplog,polling fallback in
every launch path, the polling-only kill-switch path, and that the Version page
detects + displays the live reactivity driver.
Thanks to uusijani, Nissulya and xet7 !
Fixes#6480,
Fixes#6481.
With FerretDB there is no MongoDB oplog, so Meteor observed every live query by
poll-and-diff, pinning FerretDB CPU at 100-390% on busy boards (#6480, #6481).
FerretDB v1 now ships an OpLog (wekan/FerretDB: auto-created capped local.oplog.rs
+ replica-set hello handshake), so every FerretDB launch path now:
- starts ferretdb with --repl-set-name=rs0 (snap ferretdb-control, docker
entrypoint + docker-compose service, bundle start-wekan.sh/.bat), which makes
FerretDB auto-create the OpLog and advertise the replica set; and
- points WeKan at it with MONGO_OPLOG_URL=.../local?replicaSet=rs0 (docker-compose
also switches METEOR_REACTIVITY_ORDER to oplog,polling), so Meteor TAILS the
OpLog instead of polling.
Enabled by default with a kill-switch: WEKAN_FERRETDB_OPLOG=false forces the old
polling-only behaviour. The calmer polling defaults remain as the fallback for
queries Meteor cannot observe via the OpLog. The transient migration ferretdb
(migration-control) deliberately stays oplog-free so bulk inserts are not slowed.
Also cut the client activity feed page from 500 to 50 rows: 500 was far more than
the sidebar/card feed shows and, under polling, was re-fetched and re-diffed on
every poll; infinite scroll still loads more on demand (the server cursor is
index-bounded either way).
Tests: tests/ferretdbOplog.test.cjs source-guards every launch path (repl-set arg,
MONGO_OPLOG_URL, kill-switch) plus the activity page size, and a negative test that
migration-control stays oplog-free.
Thanks to uusijani, Nissulya and xet7 !
Fixes#6480,
Fixes#6481.
#6481 (Nissulya): after MongoDB 3.x -> FerretDB v1 migration, cards with a
non-finite `sort` (±Infinity / NaN, written by old WeKan before #6472) failed to
insert: FerretDB/SQLite rejects non-finite doubles ("invalid value { sort: +Inf }
(infinity values are not allowed)"), aborting the document and leaving boards
stuck on the loading spinner. sanitizeNonFinite() only made the bare mongoexport
tokens PARSE; the parsed value was still a JS Infinity/NaN. Added clampNonFinite()
in the migration tool to reset every non-finite number to a finite 0 before
insert, and a `nonfinite-sort-repair` startup schema-upgrade step that heals the
same corruption in an already-running database (MongoDB or FerretDB), using a
predicate built from finite literals only so it is safe on FerretDB too.
#6481 (Nissulya): LDAP `mail` -> `email` fieldmap returned `undefined` (worked in
6.09). getDataToSyncUserData's email branch read the mapped attribute
case-SENSITIVELY (ldapUser.hasOwnProperty / ldapUser[field]) while the rest of
wekan-ldap uses the case-insensitive getLDAPValue(); a directory returning the
attribute with a different case (exposed by ldapjs -> ldapts, which preserves the
server's case) missed it. The email branch (and the addLdapUser fallback) now
read via getLDAPValue().
#6480 (uusijani): board/CSV export paused on socket backpressure with
`res.once('drain', resolve); res.once('error', reject)`, which leaked one 'error'
listener per backpressured write ("MaxListenersExceededWarning: 11 error
listeners added to [ServerResponse]"). writeWithBackpressure() now removes both
listeners once either settles.
Added tests and negative tests for all three fixes.
Thanks to Nissulya, uusijani and xet7 !
Fixes#6480,
Fixes#6481.
Fix the intermittent "Unexpected userId after login" e2e flake (seen in
33-board-domains, which re-logs-in on the same page: admin then non-admin).
Meteor.userId() can lag the Meteor.loginWithToken callback by a tick, so reading
it immediately sometimes returned the PREVIOUS userId. Poll Meteor.userId() until
it settles on the expected id (5s timeout so a genuine mismatch still surfaces).
Pure test-helper change; the direct-Mongo-seed + resume-token login path this
uses does not go through createUser, so it is unaffected by the session security
changes.
Add server/lib/selfChecks.js: at startup (and via an admin-only runSelfChecks
method) run cheap invariants a unit test would assert (database round-trip,
WRITABLE_PATH/files writable) and record any failure to the Tests stream
(Admin Panel -> Problems -> Tests). WeKan does NOT require Playwright: only
failures detectable without a browser are logged, and no runtime code imports
Playwright (asserted by the test). Wired into server/imports.js; design doc
updated.
Add server/lib/speedMiddleware.js: a WebApp middleware that times non-asset HTTP
requests and records those slower than WEKAN_SLOW_REQUEST_MS (default 2000ms; 0
disables) to the speed stream via speedLog. Best-effort, never throws; wired into
server/imports.js. Source-guard test added.
Record more runtime rejections into the eventlog (Admin Panel Problems):
- fileValidation.js: log every upload rejection (dangerous MIME content ->
xss.mime, mime not on the allow-list -> file.mime, over size limit ->
file.size).
- metrics.js: log spoofing.xff when a /metrics request carrying an
X-Forwarded-For header is denied (likely forged-whitelisted-IP attempt).
- export.js: log authz.export at all four export-authorization denial paths.
All calls are best-effort/guarded (server-only require, try/catch) so logging
never breaks the guard. Source-guard tests added.
Translate problems, summary, securityReportTitle, speedReportTitle,
testsReportTitle, new-problems, acknowledge, no-new-problems,
problems-summary-help and the event-* table headers into all 153 non-English
languages (en is the source). All files validated as JSON.
- 2nd header bar: add a Problems button (warning icon) to the RIGHT of the Info
(version) button; it turns red when there are new problems (settingHeader.js
polls eventLogProblemAreas). Remove the old Reports button; the Admin Panel
still opens on Settings.
- The former Reports page is now the Problems page: left menu Summary / Security /
Speed / Tests, then a separator, then the moved Broken Cards/Files/Rules/Boards/
Cards/Impersonation items.
- Summary (problemsSummary, renamed from the removed top banner): the checkbox
list + one Acknowledge button — the ONLY place to acknowledge.
- Security/Speed/Tests: read-only, paginated, searchable event tables via new
admin-only eventLogPage/eventLogCount methods; acknowledgeEventLog now takes a
stream or array.
- i18n keys + CSS + source-guard tests + design doc updated.
Change the top problem banner from a per-area button to a checkbox list of
problem areas with a single Acknowledge button that acknowledges every checked
area in one call. acknowledgeEventLog now accepts a stream or an array of
streams. Acknowledging happens ONLY at the top banner; the Reports pages are
read-only (noted in the design doc).
Like the Announcement banner, the Admin Panel top now shows, for each problem
area with unacknowledged events, its menu path (Admin Panel -> Reports ->
Security/Speed/Tests) and the count of NEW problems. Each area has an Acknowledge
button that marks the newest problems seen, zeroing the count and removing the
info from the top.
- models/eventLog.js: add the eventlogAcks collection and admin-only methods
eventLogProblemAreas() (count of events newer than each stream ack) and
acknowledgeEventLog(stream) (upsert ack = now).
- client adminProblemBanner.{jade,js}: poll the counts, render path/count/button,
acknowledge on click; included in settingHeaderBar (top of every admin page).
- i18n keys (securityReportTitle/speedReportTitle/testsReportTitle/new-problems/
acknowledge) + banner CSS. Source-guard tests added.
Per correction: use only the existing WeKan database via Meteor JavaScript
queries (works on FerretDB and MongoDB); do not create files/DBs under
WRITABLE_PATH; and have FerretDB report problems to WeKan rather than writing
itself.
- models/eventLog.js: one `eventlog` collection (stream: security|speed|tests).
- server/lib/{securityLog,speedLog,testLog}.js: thin wrappers that insert one
document via EventLog.insertAsync (fire-and-forget, never throw).
- models/lib/securityLogFormat.js slimmed to just sanitizeDetail; remove the
node:sqlite event store (server/lib/eventStore.js) and all file/path/schema
helpers.
- Add a Tests stream: log anything that would fail an existing WeKan test.
- Design docs (WeKan.md, FerretDB.md) rewritten: DB-collection storage, grouped
counts for the summary, Admin Reports read the collection, and FerretDB reports
problems to WeKan which records them (no FerretDB DB/file writes).
- tests/securityLog.test.cjs updated (sanitizer + catalog + collection wiring +
no-filesystem guards); verified via Python mirror.
- Avatar localization redirect SSRF: fetch avatars via fetchSafe (DNS-pinned,
redirects rejected) instead of native redirect:follow, and validate
profile.avatarUrl scheme (http/https/data:image/local) at write time.
- Trello import source.url stored XSS: only render/store an http(s) activity
source URL, so a javascript: board url can no longer execute on click.
- Trello LIVE import readable SSRF: gate every attachment/background/avatar
download with validateAttachmentUrl, matching the offline import path.
- CAS login global user-data race: store validated CAS user data per credential
token instead of a module-global _userData (fixes account-takeover race).
- Metrics X-Forwarded-For whitelist bypass: only trust XFF behind
METRICS_TRUST_PROXY (parsed spoof-resistantly from the right); else use the
socket peer address.
- Stale impersonation export bypass: remove the "|| impersonateDone" export
authorization bypass; require real board visibility (canExport) only.
- Invitation code brute force: generate invitation codes with a 128-bit CSPRNG
(crypto.randomBytes) instead of a 6-digit Math.random value, and rate-limit
createUser (DDPRateLimiter).
The OIDC shared serviceData race from the same report was already fixed (#4897,
the per-login objects are callout-local). Source-guard regression test added
(tests/securityMeifukun.test.cjs) and wired into test:unit:node.
Thanks to meifukun and xet7 !
Wire in three new publish jobs (dormant until their secrets are set — each
guards on its secrets and skips with a ::notice:: so ordinary releases are
unaffected):
- snap-variants: build+publish wekan-ondra and wekan-gantt-gpl snaps.
- ucs: bump + push the wekan/univention app and upload to the App Provider.
- nextcloud: build the ExApp image and publish to the Nextcloud App Store.
Document the new secrets in the header. Add a "Remaining steps" section to the
top of Snap-Ondra-Gantt.md, UCS.md and Nextcloud.md (keys to add + how).
The 29 per-OS manual-vs-automatic update comparison docs move into OS/;
the WeKan-specific docs (README, Snap, Snap-Ondra-Gantt, UCS) stay in the
parent. All cross-links repointed and verified.
Add manual-vs-automatic (Snap-like) update comparison docs for all operating
systems with any Internet update mechanism: Linux, BSD, Haiku, Amiga, RISC OS,
Solaris/illumos, AIX, HP-UX, OpenVMS, z/OS, ArcaOS, ReactOS, FreeDOS, Plan 9,
Redox, SerenityOS, Android, iOS, ChromeOS, HarmonyOS, Ubuntu Touch, Sailfish,
postmarketOS, KaiOS, Tizen, webOS and TV/streaming OSes; plus a README index.
Add Snap-Ondra-Gantt.md (keys/secrets + release-all.yml changes to also release
the wekan-ondra and wekan-gantt-gpl snaps). Rename the two earlier dev docs to
Snap.md / Windows.md and repoint all links.
- i18n: registering generic Chinese (zh) as its own entry made bare "zh"
resolve to itself, so the "zh -> zh-Hans" alias is now dead code — remove it
from LANGUAGE_ALIASES (keep wuu -> wuu-Hans) and update the two mocha tests
(resolveTag/setLanguage) to expect "zh" instead of "zh-Hans".
- e2e (15-board-actions): the Board Settings cog was removed from the header
(it is in the right sidebar now), so the "one click from the header cog" test
failed in every browser. Rewrite it to open the sidebar and open the board
menu (which still has Archived Items) from there.
The changes committed since the v10.00 release bump had no CHANGELOG Upcoming
section. Add one above v10.00 covering all of them: the "Submit editors with
Enter" Member Setting, the right-sidebar drag-to-resize width feature, the #6481
migration NaN/Infinity fix, the #6465 Add List inline-composer options restore,
the right-sidebar width/inputs fix, and the missing copy-selection key — each
with commit links.
Add the sidebar resize handle tooltip (drag-to-resize-sidebar, "Drag to resize
sidebar") to every language file, translated into each language (English-variant
locales carry the English text). All 154 files JSON-validated; value-only
additions.
Add a resize handle on the sidebar edge, like resizing a spreadsheet column
(mirrors the list resize in client/components/lists/list.js). Drag it to change
the sidebar width; the width is saved to profile.sidebarWidth for logged-in
users and to browser localStorage (wekan-sidebar-width) for anonymous users on
a public board, then re-applied on load.
- The handle uses the LOGICAL inset-inline-start, so in LTR it is the left edge
of the right-docked sidebar and in RTL it is the RIGHT edge of the left-docked
sidebar; the drag direction inverts for RTL so dragging always widens/narrows
the visible panel correctly.
- Desktop only: on phones (<=800px) the handle is hidden and the sidebar stays
full width (the 100vw rule is now !important so a desktop inline width can
never leak into mobile).
- Width is clamped (min 280px, capped near the viewport) and re-applied on
window resize so crossing the mobile breakpoint clears/restores it.
New profile.sidebarWidth schema field + getSidebarWidth/setSidebarWidth helpers,
setSidebarWidth Meteor method, i18n (en, fi), and source-guard test
tests/sidebarResize.test.cjs.
Thanks to xet7 !
The Multi-Selection right-sidebar "Copy selection" button used {{_ (quote)copy-selection(quote)}}
but the key was missing from every language file — including the en source —
so i18next had no value to fall back to and rendered the raw key. Add
copy-selection ("Copy selection") to en and translate it to all languages,
mirroring each language(apos)s existing "move-selection" sibling with the verb changed
to copy. All 154 files JSON-validated; value-only additions.
Thanks to xet7 !
- .board-sidebar used width: 30vw, which scaled with the viewport: on very wide
screens it grew to ~double the intended width (it looked right only at mid
widths). Use width: clamp(320px, 30vw, 560px) so it keeps the mid-width look
but is capped (and floored) instead of scaling without bound. Phones (<800px)
still get the full-width 100vw rule.
- The sidebar filter text inputs (Filter List by Title, Filter by card title,
advanced filter) had no width, so they rendered at the browser default narrow
width with a big empty gap on the right. Sidebar text/search inputs now
width: 100% + box-sizing: border-box, so they fill the padded content area
with equal empty space on both sides (the .sidebar-content 12px padding).
Source-guard test tests/sidebarWidth.test.cjs.
Thanks to xet7 !
The #6465 per-list-header inline Add List composer (addListInline) had been
reduced to just a title input, dropping the options the old addListForm had.
Restore them:
- the "Add after which list" position <select>, listing the swimlane lists and
pre-selecting the list whose header button opened the composer; submit reads
it for afterListId and derives nextListId from the following option so the new
list is inserted between them (falls back to the header target for the empty
board case).
- the "or template" link (js-list-template) to create a list from a template.
Helpers swimlaneLists()/isDefaultAfterList() added; createListAfter already
accepts nextListId. Source-guard test tests/addListComposerOptions.test.cjs.
Thanks to csonkaoszimt and xet7 !
Fixes#6465.
Add the two new Member Settings keys (submit-on-enter, submit-on-enter-
description) to every language file in imports/i18n/data/, translated into each
language and inserted after show-desktop-drag-handles. English-variant locales
(en-GB, en_AU, ...) carry the English text; each real language matches its own
terminology, and the keyboard-key tokens Enter, Shift+Enter and Ctrl/Cmd+Enter
are kept verbatim. All 154 files JSON-validated; value-only additions.
#4236 made every multi-line editor submit only on Ctrl/Cmd+Enter (plain Enter
= newline) so titles could be multi-line, but that removed the fast
Enter-to-save title workflow some users rely on (v10 email feedback). Add a
per-user preference in the Member Settings (Change Settings) popup, saved to
profile.submitOnEnter and OFF by default: when on, plain Enter submits the card
title, description and other inlined forms, and Shift+Enter inserts a newline;
when off, behaviour is unchanged (Ctrl/Cmd+Enter saves).
The rule stays in the single pure isSubmitKey(event, {submitOnEnter}); the card
title, the card-description override and the shared client/lib/inlinedform.js
all route through it and read the preference from the current user. New toggle
+ helper + toggleSubmitOnEnter method + schema field, i18n (en, fi), and
editorSubmitKey.test.cjs extended with positive + negative opt-in cases.
Thanks to xet7 !
Related to #4236.
mongo 3.x mongoexport emits non-finite doubles as the bare tokens NaN,
Infinity, +Infinity and -Infinity, which are not valid JSON, so EJSON.parse
threw ("Unexpected token N/+") and migrate-mongo3-to-ferretdb.mjs dropped the
WHOLE document — leaving boards/cards missing after an otherwise "successful"
migration. sanitizeNonFinite() now rewrites those bare tokens, only outside
string literals, to the canonical EJSON v2 double form {"$numberDouble":"..."}
that EJSON.parse accepts, so the document migrates instead of being lost.
Strings that merely contain "NaN"/"Infinity" and ordinary negative numbers are
left untouched. Test tests/migrateNonFinite.test.cjs (with negative cases).
Thanks to Nissulya and xet7 !
Fixes#6481.
Rewrite the v10.00 section to match the final implementation and group ALL 101
commits in the release under their own headings (each with commit links):
- Corrected "done differently" cases: Change Color is visible grouped swatches
(not the initial two-level dropdowns); Font is font-name + size buttons with
immediate apply (not dropdowns); the board-header "icons only" change was
reverted so button texts stay.
- Fixed the Home board entry: it is set via the All Boards Multi-Selection
"Selected:" home action and the after-login redirect runs on all deployments,
so it is available, NOT disabled and NOT Sandstorm-only (an earlier draft
wrongly said the UI was disabled).
- Every commit since v9.99 is now listed once, grouped by feature/fix area.
Rename .github/workflows/playwright.yml -> playwright.yml.disabled so GitHub
Actions no longer runs it (only .yml/.yaml files in .github/workflows are
executed). Reversible by renaming back. NOTE: this workflow also contained the
Meteor unit-tests job and a Puppeteer regression job, so those stop running too.
The script lives in releases/translations/, so the repo root is two levels up
(../..), not one. With a single .. it landed in releases/ and looked for ../tx
at wekan/tx instead of the sibling ../tx, aborting with "tx not found". Match
the cd "$(dirname "$0")/../.." pattern used by the other scripts here.
The .tx config maps Transifex "war" -> local wa-RR.i18n.json, i.e. wa-RR is
Waray/Winaray (a Philippine language), NOT a regional variant of Walloon (wa).
An earlier seed wrongly copied Walloon content into it. Retranslate the file
fresh from English into Waray (2191 strings); placeholders/brand names
preserved, key set unchanged, JSON validated.
- Register Wu Chinese (wuu-Hans): it had a data file but no languages.js
entry, so it was neither browser-detectable nor shown in Change Language.
- Add wuu -> wuu-Hans to LANGUAGE_ALIASES so a bare "wuu" browser locale
resolves (mirrors zh -> zh-Hans).
- Fix display names that were in the wrong script for their (Latin) content:
Wolof "ولوفل" -> "Wolof"; Malay "بهاس ملايو" -> "Bahasa Melayu" (+ Malaysia).
km_KH/ru_RU are symlinks to km-KH/ru-RU, already covered. Every data file is
now registered, so all are browser-detectable and visible in the popup.
Fresh translations: vi, uz, uz-AR (Arabic-script Uzbek), ve (Venda), ve-CC
(Venetian), ve-PP (Vepsian), vo (Volapuk), wa (Walloon), wo (Wolof), xh
(Xhosa), yi (Yiddish), yo (Yoruba), zgh (Tamazight/Tifinagh), zu (Zulu).
Seeded variants uz-UZ/uz-LA, vi-VN, wa-RR, zu-ZA from bases. Completes the
full 153-language set. Placeholders/brand names preserved; JSON validated.
zh.i18n.json existed but had no entry in the languages registry, so a browser
reporting bare "zh" could not auto-detect it and it was missing from the Change
Language list. Add it (code zh, name 中文). The other two unregistered data
files (km_KH, ru_RU) are underscore duplicates already covered by the existing
km-KH / ru-RU entries, whose tag is the underscore form.
Seed Mandarin/Wu/Cantonese (cmn, wuu-Hans, yue_CN) from zh-CN, Japanese-Hiragana
locale (ja-HI) from ja, West Flemish (vl-SS) from nl, and Moroccan Arabic (ary)
from ar as machine-quality baselines sharing the written form, for the community
to refine. Placeholders preserved; JSON validated. 4074 strings.
Fill previously-untranslated UI strings in de, fr, es, it, nl, ru, pl, uk, cs,
ja, ko, zh-CN, tr, ar, pt. Each language matches its existing file terminology
as a glossary plus standard Kanban terms; product/brand names, acronyms, env
vars, console commands, placeholders (__var__, %s, {{..}}) and markup preserved
verbatim. Value-only changes; every file JSON-validated.
de 524, fr 617, es 917, it 938, nl 458, ru 974, pl 1037, uk 895, cs 1108,
ja 926, ko 850, zh-CN 929, tr 1059, ar 1145, pt 500 strings.
Fill previously-untranslated UI strings in the five near-complete languages,
matching each file existing terminology as a glossary and standard Kanban
terms (board, list, card, swimlane, label, checklist, theme, font, ...).
Product/brand names, acronyms, env vars, placeholders (__var__, %s, {{..}})
and markup preserved verbatim. Value-only changes; JSON validated.
zh-TW 29, pt-BR 28, da 159, he 262, sv 382 strings.
Pushes the English source, then force-pushes every language translation to
Transifex in one run (../tx push -s; ../tx push -t -f). -f is required or the
Go tx CLI skips languages whose local file is not newer than the server copy.
Warns that it overwrites newer community translations, so pull first if needed.
Translate the 21 previously-English strings in fi.i18n.json: the disable-*
and anonymize-* import/export/activities/notifications/watch admin feature
labels and descriptions, plus impersonation report title, impersonated user
and reason. Terminology follows existing Finnish strings (seuraaminen,
toimet, profiilikuva, tuonti/vienti, esiintyä käyttäjänä).
Below the "Multi-Selection is on" button a "Selected:" row now offers two
actions on the multi-selected boards:
- star icon: stars every selected board (sets them as Starred).
- home icon: sets the first selected board as the Home board (opened after
login) via the existing toggleDefaultBoard method + router redirect, which
brings the previously hidden Home-board feature back into use.
The Home board also always shows FIRST in the Starred list (even when it is
not itself starred), badged with a home icon centred at the top of its tile.
- Search boards box is now ~half width (300px); the input flexes to fill it.
- "Click checkboxes to select boards" hint moved out of the path title and
stacked directly below the "Multi-Selection is on" button (own column).
- Folder icon and the menu title (e.g. "Remaining") are grouped so the title
always sits to the RIGHT of the icon instead of wrapping below it.
The Filter by Unread / View All / Delete All Notifications item icons
(envelope, copy, trash, check) were faint; force the .menu-icon and .check-icon
(and their i.fa) to black.
The X dropped below the header because a global `.close { position: absolute;
top: 5px }` rule (boardBody.css) applied once my rule stopped setting position.
Set position: static on the drawer .close so it stays a flex item and aligns
with the Notifications title at the right edge of the header row.
- The drawer and its header are now full width (100vw) instead of 400px.
- Rebuild the header as a flex row (hamburger left, title centre via flex:1, X
right) instead of fragile absolute positioning, so the X is aligned with the
Notifications title at the right edge (was sitting too low).
- Force the hamburger and X to black with !important (they were inheriting the
themed header white link colour), overriding the surrounding themed bar.
- The X close button is now black, moved up (top: 8px, aligned with the
"Notifications" title instead of vertically centred lower) and to the far
right (inset-inline-end: 4px).
- The left hamburger menu toggle is black (was #333).
The member-menu "Notifications" item and its js-notifications-drawer-toggle
handler toggled the same notifications drawer as the header bell icon
(notifications.js), so it was a duplicate. Remove the menu entry and its
now-unused handler; the header bell is unchanged.
The js-new-search button clears the search (empties the input + query), so it
is a Clear button. Replace its broom icon with the text of the existing
trello-clear-job translation (en "Clear" / fi "Tyhjennä") and use it for the
aria-label too.
The global-search button hardcoded belize #216694 (inline !important styles +
.board-color-belize CSS), ignoring the theme. Make it a .primary type="submit"
button (safe: the search form has onsubmit="return false") so it uses the
theme override colour via the existing button[type=submit].primary rules
(default blue, or the board/global theme), and remove the hardcoded belize
styles.
The theme override colours .primary buttons via `button[type=submit].primary`
(board-color-* and custom-theme rules). A <button> without an explicit type
attribute does not match [type=submit] in CSS (though it already behaves as
submit), so the archive Restore/Delete buttons kept the default blue. Add
type="submit" (no behaviour change) so they match the theme rules and use the
global theme override colour (named or custom).
The header bars have a higher z-index than #modal, so the archive panel top
(and the close X) was hidden under the top header bar. Offset the archive
modal content 96px from the top (clears both the quick-access and board header
bars) and extend to the window bottom via min-height calc(100vh - 106px).
The archive modal used the default .modal-content (500px, margin: 5vh auto),
so archived boards were cramped and the panel stopped short of the window
bottom. Target the archive modal (the only one containing .archived-lists) and
make it 96vw wide with no bottom margin + min-height calc(100vh - 2vh) so it
reaches the bottom of the browser window; each archived board is a full-width
row.
Add the .primary class to both "Upload an avatar" buttons so they use the
theme primary-button style (default #005377 blue background + white text,
overridden by the board/global theme color) instead of the plain grey/black
default button.
Give .js-reset-text-color / .js-reset-bg-color a button appearance (border,
padding, background, hover) with a dimmed disabled state, matching the font/
size buttons.
Redesign the Font popup:
- Font: one button per installed font, each shown IN ITS OWN font (via inline
style), plus a Default button. Clicking a font applies it immediately.
- Size: a row of buttons ordered by percent — smaller on the left, 100%
(Default) in the middle, larger on the right. Clicking a size applies it
immediately.
- Colors: the text/background color wheels now apply immediately on change /
reset. Removed the Save button (everything applies on click).
New .ui-font-btn / .ui-font-size-btn styling (with .active) in labels.css.
Tests updated in tests/uiFonts.test.cjs.
Widen the language popup responsively (width: min(90vw, 1100px), max-width:
90vw) and lay the list out as an auto-filling grid (repeat(auto-fill,
minmax(170px, 1fr))) so as many languages as possible are visible at once on
wide windows, collapsing to a single column when narrow.
Guarded by tests/changeLanguageColumns.test.cjs.
- Revert the unconditional hiding of board-header button text labels (Private,
Muted, Sort, Filter, ...): they show again at normal widths. The pre-existing
mobile-only (max-width:800px, #6419) icons-only rule is untouched.
- CHANGELOG Upcoming: add consolidated entries for the recent All Boards /
board-header layout polish, the Change Color swatches / immediate-apply /
applies-everywhere / popup title, the #4759 user-insert validator fix, and the
rebuild-wekan.sh CURRENT-IP fix; mark the #2220 home-board toggle as currently
disabled.
"+ Add Board" and "+ Add Template Container" share the .js-add-board .label
element, so add an .is-template-container class to the templates-menu variant
and give it padding 20/8/22/8 — 2px lower than "+ Add Board" (18/8/24/8) —
while keeping the same 42px total vertical padding so the tile height is
unchanged.
Shift the .js-add-board .label vertical padding (top 24->20, bottom 18->22)
so the "+ Add Board" / "+ Add Template Container" text sits 4px higher, while
keeping the same 42px total vertical padding so the grey tile stays the same
height as the board icons. Test now checks total vertical padding equality
(height) rather than an exact padding string.
The desktop board-handle had a dark rgba(0,0,0,0.4) background box; make it
transparent so only the drag-handle icon is visible. The hover highlight is
kept. Guarded in tests/addBoardTileHeight.test.cjs.
The desktop board-handle was top-centered (top:4px; left:50%; translateX);
move it to the right middle (top:50%; translateY(-50%); inset-inline-end),
matching the mobile/small-screen handle position. Guarded in
tests/addBoardTileHeight.test.cjs.
- All Boards path header: order is now Multi-Selection, then Sort, then Search
boards (Search moved to the right of Multi-Selection; Multi-Selection moved to
the left of Sort). Guarded in tests/addBoardTileHeight.test.cjs.
- Top header bar: swap the positions of the mobile/desktop toggle and the zoom
controls (mobile toggle now right after the logo, zoom later). Both kept as
ungated direct children (zoom is no longer hidden when the logo is hidden).
Make the board-header button text labels (Private, Muted, Sort, Filter,
Swimlanes, Multi-Selection, ...) always hidden (was only under a max-width
media query), so the header shows icons only. The board title and the star
count (.board-star-counter) are not button labels and stay.
Guarded in tests/headerHamburger.test.cjs.
The #6465 one-click board-settings cog (js-open-board-menu) sat just left of the
sidebar hamburger, duplicating Board Settings which is reachable from the right
sidebar. Remove the cog from boardHeader.jade, its now-dead click handler from
boardHeader.js, and the unused .js-open-board-menu header CSS rule. The sidebar
board-menu button (sidebar.jade/js) is unchanged, and the hamburger + separator
remain.
Guarded in tests/headerHamburger.test.cjs.
When the window is not wide enough, the 2nd header bar button labels (Private,
Muted, Sort, Multi-Selection, ...) wrapped to a second row. Add a max-width:
1200px media query that hides the .board-header-btn text spans (show icons
only), keeping the board title and the buttons on one line. The star count
(.board-star-counter) is kept.
Guarded in tests/headerHamburger.test.cjs.
Previously the sidebar hamburger sat 12px from the right edge (2px margin +
10px bar right padding), more than the ~8px gap to the right of the user name
in the top bar (.header-user-bar-name margin-inline-end: 8px). Remove the board
header bar right padding and give the hamburger margin-inline-end: 8px, so its
right-edge gap is the same small amount as the user name.
Guarded in tests/headerHamburger.test.cjs.
The hamburger used the general .board-header-btn margin: 0 12px, leaving a
button-sized empty gap on each side. Tighten its side margins to 2px (and the
preceding cog button right margin to 2px) so the hamburger sits snugly at the
right edge with no wasted space to its left or right.
Guarded in tests/headerHamburger.test.cjs.
The board header bar (Swimlanes + Lists views) used a float layout: the right
button group (which ends with the js-toggle-sidebar hamburger) was float:
inline-end. As the window width changed, the floated groups wrapped and the
hamburger drifted to the middle of the bar. Make #header-main-bar a flex row
(align-items:center, flex-wrap:wrap), make the button groups flex (their
buttons float is ignored inside a flex container), and pin the right group with
margin-inline-start:auto so it always hugs the right edge (or the right of its
row when wrapped).
Guarded by tests/headerHamburger.test.cjs.
Feedback fixes to the Select Color picker:
- Apply IMMEDIATELY on clicking a swatch (and when a custom-color wheel is
committed) via a shared applySelection(); removed the Save button/handler.
- Category titles (Flat/Clear/Dark/Special) are left-aligned and clear: both,
so they drop below the previous group swatches (which float: inline-start)
instead of the 2nd/3rd/4th title sitting mid-row.
- Remove the empty band above the first (Flat) title (first-child margin-top:0)
and tighten the Default row.
- Member Settings Change Color popup now has a header: added changeColorPopup-title
("Change Color" / fi "Vaihda väri"), matching Board Settings.
Tests updated in tests/themeColorPicker.test.cjs.
The dev-server CURRENT-IP options detected the IP with
ip a | grep noprefixroute | ... | grep 192.
which requires the noprefixroute flag AND a 192.x subnet. On hosts without
either (e.g. arm64 Ubuntu on a 10.x/172.x network) it returned empty, so
ROOT_URL became http://:3000 and Meteor crashed with "TypeError: Invalid URL".
Detect the primary IP subnet-agnostically via the default route
(ip route get 1.1.1.1 -> src), fall back to hostname -I, then to localhost so
the URL is always valid. macOS uses ipconfig getifaddr en0/en1 with a localhost
fallback. Applied to both CURRENT-IP dev-server options.
Remove the fa-home Home-board toggle from the board tiles (boardsList.jade),
its click handler and the isDefaultBoard tile helper (boardsList.js), so a
Home board can no longer be set from the All Boards page. The model helpers,
server toggleDefaultBoard method and the login redirect are left intact but
are no longer reachable from the UI. Test flips to guard the disabled state.
The All Boards button showed only an icon (span.emoji-icon > fa-check-square-o)
with the reset X as a sibling, while the Swimlanes View (boardHeader.jade)
shows the check icon + a "Multi-Selection" text label with the reset X nested
inside. Match the All Boards markup to the Swimlanes View (direct icon + text
label + nested reset with fa-times-thin), and add stopPropagation to the reset
handler so the now-nested X does not bubble to the activate handler and
immediately re-enable selection.
Guarded by tests/multiSelectionButton.test.cjs.
The .boards-path-header was a single-row flex (justify-content: space-between,
no flex-wrap) and .path-right (search box + Sort / Multi-Selection / Clear)
also did not wrap, so narrowing the window — especially with Multi-Selection
on, whose hint widens .path-left — pushed those buttons off/hid them. Add
flex-wrap: wrap to the bar, .path-left (min-width:0 so it can shrink; hint
wraps under the title) and .path-right (buttons wrap among themselves), so
everything drops to a second row and stays visible.
Guarded in tests/addBoardTileHeight.test.cjs.
The .board-list-header div (a leftover zoom-controls container) rendered
nothing but still reserved vertical space via margin: 1vh 0 2vh 0 + padding:
1vh 0 — an empty grey band above the boards layout, between the top and the
left menu / "My Boards" bar. Removed the empty element from boardsList.jade
and its now-unused CSS rule, so .boards-layout is the first child of .wrapper.
Guarded in tests/addBoardTileHeight.test.cjs.
The grey add-tile (.js-add-board .label, shared by both "+ Add Board" and
"+ Add Template Container") overrode min-height:100px, line-height:56px and
padding:36px 8px 32px 8px — taller than the #6465-thinned board tiles
(.board-list-item: min-height:72px, padding:24px 8px 18px 8px). Match the
board-tile box model so the grey background is the same height as a board icon.
Guarded by tests/addBoardTileHeight.test.cjs (asserts the add-tile min-height +
padding equal the board tile, and the old oversized values are gone).
Two fixes from feedback that picking clearblue "did not change" and the
dropdowns were hard to use:
1) The global theme override now wins EVERYWHERE, including board pages. Before,
the <body> autorun suppressed it whenever a board was open (onBoard gate), so
changing the color while viewing a board did nothing. Now a set global override
is applied regardless of board context, and #header / #header-quick-access
(header.jade) and .board-wrapper (boardBody.jade) prefer the global override
over the per-board color. Unset it to return to per-board colors. When no
global is set, an open board still exposes its own custom colors.
2) The picker no longer uses category/theme dropdowns (colors were invisible).
It now shows the actual color SWATCHES with their names, grouped by category,
with the category name as a header above each group. The flat/clear color
wheels still appear once such a color is selected; custom-capability is derived
from the selected color. customTheme.css styles the group labels.
Tests updated: themeColorPicker/globalThemeColor guards for grouped swatches and
the global-wins-everywhere behaviour.
Two fixes to the #6465 inline Add List composer:
1) Save button did nothing. The inline composer calls createListAfter WITHOUT
nextListId, but the server check() used Match.OneOf(String,null,undefined)
(not Match.Optional) for it. Meteor check throws "Match failed [400]" on a
MISSING object key unless the sub-pattern is Match.Optional, so every inline
add-list 400'd. The omittable params (swimlaneId/afterListId/nextListId/type)
are now Match.Optional, so callers may omit them.
2) Empty swimlane/board now shows a + button, and the create-list form appears
only after clicking it (a swimlane that already has lists keeps using the
per-list-header add-list button — no + button needed). The reveal is scoped
to the clicked swimlane via a wekan-add-list-empty Session flag, cleared on
save/close.
Tests: models/lib/createListParams.js + tests/createListParams.test.cjs mirror
the method's param contract (incl. Meteor's missing-key rule) with the exact
client shapes + negatives, and a source guard that the check uses Match.Optional;
tests/upcomingUiGuards.test.cjs gains guards for the empty-state + button reveal.
Wired into test:unit:node.
specs/13-swimlanes.e2e.js waited only 6s for the swimlane action .js-pop-over
to appear. Under the full parallel suite that occasionally exceeds 6s on
webkit (the popup opens fine, ~3s, when the test runs alone), so the test
flaked. Bump to 15s to match the suite standard popup-visibility wait; a
genuinely broken popup still fails.
The optional profile.uiTextColor / profile.uiTextBgColor schema fields ran
their custom() hex validator on EVERY user insert, including when the field
was absent. /^#..$/.test(undefined) is false, so the validator returned
"notAHexColor" and collection2 threw ValidationError for any user with no
custom color set — surfacing as a SyncedCron fatal unhandledRejection and
blocking user creation (seen in ../log test-server.log; also cascaded into
ECONNREFUSED e2e failures as the server destabilized).
Guard both custom() validators to return undefined when the value is unset
(undefined/null/empty), matching the existing list-color validator. Add a
source-guard test so the guard cannot silently regress.
Translate the strings added this cycle in fi.i18n.json, reusing existing
terminology (väri, taulu, lista, uimarata, fontti, teema, Oletus): the Select
Color theme categories, the Member Settings font / size / text + background
color pickers, the default-board tooltips, and the list-move confirmation.
Fixes theme-category-clear, which had been auto-translated to the verb
"Tyhjennä" (empty) instead of the theme sense "Kirkas". font-preview-text uses
the standard Finnish pangram so the preview shows ä/ö.
Restore the old "a grain opens straight into its board" behaviour as a
convenience, WITHOUT persisting anything. On the Sandstorm home route only:
- a saved Home board (explicitly chosen by the user) always wins -> redirect;
- else, once the boards subscription has loaded, exactly ONE board -> just
open it (nothing is saved);
- else (zero or many boards, no chosen Home board) -> stay on All Boards.
Choosing a Home board stays a separate, explicit action (the All Boards home
toggle, #2220) — the auto-open never writes it.
The redirect is reactive (Tracker.autorun over a boards subscription), so it
waits out Sandstorm asynchronous grain login (Meteor.userId lands late) and
board loading instead of firing once too early. Runs at most once per page
load, and only when isSandstorm.
- models/lib/sandstormAutoOpen.js: pure, unit-tested decideSandstormAutoOpen()
(+ tests/sandstormAutoOpen.test.cjs, wired into test:unit:node).
- config/router.js: startSandstormAutoOpen() reactive redirect, gated to
Sandstorm; non-Sandstorm keeps the existing one-shot default-board redirect.
The Font popup now has a color wheel for the text color and one for the text
background color, each with an Unset button to go back to default. Values are
free choices (native <input type=color>), validated server-side as #rrggbb
hex (models/lib/uiFonts isHexColor6) so only a strict hex ever reaches a CSS
value — no injection surface. An unset/invalid value clears that color.
- profile.uiTextColor / profile.uiTextBgColor schema (hex-validated) + getters;
setUiColors(textColor, bgColor) method ($set valid hex, $unset otherwise).
- changeFontPopup: two color wheels + reset buttons; live preview reflects
font, size, text color and background color.
- Applied UI-wide via --wekan-ui-text-color / --wekan-ui-bg-color CSS variables
+ has-ui-text-color / has-ui-bg-color body classes (uiFont.js + uiFont.css).
- i18n text-color / text-background-color; tests extended (hex + negatives +
source guards).
Part of #4759.
New "Font" entry in the member menu opens a popup with a dropdown of fonts
DETECTED as actually installed in the browser (no free typing) and a Font
size dropdown of sensible presets (Smaller..Largest, no typed numbers). Both
have a Default option that unsets the custom choice.
Security: font names and size keys are validated server-side against a
curated whitelist / preset list (models/lib/uiFonts.js), so only a known safe
font name and a preset percentage ever reach the DOM. Labels are plain text
(Blaze-escaped) — never HTML or markdown. No CSS-injection surface.
- models/lib/uiFonts.js: UI_FONTS whitelist + SAFE_FONT_RE + isKnownFont /
fontFamilyValue and UI_FONT_SIZES presets + isKnownFontSize / fontSizeValue.
- client/lib/fontDetector.js: canvas width-comparison detection of installed
fonts, filtered to the whitelist.
- profile.uiFont / profile.uiFontSize schema + getters; setUiFont /
setUiFontSize methods (validated, null/default unsets).
- changeFontPopup (font + size dropdowns, both with Default) wired in
userHeader.js; applied UI-wide via CSS variables in uiFont.js + uiFont.css
(marker body classes), wired into client/features/main.js + client/styles.js.
- i18n change-font/font/font-default/font-size(+presets); tests/uiFonts.test.cjs
(pure + negatives + source guards) wired into test:unit:node.
This is a Member Settings feature (not an Admin Panel option).
Fixes#4759.
Both Board Settings and Member Settings Change Color now use a shared
category -> theme two-level dropdown picker with custom color wheels for the
flat (1 color) and clear (2 colors, a color slide) categories; dark/special
are fixed. Custom colors are validated server-side as #rrggbb, stored in
board.customThemeColors / profile.globalThemeCustomColors, and applied as
CSS variables (--theme-accent / --theme-accent-2) consumed by customTheme.css
to recolor the header, primary buttons and sidebar button.
- client/components/main/themeColorPicker.{jade,js}: shared picker, scope
"board" or "global", reusing the #5514 native color wheel + themeCategories.
- board.setColor(color, custom) + setGlobalThemeColor(color, custom): validate
via isValidCustomColors (flat/clear + count + hex) before storing.
- boardChangeColorPopup and changeColorPopup now render the shared picker;
removed their old swatch-list helpers/events.
- globalThemeColor.js: apply custom colors as :root CSS variables (board own
colors on a board, else the user global override) + marker body classes;
customTheme.css consumes them.
- Wire globalThemeColor.js / themeColorPicker into client/features/main.js and
customTheme.css into client/styles.js. This also fixes globalThemeColor.js
never having been imported (so the #5778 override now actually loads).
- i18n theme / theme-category[-flat/clear/dark/special]; tests
themeColorPicker.test.cjs + updated globalThemeColor.test.cjs.
Fixes#5778.
Partition the ~19 board/global theme colors into flat / clear / dark /
special and define where custom colors are allowed (flat = 1 accent,
clear = 2 for a color slide, dark/special = fixed none). This is the shared
source of truth for the redesigned Select Color picker (two-level dropdowns
+ color wheel) used by both Board Settings and Member Settings.
- models/lib/themeCategories.js: THEME_CATEGORIES + categoryOf /
colorsInCategory / allowsCustomColor / customColorCount /
isValidCustomColors (validates category + count + #rrggbb hex).
- tests/themeCategories.test.cjs (wired into test:unit:node): behaviour +
negatives + a source guard that the partition exactly equals
config/const.js ALLOWED_BOARD_COLORS.
- docs/Theme/Theme.md: full design — categories, two-level dropdown UX,
custom-color rules, CSS-variable rendering plan, storage
(board.customThemeColors / profile.globalThemeCustomColors), and
server-side validation.
Completes the global per-user theme override (Member Menu / Change Color)
begun in the previous commit.
Fixes#5778.
A new "Change Color" entry in the member menu works like Board Settings /
Change Color, but is a per-user GLOBAL override saved to the profile
(profile.globalThemeColor). The chosen color theme applies to the whole UI —
All Boards, Search All Boards, Admin Panel, My Cards, etc. — via a
board-color-<name> class on <body> and the header, so a user can pick a dark
theme (dark/midnight/moderndark/exodark/cleandark) or any other theme
everywhere. The first popup entry clears the override.
- models/users.js: profile.globalThemeColor field + getGlobalThemeColor /
globalThemeColorClass / setGlobalThemeColor helpers.
- server/models/users.js: setGlobalThemeColor method, validated against
BOARD_COLORS (rejects unknown colors so no arbitrary CSS class is injected)
and auth-guarded; null/empty clears it.
- client/components/main/globalThemeColor.js: Tracker autorun that adds the
class to <body> on non-board pages; on a board the board own color wins, so
the global class is cleared there to avoid two themes clashing.
- header.jade: #header and #header-quick-access fall back to the user global
theme class when not on a board (covers the #header-specific color rules).
- Change Color menu entry + changeColorPopup (color swatches + clear option),
wired in userHeader.js.
- i18n change-color / theme-default; tests/globalThemeColor.test.cjs wired
into test:unit:node.
Deleting a list no longer destroys it. The list and its cards are marked
(deletedAt/deletedBy/deleteBatchId), hidden from the board, recorded as a
reversible delete in history, and restorable via lists.restore or Ctrl+Z.
This is the first slice of "no permanent delete in ordinary use": physical
removal is limited to GDPR/account erasure and a Global-Admin purge gated by
a new Admin Panel / Features / Delete "Enable permanent delete" toggle
(off by default).
- models/lib/softDelete.js: pure notDeleted/softDeleteSet/restoreModifier/
canPurge helpers (+ tests/softDelete.test.cjs, wired into test:unit:node).
softDeleteSet omits falsy actor/batch and restore uses $unset so no null is
ever written to a Date/String schema field.
- enablePermanentDelete feature flag (models/lib/featureFlags.js) + Settings
schema field.
- deletedAt/deletedBy/deleteBatchId schema fields on Lists and Cards
(optional, no null default -> absent means live).
- board/swimlane list render paths filter deletedAt: null.
- server/models/lists.js: shared softRemoveList() + lists.softRemove /
lists.restore / lists.purge (purge gated on canPurge = isAdmin && flag);
client delete handler and REST DELETE now soft-remove.
- userPositionHistory undo()/redo() handle actionType delete/restore via a
batch-consistent _applyDeleted() helper.
Design + planned rollout (other entities, Recycle Bin, admin Delete category
table with GDPR data classes) in docs/Features/Undo/Undo.md.
Every user-facing delete becomes a soft delete (deletedAt/deletedBy), making
deletions restorable and undoable (#1023). No permanent delete in ordinary
use: physical removal is limited to GDPR/account erasure and an explicit
Global-Admin purge gated behind Admin Panel / Features / Delete ->
"Enable permanent delete for Global Admin" (off by default). Covers scope
(soft vs exempt collections), the softRemove/notDeleted mechanism, cascade
via deleteBatchId, archived-vs-deleted distinction, undo/History integration,
and migration steps.
The activities publication only ships the most-recent `limit` activities and
the client pages them in via loadNextPage, so large boards stay fast without
deleting activity history. Document this so the request is closeable.
Fixes#2539.
The 'Filter by date -> Overdue' and 'list disappears in swimlanes' fixes were
reported originally by email, not as GitHub issues - #6483 and #6484 do not exist
(404). Replace the dead issue links in their Upcoming entries with '(reported by
email)'. The fixes themselves are unchanged.
Thanks to xet7.
Adds tests (with negative cases) for the Upcoming changes that previously lacked
them:
- tests/defaultBoard.test.cjs (#2220): behavioural toggle/decision logic (set,
unset the current default, switch to a different board; negatives: nothing is
the default when unset, B is not the default when A is) + source guards that
the schema, model + server toggleDefaultBoard, once-per-session router redirect,
and board-tile toggle are all wired.
- tests/upcomingUiGuards.test.cjs: source guards for the #6465 Add List header
button + inline composer (leading column replaced by empty-state gating),
minicard white-space (min-height:0) and swimlane-header zoom clip
(overflow:visible/height:auto), and the #6478 touch confirmation before a
cross-swimlane list move (gated on touch/small screen; declining aborts).
The other Upcoming changes already had tests (filterDateNull, listMoveSwimlane,
cardDragSwimlaneScrollRefresh, popupAfterConfirm, undoRedoSelection,
listWidthDefaults, ferretdb* in the FerretDB repo).
Fixes#2220. Fixes#6465. Fixes#6478. Fixes#6479.
Thanks to xet7.
On the All Boards page each board tile gets a home (house) toggle next to the
star. Marking a board sets it as the user's default 'home' board; the next login
(or reload) lands on it instead of All Boards. Clicking again clears it.
- profile.defaultBoardId (per user) + getDefaultBoardId/isDefaultBoard/
toggleDefaultBoard model helpers + a toggleDefaultBoard Meteor method (mirrors
toggleBoardStar).
- home route redirects to the default board ONCE per session (so clicking All
Boards afterwards stays on the list; honoured again next login/reload); by id
so it works before the board subscription loads; graceful fallback if the board
is gone.
- board tile .js-set-default-board toggle + isDefaultBoard helper; i18n
set/unset-default-board-title.
Thanks to xet7.
Per request, the History design now records EVERY change a user makes (not just
card groups) in one append-only changeHistory store, and undo/redo becomes the
keyboard front-end to it (restore the current user's most recent change) rather
than a separate feature - superseding userPositionHistory.
Same table + restore is surfaced from many menus, each just a scope filter over
the one store: card group, whole card, Member settings (per-user), Board Settings
(per-board), Swimlane menu, List menu, and any future entity menu. Scopes nest
(card in list in swimlane in board) via id-column ORs. Added a scoped-views
section, the scope/scopeId page() params, per-scope permissions, and updated the
phasing (migrate position undo/redo onto changeHistory first). Undo.md now points
at History.md as the source of truth for the unified direction.
Thanks to xet7.
docs/Features/Undo/Undo.md documents the #6478 feature: position-move undo/redo
(card/list/swimlane) via userPositionHistory with an undone/undoneAt redo stack,
undoLast/redoLast methods, Ctrl+Z/Ctrl+Y bindings, and the touch confirmation for
cross-swimlane list moves. Records the inert-recording bug (collection referenced
as an un-imported global), the write/read wiring, retention, v1 limitations
(positions only; content undo is the separate History feature) and an extension
checklist.
Thanks to xet7.
Detailed design doc at docs/Features/History/History.md for the requested card
details 'History' feature: per-group history popup with a searchable, paginated
(current page only), per-contributor change table and restore.
Key point: WeKan's Activities log records what happened but NOT before/after
values, so restore needs a new append-only cardGroupHistory content-versioning
collection - the doc specifies the model, write/read (paginated+searchable+
per-user) methods, the 2-pane UI (History + contributor avatars | table with
select/change-type/content/datetime, search+pagination+restore bar), RTL
mirroring, restore semantics (re-logged to both source and restorer; history is
append-only), security, and a phased rollout starting with Description.
Thanks to xet7.
Swimlanes/Lists views no longer render a permanent Add List composer column.
Each list header now has a far-right add-list button; clicking it opens the Add
List composer as a column immediately AFTER that list in the DOM - which appears
to the right of the list in LTR and to the left in RTL (the lists lane is a flex
row that mirrors under dir=rtl). An empty swimlane/board shows the composer at the
start so the first list can still be added.
- new always-open addListInline template (title input + save + close), submit ->
createListAfter (afterListId = the clicked list, swimlaneId = the displayed
swimlane recorded from the DOM by the header button).
- Session 'wekan-add-list-after'/'wekan-add-list-swimlane' drive which list the
composer opens after; isAddListAfter/swimlaneHasNoLists/boardHasNoLists helpers.
- leading +addListForm removed from the non-mini swimlane and listsGroup
branches; mini-screen path unchanged.
First cut - desktop/main views; needs live verification of styling, RTL and the
empty-board case.
Thanks to csonkaoszimt (report) and xet7.
A user accidentally dragged a list into another swimlane on mobile (merging it
with an existing list) and spent ~15 min recovering - no confirmation, no undo.
Undo/Redo: the userPositionHistory collection modelled this but was NOT actually
recording (trackChange guarded on an un-imported global) and had no redo or key
bindings. Now:
- list moves are recorded in updateListSort (import UserPositionHistory so the
guard passes); card moves already had the call.
- add a symmetric redo() model method + an undone/undoneAt redo stack; add
userPositionHistory.undoLast/redoLast methods (a new action clears the redo
stack via trackChange).
- wire Ctrl+Z / Ctrl+Y (and Cmd variants) in client/lib/keyboard.js; disabled
inside inputs/textareas so native text undo is unaffected.
- pure selection logic models/lib/undoRedoSelection.js + tests.
Confirmation: on touch / small screens, moving a list to a DIFFERENT swimlane
(which re-homes every card) now asks for confirmation; a decline reverts the
drag. Same-swimlane reordering is unaffected.
Thanks to mueschel (report) and xet7.
Clicking a .js-confirm button silently did nothing. Popup.afterConfirm() stashed
the pending action as a field on the Blaze DATA CONTEXT and the confirm button
read it back as this.__afterConfirmAction. But the context is a
ReactiveCache/Minimongo document (a board member sub-doc), and Blaze re-renders
the confirmation popup with a fresh/immutable copy of that context, so the field
was gone by click time - so every afterConfirm dialog (remove member from board,
etc.) no-oped.
Store the action on the Popup INSTANCE (Popup._afterConfirmAction) instead, which
survives the re-render; it is still invoked with the confirmation popup's own data
context as this, so this.userId keeps working. The .js-confirm handler also guards
against a missing action instead of throwing. Test tests/popupAfterConfirm.test.cjs
with a negative case reproducing the old context-mutation failure.
Thanks to mueschel (report) and xet7.
Dragging a card from one swimlane to the SAME list in another swimlane dropped
it back in the source swimlane, especially with long overflowing lists. The card
sortable's sort handler auto-scrolls the board (#443) via .js-lists scrollLeft /
.board-canvas scrollTop, but jQuery UI sortable caches container/item geometry at
drag start and only re-caches on its own placeholder moves - never on our manual
scroll. Long lists are exactly what triggers auto-scroll, so the drop resolved
against a stale geometry map and the stop handler's ui.item.parents('.swimlane')
persisted the wrong swimlane.
Re-cache positions with sortable('refreshPositions') whenever the sort handler
actually scrolled - complementing the #2769 DOM-mutation refresh in listBody.js,
which does not fire on scroll. Source-guard test
tests/cardDragSwimlaneScrollRefresh.test.cjs.
Thanks to mueschel (report) and xet7.
The #6465 density change lowered MIN_LIST_WIDTH 270->200 and the default
272->220, but specs/38-fixed-list-width.e2e.js still asserted the old 270 floor:
setFixedListWidth(200) was expected to be REJECTED (it is now valid), and the
anonymous default was expected to be 272. This failed on chromium/firefox/webkit.
Use 150 as the too-small value (below the new 200 floor), update the client MIN
mirror to 200 and the default to 220, and add a POSITIVE case that a width of
210 (>=200 but below the old 270) is now accepted.
Thanks to xet7.
v9.99 was released with the LDAP connection-leak fix, the bundled-FerretDB pool
note and the snap #6476 wait-loop diagnostics. Everything committed after the
v9.99 bump - the QEMU snap build, the #6465 UI density/minicard/dock/swimlane
changes, the #6483 date-filter fix, the #6484 list-move fix, and the #6476
root-cause (FerretDB orphaned-table crash loop) - now sits under a new
# Upcoming section above v9.99 instead of being backdated into v9.99.
Thanks to xet7.
Updates the #6483 note: label ($in), title ($regex) AND date range
($gt/$gte/$lt/$lte) filters are all now pushed down to SQLite in the bundled
FerretDB, so none of them full-scan the board. Dates use SQLite's ->> accessor
for numeric comparison of the stored millis, verified end-to-end.
Thanks to xet7.
Notes that the filter slowness flagged earlier is addressed in the bundled
FerretDB, which now pushes {labelIds: {$in: [...]}} and literal {title: {$regex}}
down to SQLite instead of scanning + matching every card's sjson in Go.
Thanks to xet7.
At e.g. 80% zoom (transform: scale on .board-wrapper) the gray swimlane header
(.swimlane-header) looked cut off: it had overflow: hidden with a fixed
min-height, so once the scaled layout rounded, the bar/text was sliced. Use
overflow: visible + height: auto (keeping the min-height floor) so the header
grows to fit and cannot clip vertically; short swimlane names are unaffected and
a long name wraps instead of being cut.
Speculative CSS fix - verify in the running app at 80% (and check 50%/125%/200%).
Thanks to xet7.
Operator logs (uusijani, a1bert01) showed FerretDB was listening and WeKan
connected, then a startup SyncedCron upsert hit FerretDB error
'table accountslockout.connections_<hash> already exists', which became an
unhandledRejection and exited the process; systemd crash-looped it so the HTTP
port never came up. The real fix is in the bundled FerretDB (collectionCreate
now uses CREATE TABLE/INDEX IF NOT EXISTS to re-adopt an orphaned table). The
snap wait-loop timeout/diagnostics remain useful for the separate 'FerretDB
genuinely not ready' case.
Thanks to xet7.
Several users reported 9.x wastes space vs the older 6.09 layout. Tighten three
density defaults (all overridable; user customizations untouched):
- List width default 272 -> 220px (models/lib/listWidth.js, models/lists.js),
minimum 270 -> 200px here and in the server check (server/models/users.js).
Lists already render the same width by default (resolveListWidth) on private
and public boards; now narrower so more fit. Test updated.
- Board tiles (All Boards page): min-height 100 -> 72px, vertical padding
36/32 -> 24/18px (boardsList.css).
- Card-detail right dock max-width 800 -> 520px (and the wide-screen centred
variant 600 -> 520px) in cardDetails.css, so the opened card no longer takes
~1/4 of the screen.
Thanks to xet7.
A board-wide list has swimlaneId === null and renders under every swimlane. The
list-drop handler computed
isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId
but targetSwimlaneId is always the swimlane the list is shown under, and
originalSwimlaneId is null for a board-wide list, so ANY small drag made this
true - it set list.swimlaneId AND moved every card in the list to that one
swimlane, so the list vanished from all other swimlanes (recoverable only by
restoring a backup).
Also require originalSwimlaneId: board-wide lists stay board-wide on a reorder,
while a genuinely swimlane-scoped list can still be moved between swimlanes. Test
tests/listMoveSwimlane.test.cjs, with a negative control that the old target-only
condition rebound a board-wide list.
Thanks to xet7.
Cards without a date store the field as null (that is what the sidebar's noDate
filter matches: {dueAt: null}). The comparison filters build {$lte: now}
(Overdue/past) and {$gte, $lte} (today/this/next week), and $lte MATCHES null
under both FerretDB and minimongo - null sorts before any date and neither
implements MongoDB type-bracketing - so Overdue selected every dateless card.
DateFilter._getMongoSelector now adds $ne: null to any comparison selector so
only real dates match; the noDate filter (a bare null) is unchanged. Test
tests/filterDateNull.test.cjs, with a negative control proving the old bare
{$lte: now} matched null.
The reported filter SLOWNESS and the title/label over-matching are separate
(FerretDB scanning $regex/$in/range queries not pushed down to SQLite) and need
live reproduction.
Thanks to xet7.
The minicard title renders inside a .viewer, which inherited the global
.viewer { min-height: 2.5vh } (client/components/main/layouts.css). On tall
screens that reserved a tall empty band beneath a one-line title, so a large
part of every minicard was blank and fewer cards fit on screen. The minicard
title .viewer now sets min-height: 0 (the same override already applied to the
label .viewer), so a minicard collapses to fit its content.
Thanks to xet7.
The Launchpad 'snapcraft remote-build' legs for ppc64el and s390x ended in
Launchpad state 'Stopped' with no snap produced, yet remote-build still exited 0;
the job's name-only success check let an empty/stale file through and the upload
died with ''wekan_<v>_<arch>.snap' is not a valid file' (exit 64). Build them
instead with docker/setup-qemu-action + diddlesnaps/snapcraft-multiarch-action on
a normal runner - fast, because the snap is bundle-centric (the wekan part only
downloads the prebuilt wekan-<version>-<arch>.zip and repackages it, no native
compilation).
riscv64 stays on Launchpad (the QEMU snap action does not support it) but now has
timeout-minutes: 180 so a stuck build fails with a clear message instead of
GitHub's silent 6-hour cancel.
Thanks to xet7.
With database=ferretdb, snap-src/bin/wekan-control waits for FerretDB to accept
connections before starting node - WeKan does not open its HTTP port until the
database answers. That wait loop had no timeout and no diagnostics, so if
FerretDB never started listening (crashed or arch-incompatible per-arch binary,
locked/corrupt SQLite, or the service left disabled by a failed migration
hand-off), WeKan blocked there forever: the service showed active but the port
never opened and the only symptom was a silent 'FerretDB not ready yet' loop.
uusijani's wekan.log stopped right after the startup env dump, confirming
control never reached node main.js.
Mirror the MongoDB branch: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120,
overridable) print an actionable hint once, then keep retrying - pointing to
'snap logs <snap>.ferretdb', naming the SQLite dir to check for locks/corruption,
the arch-mismatch case, the snap start --enable recovery, and the
'snap set <snap> database=mongodb' fallback. Regression test
tests/ferretdbWaitTimeout.test.cjs, including a negative guard that the wait is
not a silent unbounded loop again.
Thanks to uusijani (report) and xet7 (fix).
tx pull -f fills any string untranslated on Transifex with the English source, so
languages whose translations live only in git silently revert to English on every
pull. Make the pull self-heal, and fix the push helpers so restored translations
actually upload.
- pull-translations.sh: after the pull, for each language file whose changes are
ENTIRELY reverts, git checkout HEAD -- <file> to restore the committed
translations and force-push them back (../tx push -t -l <lang> -f). Files that
also carry a real new/updated translation from Transifex are NOT auto-healed
(a whole-file checkout would discard it) and are left for manual handling.
- report-english-regressions.mjs: add a machine-readable --files mode and an
otherChanges classifier, so only pure-revert files are offered for auto-heal;
the human report tags mixed files.
- push-translation.sh: add -f (without it the Go tx CLI skips a language whenever
the local file is not newer than the server copy - push.go shouldSkipPush - so
a freshly reverted file uploads nothing), plus a usage guard.
- push-copy-en-gb-translation.sh (new): copy en.i18n.json to en-GB.i18n.json
verbatim and force-push it as the en_GB translation, so British English shows
as translated instead of reverting to the English source on each pull.
Thanks to xet7.
Operators reported that after an update WeKan "dies really quickly", restarts
did not help, and it "kills our openldap server with too many open connections";
others saw logins hang for minutes then fail with "Must be logged in". Every
LDAP login attempt (loginHandler.js) and every background sync tick (sync.js)
created a new LDAP() and connect()ed but NEVER called disconnect() on any path,
so each attempt - including every failed login and every one-minute sync tick -
leaked a socket to the LDAP/AD server until it hit its connection limit and
started refusing connections, taking it and all WeKan logins down.
Guarantee release on every exit path: a shared runWithLdapDisconnect() helper
(connectionGuard.js) disconnects in a finally; the login handler runs its whole
flow through it; background sync() disconnects in a finally; importNewUsers()
disconnects only the connection it owns; and LDAP.disconnect() is a safe no-op
when connect() was never reached. Adds behavioural + negative + source-guard
tests (tests/ldapConnectionRelease.test.cjs, wired into test:unit:node).
Also bumps the bundled FerretDB whose SQLite pool no longer caps MaxOpenConns
at 16 (that starved WeKan's cursor load: minutes-long board loads, failed
logins).
Thanks to xet7.
Document the two GitHub CodeQL code scanning fixes already committed
(alerts #423 js/incomplete-sanitization and #422
js/biased-cryptographic-random) in a new Upcoming section above v9.97,
linked to the wekan.fi Hall of Fame entries.
Thanks to GitHub CodeQL and xet7.
256 is not a multiple of the 55-character id alphabet, so the plain
byte % 55 mapping skewed generated document ids toward the first
characters. randomId now uses rejection sampling: bytes at or above the
largest multiple of the alphabet size (220) are discarded and resampled,
so every character is equally likely. Ids stay Meteor-style 17-char and
exact-length for custom lengths. Resolves code scanning alert #422.
Tests: tests/schemaUpgradeSteps.test.cjs gains a rejection-sampling
guard (negative case pins that out-of-range bytes are never wrapped).
Thanks to xet7.
The CSS-declaration-to-RegExp conversion escaped only parentheses;
escape the full regex metacharacter set (including backslash) instead.
Resolves code scanning alert #423. Test-only, fixed input list, but the
complete escape is correct and matches the pattern already used in
tests/archiveLinkGrouping.test.cjs.
Thanks to xet7.
Every deletion path (admin removeUser method, self-service account
delete, DELETE /api/users/:userId) removed only the users document,
leaving dangling references everywhere: empty-avatar board members that
could not be removed, stale card members/assignees/watchers, orphaned
avatar files - reproducible for 8 years and reopened in 2025. A
server-only Users.after.remove collection hook now prunes
boards.members/watchers (object-shaped, $pull by userId),
cards.members/assignees/watchers and lists.watchers (string arrays), and
the user's Avatars files, on EVERY path that removes a user; a cleanup
failure can never break the deletion itself. Activities and comments are
deliberately preserved for history - their rendering is already
null-guarded (270eacb20, Playwright-verified).
Tests: tests/userDeletionCleanup.test.cjs (6, positive and negative,
end-to-end against a fake database).
Thanks to chotaire and xet7.
Drag start hid every list's "+ Add Card" composer link with
display: none, collapsing its row: lists shrank, auto-height swimlanes
shrank, and every swimlane below jumped up about one row (and back down
on drop), moving the drop target under the cursor mid-drag. The root
cause was proven by frame-diffing the issue's own GIF: the three largest
frame discontinuities are exactly drag start and drop, showing the
composer row vanish and the next swimlane header shift ~23px. The
composer now hides with visibility: hidden, preserving its layout box;
checked multi-selection cards keep display: none intentionally (the drag
payload previews the post-drop list).
Tests: tests/swimlaneDragJump.test.cjs (8, positive and negative,
including a guard that flags any return of the layout-collapsing rule).
Thanks to gerroon and xet7.
off-screen list.
The horizontal auto-scroll in the card sortable targeted .board-canvas,
which stopped overflowing horizontally when the swimlane layout made the
.js-lists lane the horizontal scroller - scrollLeftMax was always 0, the
guard compared 0 < 0 and never fired, and ui.helper[0].offsetLeft -= 15
assigned a read-only property. Edge-proximity auto-scroll now drives the
lane actually under the pointer (axis-agnostic, clamped, overshoot-safe
helpers in imports/lib/boardAutoScroll.js), with vertical scrolling kept
on the board canvas.
Tests: tests/boardAutoScroll.test.cjs (15, positive and negative,
including a reproduction that the old no-overflow target can never
scroll).
Closes#603: obsolete - the stale-drop-target cause was removed by
d887e553c (perfect-scrollbar removal, native list scrolling),
ed8471be9 (sortable auto-scroll re-enabled) and jQuery UI 1.14.2, whose
sortable resolves insertion neighbours with live offsets on every
mousemove, so a scrolled target list cannot desynchronize drop targets.
Thanks to anhenghuang, AlexanderS and xet7.
Sandstorm username races.
- #2345: the Card Title Filter is now SAVED by every board trigger
builder (a trigger doc missing the field could never satisfy the
matcher's $in - those rules fired for nothing), SHOWN in the rule
details, and ENFORCED for archive/restore triggers by resolving the
title from the card when the activity lacks it; legacy field-less
triggers keep matching; the filter popup prefills its current value.
- #2674: rules created via POST/PUT /api/boards/:boardId/rules never
fired - missing matching fields are now normalized to '*' wildcards
and trigger/action types validated; the addMember/removeMember rule
actions no longer crash on unresolvable usernames or member-less
cards and their writes are awaited. New docs/API/Rules.md (endpoints,
matching fields, wildcard semantics, the exact two-rule
add-on-move-to/remove-on-move-from workflow), linked from REST-API.md;
api.py help now lists the rule commands with the same example.
- #574: Sandstorm unique-username generation probes case-insensitively
(anchored, regex-escaped) and retries the next number when a
concurrent insert wins the unique-index race, instead of aborting the
login hook with E11000.
Tests: tests/rulesCardTitleFilter.test.cjs (12),
tests/rulesApiTriggerNormalize.test.cjs (19),
tests/sandstormUsername.test.cjs (11) - positive and negative cases.
Thanks to InfoSec812, sfahrenholz, mitar and xet7.
Inviting by email derives the new user's username from the email local
part; a collision with an existing user (cats@foo.com then
cats@facebook.com) made Accounts.createUser throw Meteor's raw
"403 Username already exists", shown to the inviter as just "403". The
username now probes cats, cats1, cats2, ... to the first free variant
(bounded), and exhaustion raises the translated error-username-taken.
Tests: tests/inviteeUsername.test.cjs (11, positive and negative,
including the literal reported scenario).
Closes#804: already fixed - every popup is viewport-clamped with
internal scrolling since ac368de06 (#5667 geometry rewrite: maxHeight
from live viewport space, bounded flex layout) plus the 04eb8a8fb
(#6221) global 90vh clamp; the member picker inherits the shell, so
neither standalone nor the Sandstorm iframe can grow past the viewport.
Thanks to lemoer, ocdtrekkie and xet7.
The due-date picker configures defaultTime 17:00 (received/start/end use
now()), but an inverted guard in datePickerRendered applied it only when
the card ALREADY had a date - the one case where it is unnecessary - so
for dateless cards the time input stayed empty and submit stored a
hard-coded 12:00. Empty pickers now pre-fill with the configured
default, submit falls back to it, and existing dates keep their own
time. The 2018 AM/PM display/parse mismatch was already fixed by the
native HTML date/time inputs (79b94824e); week start already honors the
user preference.
Tests: tests/datePickerDefaultTime.test.cjs (10, positive and negative).
Thanks to Vlasterx, saschafoerster, suncobran and xet7.
- #2769: refresh drag geometry when the DOM changes mid-drag - cards no
longer drop into the wrong swimlane while someone adds a card.
- #1992: board import restores all five card date fields (and creator)
with fallbacks; receivedAt/endAt were never imported at all.
- #2292: archiving a swimlane archives its cards (mirroring lists);
restore brings back exactly the cards archived with it.
- #2494: client noCache card lookup returned a Promise since the
Meteor 3 port - multi-select board moves wrote sort: NaN everywhere.
- #1853: subtask "View it" falls back to the subtask's boardId when the
deposit board is not loaded, instead of silently doing nothing.
- #1554: restore the reactive dependency lost in 7673c77c5 so cards
added after board render accept sidebar label/member drops.
- #2306: "filter to selection" and every bulk action are board-scoped;
foreign-board cards can no longer be silently archived.
- #1437: new POST /users/logout API endpoint revokes the presented
resume token, or all tokens with {"all": true}.
All with positive+negative regression tests (see tests/*.test.cjs).
Closes#1389: already fixed - the standalone pencil edit button
(0cc63b810) plus the wrapped viewer (bf2b3b40c) guarantee an edit target
even when a full-width link fills the description.
Thanks to hever, Cactusbone, Vermeille, Vanclief, Miffe, IcedQuinn,
ppouliot, GhassenRjab and xet7.
Since 8c3ce4bb7 (2021) the "close the previous inlined form" call was a
silent no-op: the inlinedForm escape action registers with
enabledOnClick: false and EscapeActions.clickExecute skips such actions,
so clicking another checklist item left both edit forms open - and the
submit handlers find the template's FIRST textarea, so the wrong form's
text was saved over the submitted item. Subtasks reproduced the full
bug; the checklists workaround corrupted the shared open-form tracker so
Escape fell through and closed the whole card details pane. A pure state
manager (client/lib/inlinedFormManager.js) now enforces the single-open
invariant: opening a form closes the tracked previous one (popups
untouched, preserving the 2021 intent); close/destroy only reset the
tracker for the form they own.
Tests: tests/inlinedFormSingleOpen.test.cjs (9, positive and negative,
including the exact reported A->B->C repro).
Thanks to Beebo89 and xet7.
Two defects: the tokenizer flipped into regex mode on every '/' even
inside quoted strings, so a quoted date like '06/04/2020' broke
tokenizing and the whole filter silently no-opped; and date custom
fields store Date objects while the comparison selectors used strings
and parseInt - equality and ranges could never match, != matched every
card. Dates now parse per the user's date format (day-first formats
respected) into half-open Date-range selectors; non-date fields keep the
legacy selectors unchanged.
Tests: tests/advancedFilterDate.test.cjs (14, positive and negative).
Closes#2521: already fixed since v3.88 (9819c9f80) - the checklist
completed/uncompleted activity text was missing due to an activityType
name mismatch between model and template; the current unified activity
template, i18n keys and helpers render it correctly.
Thanks to k1ng440, ott29 and xet7.
addSubtaskCard inserted every subtask card with the constant sort -1,
leaving the whole list tied: a drop between two equal sorts computes a
zero increment, the move modifier is empty and Card.move() silently
discards it - the card snapped back, and a multi-selection drop wrote
the SAME sort to every selected card, permanently destroying their
relative order (the data-loss risk in the issue triage). Subtask cards
now append with a unique sort (max+1), and the sortable stop handler
detects degenerate gaps (ties/inversions) and repairs the siblings'
sorts to a strictly increasing order before recomputing the drop index;
healthy lists take the unchanged fast path.
Tests: tests/subtaskCardReorder.test.cjs (14, positive and negative,
including the exact issue scenario and a guard that the old
{base:-1, increment:0} result can never be produced again).
Thanks to jayki and xet7.
- #3453: publish the FULL subtask ancestor chain, not only direct
parents - the cross-board full-path label vanished after refresh.
- #3748 (defect half): skip unresolvable custom-field definitions on
linked cards - phantom empty rows and a template TypeError on every
card details render. The mirroring itself is by design: linked cards
are pointers; label/custom-field ids are board-scoped; name-based
inheritance is the copy feature, not the link feature.
- #3199: group each archived card with its own Restore/Delete links and
add a clear separator below the group (RTL-safe, theme-neutral).
- #3843: comment-editor uploads and the attachments popup now share one
null-safe meta builder, and tests pin that comment attachments list in
the card's Attachments section.
Closes#3748: mirroring is by design (see above); the only actionable
defect is fixed in this commit.
Closes#3378: already fixed - notification_cleanup moved off SyncedCron
to a guarded Meteor.setInterval singleton, and quave:synced-cron 2.4.0
handles the E11000 history collision that percolate 1.5.x missed.
Closes#3276: already fixed by d3625db75 - the spent-time badge helpers
now read the live Cards document reactively, so the overtime color flips
immediately; verified behaviorally against the real component.
Closes#3843: behavior verified present and now regression-locked.
Thanks to MPeti1, peterbecich, fxkr, jghaanstra and xet7.
The maximized card pane had no position of its own, so it remained an
in-flow item inside the scrolled board canvas and rendered off-screen
after scrolling down; desktop-mode floating-window rules also overrode
every maximize geometry rule, and inline drag offsets survived
maximizing. The pane is now viewport-fixed with insets that win over
both (drag position restores on minimize), RTL-safe. Regression test
with a CSS cascade resolver that fails on the pre-fix stylesheet:
tests/maximizedCardPosition.test.cjs.
Closes#3566: mobile shows one list at a time by design (xet7's own
comments in the issue); Move Card popup is the supported flow, and the
2021-era touch drag glitches were since fixed with drag handles.
Closes#3252: already fixed - comment deletes are guarded in
client/components/activities/comments.js (tagged #3252), checklists in
checklists.js, attachments via #5282.
Closes#3542: no code bug - redirect_uri is the same expression on the
client and server (mismatches come from ROOT_URL/proxy configuration,
per the Bug:Environment-specific label), and the login-style half was
fixed via #5695.
Thanks to pravdomil, 1ubuntuuser, gastoncollaud and xet7.
- CHANGELOG.md: Upcoming entries for the LDAP group filter fix (#4036),
invitation code regeneration (#4043), CJK add-card footer wrapping
(#4023) and team-to-board member sync (#4593), whose code shipped in
the previous commit.
- server/models/users.js: do not delete the invitation code in
onCreateUser before the account insert is committed - a failed insert
(duplicate username/email) burned the code for every retry (#4043).
Consumption happens after a successful insert; re-invites regenerate
stale codes.
- tests/teamBoardMemberSync.test.cjs: the #4593 regression tests (13,
positive and negative) for the team board-member sync helpers.
Thanks to zeisss-mercedes, jkoenig134, yuki-snow1823, Sylvain2703,
szymonsztuka and xet7.
#5695#4897#4654#5174#4825#5659#4730#4419#4158#4560#4036#1959#1971.
- Startup schema upgrade (server/lib/schemaUpgradeSteps.js): on start,
check that data from EVERY old WeKan version is migrated to the newest
structure and migrate only what is missing - all platforms, MongoDB and
FerretDB. Version-gated (marker stores WeKan version + datetime of the
last re-check; one findOne per boot until a new release), non-blocking,
live dashboard at /schema-upgrade-status showing the product name, fast
on big databases (distinct() set-joins, updateMany batches). Steps:
archived-flag backfill, swimlane structure + visibility rescue
(#1959#1971: cards under deleted/archived/foreign swimlanes reassigned
to a visible swimlane; card-insert hook validates supplied swimlaneIds),
merge-per-swimlane-lists (v7.98/v8.0x list copies merged back into
shared lists: no duplicate columns in Lists view, everything visible in
Swimlanes view, cards keep their swimlane), embedded checklist items,
customFields boardIds, allows* backfills, members isActive, permission
lowercase, filesystem path healing.
- Migration speed (5-hour reports): insertMany batches in the text phase,
preloaded target-record maps in the file phases.
- Snap attachment-repair + FILES_ONLY importer mode (see prior commit
wiring) extended with CFS FileSystem-store coverage
(ATTACHMENTS_STORE_PATH era), missing-binary reporting, and
migrate-gridfs-to-fs.mjs bucket fixes.
- OAuth2/OIDC: OAUTH2_LOGIN_STYLE=redirect honored (#5695); concurrent
logins no longer contaminate each other's user data (#4897); account
merge no longer wipes avatars/templates (#4560).
- LDAP: background sync fixed (#4654), password login rejected after LDAP
migration (#4419), LDAP_ENCRYPTION=true actually encrypts (#4158),
group filter accepts multiple groups and admits admin-sync groups
(#4036).
- All Boards tiles: per-list card counts + member avatars restored
non-reactively (#5174#4825). Uniform default list widths (#5659).
Declined invitations no longer resurrect membership (#4730).
- Tests with negative cases for every fix (tests/*.test.cjs; suite green
except the pre-existing environment-only htmljs failure).
Closes#5600: OAUTH2_CA_CERT already works - the fetch migration
(49a272d6c) applies the CA to all OIDC calls via https.Agent.
Closes#3828: board members seeing all board cards is by design (no
per-card visibility model), and organizations/teams now exist.
Thanks to mueschel, ArturRuta, gerardo-junior, fabianrbz, Miuler,
butteredCat-2021, Griiimm, preciousamorc, farwayer, LeoLu-eng, hnb1,
rangersdo and xet7.
- Migration importers: resolve the CollectionFS GridFS id from
copies.<bucket>.key too (the REAL old-WeKan layout; only
original.gridFsFileId / gridFsFileId / copies.gridfs.key were tried,
none of which exist there) via a shared resolveCfsGridFsId() in
releases/migrate-mongodb-to-ferretdb.mjs,
snap-src/bin/migrate-mongo3-to-ferretdb.mjs and
snap-src/bin/migrate-gridfs-to-fs.mjs. The modern importer now drives
CollectionFS extraction from cfs_gridfs.<bucket>.files itself, so a
missing/empty cfs.<bucket>.filerecord no longer silently skips every
file; binaries without a filerecord are still extracted to disk, and
filerecords whose binary cannot be located are reported on the
dashboard instead of dropped.
- Meteor-Files phase: match versions.*.meta.gridFsFileId without a
storage flag (WeKan's own getFileStrategy rule), handle every version
(not just 'original'), and add a bucket-driven sweep of <bucket>.files
by metadata.fileId so binaries are recovered even when the record's
flags say nothing about GridFS. GridFS-flagged versions with no
locatable binary are reported.
- mongo3 importer: copy the CollectionFS top-level boardId/cardId/
listId/swimlaneId/userId into meta on the created records - an
attachment without meta.cardId shows on no card.
- Admin Panel > Attachments: the Filesystem Storage paths were computed
from process.env.WRITABLE_PATH IN THE BROWSER, so they always showed
"/data". New admin-only getAttachmentStoragePaths method returns the
real Snap-aware paths (shared models/lib/attachmentStoragePath.js),
also used for the settings document default (was /data/attachments,
actual Docker path is /data/files/attachments).
- Snap: `snap run wekan.database ferretdb` now says it does NOT migrate
data, says when nothing was switched, and points at
`snap run wekan.migrate` for re-running the full migration.
- Tests with negative cases: tests/migrationAttachmentExtraction.test.cjs
(behavioral resolveCfsGridFsId from all three importers + wiring
guards) and tests/attachmentStoragePath.test.cjs, wired into
test:unit:node.
- Companion fix in wekan/FerretDB: accept documents with literal dotted
field names like MongoDB 3.6+, so migrated documents containing them
are no longer silently dropped.
- CHANGELOG.md: new Upcoming section with these changes.
If attachments are already missing after an earlier migration, run
`snap run wekan.migrate` - the source MongoDB data was never modified,
and the fixed importers re-create the attachment records and files.
Thanks to mueschel and xet7.
The right-docked card details window exposed two real issues in the
Playwright run (03-cards-operations failing in all three browsers):
- Archiving or deleting a card left its id in the openCards session
list, so the floating details window kept rendering above the board —
and, docked to the right edge, exactly over the archives sidebar,
intercepting its Restore/Delete clicks. Both handlers now drop the
card from openCards and clear currentCard.
- An OPEN board sidebar now stacks above the card window (z-index 2002
vs 2001), so the sidebar is always usable while a card is open.
Also from the same run: the new one-click cog spec closed its popup with
a keyboard Escape that proved unreliable across engines — it now uses
the popup's own X button (.js-close-pop-over) like 19-accessibility
does. The chromium-only 33-board-domains failure is an unrelated
token-login race in the auth helper (userId mismatch), not caused by
these changes.
tests/uiDensity.test.cjs got guards for both follow-ups (openCards
cleanup in archive+delete, sidebar z-index above the card window).
Updated ChangeLog.
Thanks to xet7.
tests/ferretdbHasData.test.cjs runs the real snap-src/bin/ferretdb-has-data
against crafted directories. This guard gates two critical switches
(migration-control's finish_success and mongodb-control's #6458/#6466
failure handler), so its semantics must never drift. Positive: non-empty
.sqlite, no -wal sidecar required (clean shutdown checkpoints it away),
one good file among empties. Negative: 0-byte .sqlite from a failed
migration, empty/missing directories, -wal/-shm-only leftovers, and a
DIRECTORY named *.sqlite. All eight behaviors verified against the
actual script. Wired into test:unit:node; ChangeLog updated.
Thanks to xet7.
The 2026-07-16_18-33-28 test run failed one spec in all three browsers:
the #6465 one-click board-settings cog added a second .js-open-board-menu
element, so 03-cards-operations' bare class locator resolved to 2
elements and tripped Playwright's strict mode. The archives test now
scopes the click to the sidebar's instance (.board-sidebar
.js-open-board-menu), keeping its 'from inside the sidebar' intent.
Also added a spec for the new cog itself to 15-board-actions: visible in
#header-main-bar, popup not open before the click (negative), ONE click
opens the real boardMenu popup (.js-open-archives inside), Escape closes
it again.
Thanks to xet7.
tests/cpuExecWiring.test.cjs, with negative cases: every Linux bundle in
release-all.yml embeds cpu-exec + its own arch's static qemu-user (arm64
and the QEMU-built extra arches replace the inherited amd64 one, extra
arches tolerantly), the Windows/macOS bundles strip both Linux-only
files, qemu-user-static is installed in every bundle-building job and
both sandstorm jobs, the Sandstorm .spk ships cpu-exec + qemu via
build-deps.sh (tolerant when qemu is not installed), the Docker
entrypoint and the bundle launcher route ferretdb/node through cpu-exec
while keeping direct-exec fallbacks for bundles that lack it, and the
snap ships cpu-exec via the snap-src helpers part. Wired into
test:unit:node; complements tests/cpuExec.test.cjs (the script's own
behavior).
ChangeLog Upcoming: the regression-tests entry now lists the cpu-exec
wiring test, the subtasks default-board test (#6456) and the FerretDB
fork's integration-test fixes.
Thanks to xet7.
Fixes#6456.
The lazily-creating getters for the default subtasks helper board and
its landing list still used the sync Boards.insert / Swimlanes.insert /
Lists.insert / Boards.update APIs that Meteor 3 removed from the server,
so creating a subtask crashed with 'insert is not available on the
server. Please use insertAsync() instead' whenever the ^Board^ helper
board had to be created. The list creator additionally read
getDefaultSwimline()._id, which is a Promise on the Meteor 3 server.
- getDefaultSubtasksBoardId / getDefaultSubtasksListId are now PURE
getters (no creation), matching the #3868/#2256 rule that only the
server may create these.
- The server-side lazy creation moved into getDefaultSubtasksBoardAsync
and getDefaultSubtasksListAsync using insertAsync/updateAsync and
getDefaultSwimlineAsync — the exact path addSubtaskCard uses.
- The never-called date-settings twins had the same sync calls and no
server-only guard at all; they are pure getters now too.
- tests/subtasksDefaultBoard.test.cjs guards the async creators and that
no sync collection writes come back to models/boards.js, wired into
test:unit:node.
- Updated ChangeLog.
Thanks to sjohnen and xet7.
That commit's message listed all ten issues after a single Fix keyword,
but GitHub only auto-closes a reference that directly follows its own
closing keyword, so only the first issue was closed on push. Closing
the rest here; the fixes themselves are already on main.
Fixes#6455. Fixes#6458. Fixes#6466. Fixes#6467. Fixes#6468.
Fixes#6472. Fixes#6465. Fixes#5547. Fixes#5282.
Thanks to xet7.
Fix#6458 completely: the reporter's host runs inside a hypervisor that
masks AVX, and the old per-tool AVX wrappers (amd64-only, PATH-based)
were bypassed by absolute-path mongod invocations.
- New general snap-src/bin/cpu-exec: runs any binary directly when the
CPU has the features it declares (--features x86_64=avx,aarch64=atomics
or WEKAN_REQUIRED_CPU_FEATURES) and through same-arch qemu-user when
not; plain zero-overhead exec with no declared features. Lookup order:
WEKAN_QEMU_USER, qemu-<arch> next to the script (bundle/Docker),
$SNAP/qemu-<arch>, the snap's migratemongo/avx one, then system
qemu-user(-static). Clear error + direct run when no qemu exists, so
the real SIGILL surfaces instead of hiding.
- Snap: mongodb-control and migration-control run every mongod 7 through
cpu-exec, so MongoDB works without AVX (slower) and migration can READ
modern MongoDB data on such CPUs; completed-FerretDB switch and
migration fallbacks unchanged (switch_to_completed_ferretdb extracted).
- release-all.yml: every Linux bundle .zip ships cpu-exec + this-arch's
static qemu-user (amd64/arm64/ppc64le/s390x/riscv64; stripped from
win64/mac bundles), flowing into Docker and Snap automatically;
Sandstorm .spk gets both via build-deps.sh. Bundle launcher and Docker
entrypoint route node/ferretdb through cpu-exec.
- Docs: mongodb-avx-qemu.md and mongodb-raspi4-qemu.md document the
built-in mechanism.
Tests, with negative cases, for the newest features and fixes at
changelog: htmljs array-vs-attributes (#6459), description draft
normalize/reset (#6455), comment draft lifecycle (#5547), attachment
delete guard (#5282), rules move actions + import normalization (#6472),
snap mongod-failure/migration recovery (#6458#6466), FerretDB polling
defaults (#6467#6468), UI density guards for every #6465 fix, and a
behavioral cpu-exec test against fake cpuinfo + fake qemu. All wired
into test:unit:node. Go table tests for the FerretDB SQLite filter
pushdown are in the wekan/FerretDB repo.
Updated ChangeLog.
Thanks to xet7.
- #6459: card More menu rendered empty (no Delete): the vendored jade
compiler's htmljs patch made isConstructedObject(Array) false, so
'label {{_ ...}}:' lines got their content array mis-assigned as tag
attributes and Blaze threw 'foreign objects in attributes' killing the
whole cardMorePopup. Also fixed the list More link input (nonexistent
rootUrl helper -> absoluteUrl).
- #6455: saving a card description never removed a pre-existing unsaved-
edits record, and the draft/description comparison mismatched on Markdown
hard-breaks and empty descriptions, re-creating a phantom draft on every
save. Save now clears the draft; both sides normalized the same way.
- #5547: comment drafts: the draft-saving escape handler had been disarmed
since 2019 (flag with no setter), so a comment being written was lost
when the card closed. Drafts are now saved continuously (debounced),
flushed on teardown, cleared on submit; text no longer visually vanishes
on outside clicks.
- #5282: attachment delete: guard against the doc being already evicted
from Minimongo ('Removed nonexistent document'), like the existing
comment/checklist guards.
- #6472: rules move card to top/bottom: fall back to the card's current
list when listName does not resolve (was a swallowed TypeError = rule
silently did nothing); wizard stored listTitle which the engine never
reads (listName); empty destination wrote sort +-Infinity; rules JSON/CSV
import now uses the rules.createRule method instead of client inserts
rejected into minimongo limbo, and defaults missing trigger matching
fields to '*'; empty boardId no longer overrides the real board; the
server-side orphaned-cards fallback in List.cards()/cardsUnfiltered()
now actually resolves its async swimlane lookup.
- #6465: desktop density back to WeKan 6.09: base font 14px and headings
22/18/16px (vw clamps resolved to 18/24px on desktop), All Boards
wrapper padding 10px/15px, card details docks to the right edge instead
of floating on top of its own card (still draggable), 20px canvas
padding instead of ~48px white borders, removed the force-all-card-text-
to-title-size rule, admin org/team table Select/Unselect-all header
links are compact icons and header cells may wrap, zoom pill no longer
cut off, one-click board settings cog in the board header.
- #6458#6466: snap: mongodb-control now checks mongod's exit status and
does an AVX pre-flight (MongoDB 5+ needs AVX; without it mongod SIGILLs)
instead of pinging a dead port forever behind a 502: it switches to a
completed FerretDB migration, or (re)runs the migration (AVX-free) with
a 3-attempt guard. Migration importers no longer fail the whole run on
>=10 per-item errors ('Avatars Errors' etc.), and a failed 3.x-source
migration keeps the copied FerretDB data + checkpoint and resumes next
start instead of deleting it and 'falling back' to a mongod 7 that
cannot open 3.x data files.
- #6467#6468: with FerretDB, calm Meteor's no-oplog polling storm
(METEOR_POLLING_THROTTLE_MS=2000, METEOR_POLLING_INTERVAL_MS=30000
defaults in the snap and bundle launcher; overridable). The FerretDB
fork gets filter pushdown, a sane pool size and less locking in its own
repo.
- Updated ChangeLog.
Thanks to xet7.
Follow-up to PRs #6469 and #6470:
- The login-time org/team sync now clears the inherited method invocation
context (DDP._CurrentMethodInvocation.withValue) around a normal
Meteor.callAsync, instead of invoking the raw method handler with a
hand-made invocation object. This goes through Meteor's normal method
dispatch (check() argument audits, EJSON argument cloning) while still
being the server-to-server path (connection null) that the admin guard
in setUserOrgsTeamsFromLdap allows.
- Added the ddp package dependency to wekan-ldap for the DDP symbol.
- Fixed indentation in localizeAvatarFromBuffer and restored the comment
explaining the proceedAfterUpload argument.
- Removed a stray double blank line in the LDAP login handler.
- Updated ChangeLog about the merged LDAP fixes.
Thanks to xet7.
Added a build-sandstorm job that runs after the GitHub Release is created,
with the same steps as the standalone sandstorm.yml workflow (which still
exists for testing the .spk build on its own), attaching
wekan-<version>-sandstorm.spk to the Release. Non-blocking, so the
experimental Sandstorm build never fails or delays the release.
Thanks to xet7.
Both snap jobs (snap-native and snap-launchpad) pushed only to candidate, beta
and edge, and stable had to be released by hand afterwards. Publish to
stable,candidate,beta,edge instead. base: core24 allows stable and candidate.
Thanks to xet7.
A big migration can run for 5 hours, so being interrupted by a snap refresh,
`snap stop` or a reboot is normal - but it was treated as a failure, and every
interruption threw away all of the work.
Distinguish interrupted from failed. snapd's SIGTERM killed the importer, bash
resumed at the line after the importer call, read the signal exit code (143) as
"the migration failed", and fail_and_run_mongodb deleted the partial FerretDB
SQLite and set migrate=off. Now a SIGTERM/SIGINT trap (and an importer exit code
>= 128, since snapd signals every process in the service cgroup) keeps the
partial database and the checkpoint, leaves auto-migration on, and hands back to
mongodb-control so WeKan keeps serving from MongoDB until the next start resumes.
Both importers flush their checkpoint on the signal and exit 128+signo.
Checkpoint files, not just collections. Only fully-copied collections were
checkpointed; the file phase started over. Worse, the destination path was picked
with an "append _1, _2, ... while the file exists" loop, so every already-
extracted attachment was written a second time under a new name, orphaning the
first copy - a resumed migration silently needed twice the disk the up-front
space check budgeted for. Files are now checkpointed individually (recorded only
once the bytes are on disk AND the record points at them, and re-verified by size
on resume, so a half-written file is never mistaken for a finished one) and
skipped when resuming. The path is now deterministic: idHex is the record's _id,
so two records never collide and an existing file there can only be this record's
own partial extraction, which is meant to be overwritten.
Discard the checkpoint together with the SQLite it describes. The checkpoint
lives in $SNAP_COMMON, not in the SQLite dir that discard_partial_ferretdb wipes,
so it survived. The next migration then trusted it, skipped every "already
migrated" collection, copied only the rest into the now-empty database, and
reported success - leaving the snap serving a FerretDB missing most of its data.
The MongoDB 3 importer had no checkpoint at all and now resumes collections and
files the same way. The source MongoDB data is never touched on any of these
paths.
Thanks to markusst1982 and xet7.
Meteor's accounts-base "resume" login handler projects a single array element
with the positional operator:
Meteor.users.findOneAsync(
{ 'services.resume.loginTokens.hashedToken': hashedToken },
{ fields: { 'services.resume.loginTokens.$': 1 } })
FerretDB rejects that find with "Executor error during find command :: caused
by :: positional operator '.$' couldn't find a matching element in the array"
(code 51246).
Resume is how every already-logged-in browser re-authenticates on page load and
on every DDP reconnect, so the throw logged users out, the client retried the
connection, and the retry loop pinned the FerretDB CPU - the board looked broken
right after a migration that had just taken hours.
Replace the handler with one that projects the whole loginTokens array and picks
the matching token in JS. Upstream already does exactly this in its own $or
fallback query ("Cannot use ...loginTokens.$ positional operator with $or
query"), so the array is known to be small enough to fetch, and behaviour is
otherwise unchanged - including looking up the hashed token on its own first so
the unhashed token is not sent to the database unless it has to be.
Closes#6457
Thanks to markusst1982 and xet7.
9.93 is released, so move the two commits made after it into a fresh
"# Upcoming WeKan ® release" section above v9.93, organized like the previous releases:
Features: database setting authoritative + snap run wekan.database mongodb|ferretdb +
no-downtime handling of a failed migration.
Bugs: migration skips dotted (GridFS/CollectionFS/system) collections that FerretDB
rejects, and cleans up a partial SQLite from a failed migration.
No entry text changed; only relocated.
Problem: the control scripts force-switched to FerretDB whenever a *.sqlite file existed, so
`snap set wekan database=mongodb` did not stick and a failed migration caused downtime with no
way to keep WeKan running on MongoDB while fixing it.
- The `database` snap setting is now AUTHORITATIVE. Removed the "FerretDB SQLite present ->
force ferretdb / disable mongodb" overrides from wekan-control, ferretdb-control and
mongodb-control. wekan-control keeps only the safety guard (never start an EMPTY FerretDB
while MongoDB has data).
- New `snap run wekan.database mongodb|ferretdb` command (bin/wekan-database): switch which
database WeKan runs on. `mongodb` also pauses auto-migration (migrate=off) so it does not
switch back; `ferretdb` requires a non-empty SQLite first.
- Auto-migration can be paused: migration-pending now also returns "not pending" when
`snap get migrate` == off. A migration that FAILS now runs fail_and_run_mongodb: delete the
partial SQLite, set migrate=off, and exec mongodb-control so WeKan keeps working on MongoDB
(no downtime, no retry loop). fall_back_to_mongodb likewise sets migrate=off instead of the
done marker. `snap run wekan.migrate` re-enables migrate and retries.
- finish_success now restarts wekan.wekan so it reconnects to FerretDB after a successful
migration instead of waiting for the now-stopped MongoDB.
Registered the `database` app in snapcraft.yaml and snapcraft-core26.yaml (also added the
previously-missing migrate/maintenance apps to core26).
Thanks to xet7.
Two bugs surfaced when the MongoDB 7 -> FerretDB migration finally connected (after the
db-eval fix):
1. FerretDB v1 rejects collection names containing a dot:
invalid key: "attachments.chunks" (key must not contain '.' sign)
Both importers tried to copy GridFS internals (attachments.chunks/.files, avatars.*,
cfs_gridfs.*) and CollectionFS cfs.<bucket>.filerecord collections as TEXT, aborting the
migration. Now the text phase skips ANY dotted collection: none of WeKan's real data
collections contain a dot, and the dotted ones are GridFS internals (extracted in the file
phase), CollectionFS filerecords (now turned into bare attachments/avatars records in the
file phase via replaceOne upsert:true) or system.* collections.
2. The failed migration had already copied some text collections, leaving a NON-EMPTY but
incomplete files/db/wekan.sqlite. ferretdb-has-data then saw it as "migrated", so
mongodb-control disabled MongoDB and the snap tried to serve a half-migrated FerretDB. On
any failure migration-control now runs discard_partial_ferretdb: stop the temp target
FerretDB and delete the partial SQLite, so the snap cleanly keeps MongoDB and retries. The
finish_success switch is also gated on it (`rc -eq 0 && finish_success`).
Source MongoDB data is never modified or deleted.
Thanks to xet7.
9.92 is released, so move everything committed after it into a fresh
"# Upcoming WeKan ® release" section above v9.92, organized by topic like the
previous releases (features, then bugs):
Features: wekan.migrate command; maintenance mode + maintenance page.
Bugs: db-eval driver resolution (ESM ignores NODE_PATH); decide FerretDB vs
MongoDB from a non-empty SQLite database (not the marker); mongod-7
readiness probe fast-fail.
v9.92 is restored to what it shipped, including its "migration-success marker is
now authoritative" bug entry (which the Upcoming "presence of a FerretDB SQLite
database" entry supersedes). No entry text changed; only relocated.
ready_via_node waits up to 45s for the temporary source mongod to accept connections, to
allow a large MongoDB 6/7 database time to replay its journal (#6454). Add a liveness check:
if the mongod process (SRC_PIDFILE) has already exited - e.g. mongod 7 cannot open old
MongoDB 3.x data - stop waiting immediately and drop to the MongoDB 3.2 reader, instead of
pinging a dead process for the full timeout. Matters across many unattended 6.09 -> 9.x
upgrades. (With --fork, start_mongod usually already returns non-zero when mongod cannot open
the data; this also covers a mongod that started then died mid-probe.)
Thanks to xet7.
For unattended upgrades at scale (6.09/MongoDB 3.2 -> 9.x/FerretDB across many servers), the
"did the migration succeed?" test must not be fooled by a 0-byte stub file.
- New shared helper snap-src/bin/ferretdb-has-data: exit 0 only if a *.sqlite file bigger than
0 bytes exists in files/db. Used by mongodb-control, ferretdb-control and wekan-control in
place of the previous "*.sqlite exists" glob, so the DB decision is based on a real,
non-empty FerretDB database. (Deliberately does NOT require the -wal file, which SQLite may
checkpoint away when FerretDB is stopped - that would cause false negatives at startup.)
- migration-control finish_success now verifies the non-empty SQLite database exists (and logs
the WAL/shm sidecars) BEFORE switching the snap to FerretDB. If the importer returns success
but wrote no data, it does not switch or set the done marker - MongoDB data is kept and the
migration retries on the next start.
Thanks to xet7.
db-eval is the Node helper the snap uses instead of mongosh to check whether MongoDB/FerretDB
is up, elect the replica-set primary, and probe mongod-7 readiness during migration. It did
`import { MongoClient } from 'mongodb'` while the wrapper set NODE_PATH to the bundled driver
-- but Node's ESM loader IGNORES NODE_PATH (only the CommonJS loader honors it). So the import
resolved nothing and db-eval exited BEFORE opening any connection, making every
ping/primary/rs-* check fail.
This single bug explains the whole family of failures seen this week:
- WeKan looping "MongoDB not ready yet, retrying..." / "FerretDB not ready yet..." forever
even though the database was running and listening;
- replica-set initialisation failing;
- the MongoDB -> FerretDB migration falling back to MongoDB 3.2 because the mongod-7
readiness probe never connected (mongod 7 opened the data fine, but db-eval couldn't reach
it), and 3.2 cannot read WiredTiger-7 files -> no FerretDB SQLite produced.
Proof from the migration source mongod log: mongod 7 reached "Waiting for connections" with
ZERO "Connection accepted" before the readiness timeout killed it; the only connection in the
file was the native `mongo` shell to mongod 3.2.
Fix: resolve the driver with createRequire (CommonJS resolution honors NODE_PATH and the
bundle layout), anchored inside the modern bundle (npm-mongo v6 first). Applied the same fix
to the standalone migrate-schema-v843.mjs and migrate-gridfs-to-fs.mjs tools.
Thanks to xet7.
wekan.migrate (bin/wekan-force-migrate): force a fresh MongoDB -> FerretDB migration,
ignoring the database setting and the migration marker. Removes any partial FerretDB SQLite +
resume checkpoint, sets database=mongodb, and starts wekan.mongodb so migration-control
re-reads the existing MongoDB (3 or 7) data and migrates again. Source MongoDB data untouched.
wekan.maintenance on|off (bin/wekan-maintenance): toggles a $SNAP_COMMON/.wekan-maintenance
marker. While present, mongodb-control / ferretdb-control do NOT auto-migrate or auto-disable,
so `snap start wekan.mongodb` or `snap start wekan.ferretdb` stays up and you can reach the
data over the MongoDB wire protocol on port 27019 (one at a time - they share the port).
Maintenance page (bin/wekan-maintenance-page.mjs): in maintenance mode wekan-control execs
this instead of WeKan; it serves an "under maintenance" page (HTTP 503) on the web port for
ALL URLs. It shows the Admin Panel product name if set (not "WeKan"), read from
$SNAP_COMMON/.productname.txt, which is cached while a database is running by wekan-control
(new db-eval `product-name` command) and by both migration importers - so it is available even
when both databases are stopped.
Registered `migrate` and `maintenance` apps in snapcraft.yaml.
Thanks to xet7.
Regression fix. An earlier change made mongodb-control/ferretdb-control/wekan-control key
the "use FerretDB / disable MongoDB" decision off the .migration-to-ferretdb-done marker.
But migration-control ALSO writes that marker when the migration falls back (data
unreadable, tools missing) or when there is nothing to migrate - producing NO FerretDB
data. So on a server whose MongoDB 7 -> FerretDB migration failed:
- mongodb-control saw the marker -> "migration already finished; disabling mongodb
service" -> disabled MongoDB;
- FerretDB SQLite (files/db) was EMPTY;
- database=mongodb, so wekan-control waited on MongoDB -> "MongoDB not ready yet,
retrying in 5 seconds..." forever, and wekan.mongodb refused to stay up.
Now all three scripts decide from ACTUAL DATA ON DISK: MongoDB is disabled / FerretDB is
forced ONLY when a *.sqlite database exists in files/db. An empty files/db means the
migration did not succeed, so mongod starts normally and WeKan keeps working (and the
migration can be retried). The marker still prevents re-running a finished migration.
Thanks to xet7.
Upcoming section: document that statfs is in snapd's default seccomp allow-list, so the
MongoDB -> FerretDB migration's free-space guard is active on Snap; quotactl is not used and
its absence is covered by the ENOSPC/EDQUOT write-failure fallback.
Document that statfs (and statfs64/fstatfs/fstatfs64/statvfs/fstatvfs) are in snapd's
default seccomp allow-list (snapcore/snapd interfaces/seccomp/template.go), so free-space
checks WORK inside a snap (which also has no per-snap disk quota by default, so statfs shows
real usable space). quotactl is NOT in the allow-list, but it is never called; a snapd
storage-quota group is still caught by the ENOSPC/EDQUOT write-failure path. Only Sandstorm
grains genuinely lack statfs (FUSE unimplemented, quotactl blocked). No behaviour change.
migrate-mongodb-to-ferretdb.mjs (the modern MongoDB 6/7 driver importer that
migration-control runs, packaged from releases/):
- Resolve the mongodb driver by probing the WeKan bundle with createRequire anchors and
USE WHAT WORKS: prefer a v6+ driver that exposes GridFSBucket and speaks OP_MSG, since
an older bundled v2 driver's OP_QUERY is rejected by FerretDB ("Unsupported OP_QUERY
command: update"). A plain `import from 'mongodb'` could pick up that v2 driver. Fail
fast with a clear message if no usable driver is found.
- Bring it to parity with migrate-mongo3-to-ferretdb.mjs's data-safety and UX: measure
total attachment/avatar bytes up front and STOP before extracting if they will not fit;
if space runs out mid-run, stop + delete the partially migrated files + report how much
more disk is needed (a full disk can corrupt the still-running source MongoDB);
detect ENOSPC/EDQUOT where free space is not measurable (Sandstorm); show a live
per-file progress bar; and translate the dashboard into the browser's language.
Existing checkpoint/resume, schema upgrade, orphan fixing, compaction and index
creation are unchanged.
wekan-control:
- On startup, decide the database from what data actually exists on disk (FerretDB SQLite
at $SNAP_COMMON/files/db, MongoDB WiredTiger at $SNAP_COMMON), not the `database` setting
alone. If ferretdb is selected but its SQLite is empty while MongoDB still has data,
revert to database=mongodb instead of starting an EMPTY FerretDB that would look like all
data was lost, and tell the user to run the migration first.
Thanks to xet7.
Symptom: WeKan never started, looping "MongoDB not ready yet, retrying in 5 seconds..."
forever, even though mongod appeared to be running.
Cause: mongodb-control started mongod with a HARDCODED --wiredTigerCacheSizeGB 32. On any
server with less than ~34 GB RAM, mongod was OOM-killed seconds after startup. That
included the temporary mongod started to initialise the replica set: it died before
db-eval could reach it (log showed "child process started successfully" then 30 s of
failed pings), so the replica set was never initiated. The final mongod then ran with
--replSet rs0 but no config, no PRIMARY was elected, and wekan-control (which needs the
replica-set primary for Meteor change streams) waited forever.
Fixes in mongodb-control:
- WiredTiger cache is now RAM-aware: ~50% of (MemTotal - 1 GB), the same formula mongod
uses for its own default, capped at 32 GB, min 1 GB. Override with
MONGODB_WIREDTIGER_CACHE_GB. Applied to both the temp replica-set-check mongod and the
final mongod.
- Temporary-mongod readiness wait raised 30 s -> 90 s so a large database that needs
longer to replay its journal still gets its replica set initialised/reconfigured.
Fix in migration-control:
- The temporary source mongod started to READ the data during a MongoDB -> FerretDB
migration now uses a conservative RAM-aware cache (1/4 of (MemTotal - 1 GB), cap 8 GB,
override MIGRATION_MONGOD_CACHE_GB), leaving room for the target FerretDB and the Node
importer that run alongside it. Applied only to the mongod 7 (WiredTiger-only) read so
the flag can never clash with a legacy MMAPv1 MongoDB 3.x dbpath.
Thanks to xet7.
Symptom: on an already-migrated install, `sudo snap start --enable wekan.ferretdb`
started FerretDB and then it immediately stopped itself, logging:
"database is 'mongodb', not 'ferretdb'. Disabling ferretdb service."
Cause: every DB service keyed its behaviour off the `database` snap setting alone. If
that setting was never flipped to ferretdb (e.g. the migration's INTERNAL `snapctl set`
did not re-run the configure hook), ferretdb-control read `database=mongodb` and disabled
itself, so FerretDB could not be started by hand without first running
`snap set wekan database=ferretdb`.
Fix: the marker $SNAP_COMMON/.migration-to-ferretdb-done is now authoritative.
- ferretdb-control: if the marker exists, repair database=ferretdb and keep running
instead of self-disabling.
- mongodb-control: if the marker exists, repair database=ferretdb and disable mongodb,
BEFORE the migration-pending check so a finished migration is never re-attempted.
- wekan-control: already forces ferretdb on the marker (previous commit).
Immediate recovery on an already-migrated install running an older release:
sudo snap set wekan database=ferretdb
Thanks to xet7.
wekan-control (the wekan.wekan service) only WAITED for the database DATABASE points at
and never started a DB service, so if both wekan.ferretdb and wekan.mongodb were
stopped/disabled WeKan hung on "MongoDB not ready yet, retrying..." forever.
Now, on start, wekan-control:
1. If the migration-success marker $SNAP_COMMON/.migration-to-ferretdb-done exists,
forces database=ferretdb, then enables + starts wekan.ferretdb and stops + disables
wekan.mongodb. This self-heals the case where the migration's INTERNAL `snapctl set`
never re-ran the configure hook that flips the services.
2. Before waiting, enables + starts whichever DB service is configured
(snapctl start --enable = snap enable + snap start), so a merely-stopped DB is
brought back up instead of WeKan waiting forever.
Uses the snap instance name so parallel installs target their own services.
Thanks to xet7.
v9.91 was cut as a release, so move the Snap FerretDB service-flip fix out of it into a
fresh "# Upcoming WeKan ® release" section above v9.91. No entry text changed; only
relocated.
After a MongoDB -> FerretDB migration, WeKan hung on "MongoDB not ready yet, retrying..."
because no database was running: wekan.ferretdb stayed disabled and wekan.mongodb stayed
enabled-but-stopped.
Root cause: the wekan.ferretdb service disables itself while database=mongodb, and the
service flip that re-enables it lives in snap/hooks/configure. migration-control switches
the backend with an INTERNAL `snapctl set database=ferretdb`, but an internal snapctl set
does not re-run the configure hook, so the flip never happened.
Fix: finish_success now does the flip itself (mirroring the configure hook's ferretdb
branch): enable+start+restart wekan.ferretdb, then stop+disable wekan.mongodb, using the
snap instance name so parallel installs target their own services. FerretDB is started
before MongoDB is disabled so a DB is up first.
Immediate recovery on an already-migrated install:
sudo snap start --enable wekan.ferretdb && sudo snap stop --disable wekan.mongodb
v9.90 (2026-07-15) was already cut, but later work had been appended to it. Create a new
"# Upcoming WeKan ® release" section above v9.90 and move into it everything committed
after the v9.90 cut:
- new features: Snap/Sandstorm migration dashboard (per-file progress + disk guard)
- updates: i18n 10 more languages, native-name fixes, pull English-regression report
- tests: dev server also frees the MongoDB port
- bugs: MongoDB 3->FerretDB EJSON + stay-on-page; "Always show all code as plain text"
reactivity race; i18n browser-language -> file mapping fixes
v9.90 is restored to its cut contents (MimeBleed CRITICAL + the original features/updates/
tests/bugs). No entry text changed; only relocated.
Document the Upcoming i18n work: the ja_JP->ja clobber fix, case/underscore-insensitive
language matching, Chinese subtag fallback + zh alias, the cmn tag typo fix (bugs), and
the 10 new selectable languages, native-name corrections, and the pull-translations
English-regression report (updates).
Add languages.js entries for translation files that had none (so they were pulled from
Transifex but never selectable): ca-valencia (Català (Valencià)), en_ID/en_SG/en_TR
(English (Indonesia/Singapore/Turkey)), es_CO (Español (Colombia)), fr-FR (Français
(France)), pt_PT (Português (Portugal)), ru-UA (Русский (Украина)), tk_TM (Türkmençe
(Türkmenistan)), wuu-Hans (吴语(简体)). Skipped km_KH and ru_RU (duplicate the existing
km-KH / ru-RU entries) and zh (already aliased to zh-Hans).
Fix language names that used the English name instead of the native one:
ace "Acehnese" -> "Bahsa Acèh"
cy "Welsh" -> "Cymraeg"
cy-GB"Welsh (UK)" -> "Cymraeg (Y Deyrnas Unedig)"
af_ZA"Afrikaans (South Africa)" -> "Afrikaans (Suid-Afrika)"
Fix several browser-language -> translation-file mapping gaps:
- isLanguageSupported is now case-insensitive (requested) via a new TAPi18n.resolveTag()
that maps any input to the canonical languages.js tag case-insensitively AND
underscore<->hyphen-insensitively (so 'zh-hant'->'zh-Hant', 'JA-JP'->'ja-JP', browser
'af-ZA'->legacy key 'af_ZA'). loadLanguage/setLanguage/ensureLanguageLoaded resolve
through it, so the loaded file, the i18next code and the stored current tag all agree.
- Bare base codes with no plain-code file resolve via an alias map: 'zh' -> 'zh-Hans'
(Chinese has only script/region files), so plain 'zh' gives Simplified, not English.
- Browser detection (client/lib/i18n.js) now strips trailing subtags progressively
instead of jumping to the first segment: 'zh-Hans-CN' -> 'zh-Hans', 'zh-Hant-TW' ->
'zh-Hant' (Chrome sends these), rather than -> 'zh' -> English.
- languages.js: fix the 'cmn' entry whose tag was the typo 'cnm' (and code 'cn'), which
mismatched supportedLngs and broke selecting Mandarin; now tag/code = 'cmn'.
Adds i18n.test.js coverage for resolveTag, case-insensitive isLanguageSupported, the zh
alias, the underscore/hyphen match, and each Japanese variant loading its own file.
Two Transifex languages were writing to the same local file: .tx/config lang_map had
`ja_JP: ja`, which sent Transifex's ja_JP INTO imports/i18n/data/ja.i18n.json — the same
file the real Japanese (`ja`) uses. On `tx pull -a -f`, the emptier/untranslated ja_JP
overwrote ja.i18n.json, reverting Japanese strings to the English source. Map it to its
own file instead: `ja_JP: ja-JP` (-> ja-JP.i18n.json), matching languages.js which already
has three separate Japanese entries (ja -> ja.i18n.json, ja-JP -> ja-JP.i18n.json,
ja-Hira -> ja-HI.i18n.json). No other lang_map target collides. languages.js already loads
each to its own file, so no change needed there.
Also add releases/translations/report-english-regressions.mjs and call it from
pull-translations.sh: after pulling, it lists the language files where a previously
translated string reverted to exactly the English source (i.e. untranslated on Transifex),
so the regression is visible instead of being committed silently.
The MongoDB 3 -> FerretDB migration work had grown into one large bug bullet that
actually contained major features (live per-file progress bar, chunk-by-chunk streaming,
disk-space safety guard + rollback, Snap/Sandstorm/availability detection). Move those to
the "new features" section as their own bullet, and keep the EJSON-resolution fix +
stay-on-page hand-off as the bug entry (with a pointer to the feature bullet).
Previously the statfs free-space guards were gated on IS_SNAP. But a Snap container may
also be unable to report free space (statfs fails). Drive the behaviour off whether free
space is actually MEASURABLE instead:
- updateDiskFree: skip statfs only on Sandstorm (where it is known-unreliable — reports the
host disk, not the grain quota); everywhere else ATTEMPT statfs, and on failure leave
state.diskFree < 0 (unknown).
- ensureDiskForFile / the up-front total-size pre-check: run only when state.diskFree >= 0
(measurable); otherwise skip and rely on an ENOSPC/EDQUOT write failure.
- Dashboard already hides the free-space figure and switches to "free space required for
all files" whenever diskFree < 0, so a Snap-with-no-statfs degrades exactly like
Sandstorm: try, and stop + delete + report on a write failure.
IS_SNAP is now only used for the platform log line. CHANGELOG updated.
The statfs free-space checks only make sense on the Snap (a real filesystem whose size we
can read). A Sandstorm grain cannot see its free space or quota — its FUSE layer does not
implement statfs and quotactl is blocked (confirmed in the Sandstorm source), and statfs
on the writable /var reports the host disk, not the grain quota. So:
- Detect Snap via $SNAP (IS_SNAP) and Sandstorm via SANDSTORM / SANDSTORM_RAW_MONGO_PATH /
METEOR_SETTINGS.public.sandstorm (IS_SANDSTORM).
- Run the statfs up-front + mid-run free-space guards ONLY on Snap.
- On Sandstorm (and anything else), just try to migrate and detect "out of space" from an
actual ENOSPC/EDQUOT write failure (in the streaming writer and in the file-loop catches),
which triggers the same stop + delete-migrated-files + report rollback (onDiskAbort).
- Dashboard shows the free-space figure only when known (Snap); on Sandstorm it says free
space is unknown and phrases the requirement as "free space required for all files".
Sandstorm source used only for research (grain cannot query disk/quota); not committed.
CHANGELOG updated.
Add the missing Upcoming bug entry for the alwaysShowCodeAsText fix (the viewer read a
stale ReactiveVar and never re-rendered, so the toggle did nothing; now set inside the
mentions helper from the same reactive setting it already reads). The MongoDB 3 ->
FerretDB migration progress/disk-guard improvements were already recorded in the mongo3
migration entry.
Migration dashboard + migrator improvements for the MongoDB 3 -> FerretDB path:
- Live per-file progress: the file currently being extracted shows its name, size,
"file N of TOTAL", a percent progress bar, and whether it is an attachment or an
avatar. File-type words use WeKan's existing translations in the viewer's browser
language (Accept-Language), read best-effort from the bundle with an English fallback.
- RAM-safe streaming: each GridFS file is streamed chunk-by-chunk (sorted by n) straight
to disk via a spawned mongoexport + readline, with natural back-pressure, so a multi-GB
attachment never lands in memory. The old approach buffered the whole file through
mongoexport's 512 MB stdout maxBuffer and failed on big files.
- Disk-space guard (a full disk can corrupt the still-running source MongoDB): the
dashboard shows remaining free space; the migrator measures the total size of all
attachments+avatars up front and stops immediately if the volume cannot hold them; it
also stops mid-run if free space drops below a safety margin (default 1 GB,
MIGRATION_MIN_FREE_BYTES). On such a stop it deletes the partially-migrated files to
free the space back, reports how many files were migrated before stopping, and shows how
much more disk space is required to migrate all files.
Cannot run the snap here, so this is reviewed but not executed; needs a snap migration run
to verify. CHANGELOG updated.
The MongoDB 3 migrator resolves WeKan's modern bson (with EJSON) and mongodb v6 by
anchoring require() inside the Meteor bundle. It anchored the sub-paths relative to the
script, but in the snap the script runs from $SNAP/bin/ while the bundle is at
$SNAP/programs/server/... (one level up) — so every anchor resolved
$SNAP/bin/programs/server/... (nonexistent) and fell through to the ancient meteor-spk
base bson 1.x with no EJSON, giving "FATAL: EJSON.parse unavailable" and aborting the
migration.
Build candidate bundle roots from $SNAP (env) and the script's parent dirs, then try the
known modern-bundle sub-paths under each (npm-mongo's nested v6 driver first, so the
ancient v2 that FerretDB rejects with OP_QUERY is never chosen). Node's module resolution
walks up from the '_.cjs' anchor to programs/server/npm/node_modules/bson.
Also: on successful hand-off the dashboard now reloads the SAME page the user was on
(the board URL) instead of forcing All Boards, so a reload during migration keeps you in
place. The dashboard already serves the progress page for every URL. CHANGELOG updated.
The viewer (mentions -> markdown) applies alwaysShowCodeAsText via a ReactiveVar
(Markdown.alwaysShowCodeAsText) that only a separate startup Tracker.autorun kept in
sync with the setting. But the mentions helper re-renders whenever the settings doc
changes (it reads getCurrentSetting for renderLinksAsPlainText/stripLinks), and that
re-render often runs BEFORE the bridge autorun updated the ReactiveVar — so the inner
markdown helper read the stale flag and rendered links/markdown normally (clickable),
and since mentions does not depend on that ReactiveVar it never re-rendered again.
Net effect: toggling "Always show all code as plain text" did nothing (links stayed
clickable, code stayed rendered), even after reload (same race on load).
Fix: set Markdown.alwaysShowCodeAsText inside the mentions helper, from the same
reactive getCurrentSetting() it already reads, BEFORE rendering the inner markdown
via Blaze.toHTML. The markdown helper now always reads the up-to-date flag in the same
synchronous render, so the toggle takes effect immediately in every rich-text field
(card titles, descriptions, comments, checklists). The startup autorun stays for any
direct +markdown use outside a viewer.
kill_meteor_on_port freed the app port (3000) and rspack dev-server port (8080) but not
Meteor's bundled MongoDB on app-port+1 (3001). When the meteor parent is SIGKILLed its
mongo child is often orphaned and keeps holding 3001, so the next `meteor run` failed
with "Unexpected mongo exit code 48 ... port was closed, or was already taken". Also
free app-port+1 (which additionally clears a leftover standalone test mongod on :3001),
include it in the readiness guard/wait, and in the SIGKILL escalation. CHANGELOG noted.
Bring the Windows batch runner to parity with rebuild-wekan.sh: the :3000 server for
Node E2E + Playwright now runs from the precompiled .build\bundle instead of
`meteor run`, so no recompile. New :start_bundle_server subroutine (used by both the
parallel and sequential "Run ALL tests" flows):
- Builds .build\bundle once if missing (meteor build .build --directory).
- Resolves Meteor's bundled node via `meteor node -e process.execPath` and the sibling
dev_bundle mongod; runs the bundle with that node (native-module ABI match).
- Installs the bundle's programs\server npm deps once.
- Starts Meteor's mongod on :3001 (db "meteor", matching what the tests seed), or reuses
a MongoDB already listening there; stops it on exit only if this run started it.
- Starts `node .build\bundle\main.js` with MONGO_URL/ROOT_URL/PORT/WRITABLE_PATH set in
the parent scope (child inherits them) then unset so Mocha/E2E do not inherit MONGO_URL.
Mocha still uses `meteor test` (:3100, its own build) — it cannot run from a production
bundle. Intro text and cleanup updated; CHANGELOG notes the parity. Untested on Windows
here (no cmd available); needs one Windows verification run.
Group the Upcoming section under the standard headers: CRITICAL SECURITY ISSUE
(MimeBleed), new features (import/export privacy, always-show-code, render-links),
updates (webhook card description, Sandstorm docs), tests (yargs mocha crash, live
progress, .build/bundle reuse), bugs (Sandstorm migrated-grain Admin Panel, Version
storage engine). Also fold the bundle-server "meteor" vs "wekan" database fix into
the .build/bundle test entry.
The bundle-based :3000 server pointed at mongodb://127.0.0.1:3001/wekan, but the
Playwright and Node E2E tests seed their users/boards straight into
mongodb://127.0.0.1:3001/meteor (tests/playwright/helpers/db.js,
tests/e2e/list-regressions.js) — the DB name Meteor's built-in mongo used under the
old `meteor run`. Same mongod, different database: the app read an empty DB while the
tests seeded another, so every test needing seeded data failed (only the ~9 per browser
that need no seed passed). Point the bundle server at /meteor so app and tests share
one database again.
Complete the Upcoming test-infrastructure notes: the readiness poll that hung on
Meteor's :3000 proxy (progress froze at [0s]) is now fixed with a curl timeout and a
wall-clock-bounded wait, and "Run ALL tests" checks for .build/bundle specifically and
builds it once if missing. Also note the .sh (bundle) vs .bat (meteor run) split in the
server-start wait item.
Before running ALL tests, check for the .build/bundle directory specifically (the
:3000 test server runs it). If it is missing (never built, or a previous build left
.build without a bundle), build it with `meteor build .build --directory` first.
Previously it only checked the .build parent, so a missing/partial bundle was not
rebuilt here (only caught later by the main.js check).
"Run ALL tests" started the :3000 server with `meteor run`, which recompiles into
.meteor/local even after you already built .build/bundle. Node E2E + Playwright only
need a live WeKan over HTTP, so start that server from the precompiled bundle instead:
- `node .build/bundle/main.js` with Meteor's bundled node (matching native-module ABI),
MONGO_URL to Meteor's bundled mongod on :3001 (or reuse a MongoDB already listening
there). Boots in seconds, no recompile. One-time `programs/server` npm install runs
if missing. mongod is stopped on exit only if this run started it.
- The server boot log and the npm install now STREAM LIVE (scrolling: tail -n 0 -f /
tee) so you see exactly what is happening instead of a single frozen summary line.
- Sequential mode runs suites strictly one at a time, each streaming its own output;
the parallel-only combined refreshing table is now skipped in sequential mode.
Mocha still uses `meteor test` (its own .meteor/local-test build) — its in-process
server-side tests cannot run from a production bundle, so that one build is unavoidable
and copying the bundle would not help it. The script warns the bundle is run as-is, so
rebuild it after source changes. CHANGELOG updated.
In sequential mode only one suite runs at a time, so there is no reason to hide its
output in a log and show only a counter. Refactor the job runner: extract run_job_body
(the actual suite command) and, in sequential mode, pipe it through tee straight to the
console — you now see every test tick by one-by-one as the reporter prints it, then a
final PASS/FAIL + count line. Parallel mode still runs jobs in the background to logs
(the combined table reads them). tee is on the right of the pipe so the suite's exit
code is captured in STATDIR, not tee's.
The "Building WeKan" message during Run ALL tests suggested a fresh full build even
though the user had already built WeKan. Reword it: the tests start a live
`meteor run --port 3000`, and Meteor recompiles into .meteor/local on start only for
inputs that changed since the last build (sources / node_modules), then caches it.
No behavior change, message only.
The "Run ALL tests" server-readiness wait used `curl http://127.0.0.1:3000/sign-in`
with no timeout. Meteor binds the :3000 proxy early and accepts the TCP connection
while the app is still building, but does not send an HTTP response until the build
finishes. So curl blocked on that first connection for the whole build: the loop
never advanced and the new progress line froze at [0s] with only the log header,
even though the build was progressing in the log.
Give curl --connect-timeout 2 --max-time 4 so each poll returns quickly (not-ready
during the build, ready once /sign-in actually answers). The elapsed seconds and the
newest build step now refresh every few seconds as intended. Bound the wait by
wall-clock time (.sh: 1200s; .bat: ~240 polls) instead of a fixed count, since polls
now take a few seconds each. Also add the timeout to the Playwright-ALL precheck.
Audited both runner scripts and closed every place that used to sit silent for
minutes during test runs:
- Server first-build wait (Run ALL tests, both modes): .sh shows elapsed seconds +
newest build step from the server log in place; .bat shows elapsed seconds every
~10s and points at the live build log (echoing arbitrary build lines is unsafe on
cmd because they contain > < | &).
- Sequential per-job (option 2): .sh prints a live elapsed + pass/fail counter per
job and a final PASS/FAIL line; .bat now runs each job in its own minimized window
(reusing the proven parallel start-commands + .done-<key> flags) and polls a live
pass counter one job at a time, instead of redirecting silently to a log.
- Playwright ALL browsers single menu item (.sh option 10 / run_playwright_parallel):
streams each browser's list reporter live via tee while still saving the log;
PIPESTATUS[0] keeps per-browser pass/fail accurate through the pipe.
Single-suite items (Mocha, import, E2E, single Playwright) and the parallel combined
progress table already showed progress and are unchanged. CHANGELOG updated.
Record the Upcoming test-infrastructure change: "Run ALL tests" now shows elapsed
time + newest build step during the server build, and a live per-job pass/fail
counter in sequential mode, instead of sitting silent for minutes.
Running ALL tests (menu options 1/2) was silent for minutes in two places:
- While the :3000 WeKan server did its first rspack build, the readiness wait only
printed dots. Replace that with an in-place line showing elapsed seconds and the
newest build step from the server log (=> Compiled Rspack..., => Started MongoDB,
=> Started your app), so the build is visibly progressing.
- In sequential mode each job blocks until it finishes, so nothing showed while a
job built and ran. Add a live in-place counter per job (elapsed seconds + tests
passed/failed, from count_pass/count_fail on the job log) and a final PASS/FAIL
line when it completes.
The parallel-mode combined progress table is unchanged.
Update the "Test infrastructure" Upcoming entry with the confirmed root cause and
resolution: the second-stage boot crash was node_modules/yargs-parser/build/lib/index.js
using the guarded ternary `const require = createRequire ? … : undefined`, fixed by
generalizing the createRequire removal to a line-wise match (run before import.meta.url
expansion) and broadening the scan from node_modules/yargs to yargs and its ESM deps.
Also note the three server test files that were present but missing from the curated
server/lib/tests/index.js loader and are now registered. Verified: 450 passing, 0 failing
(411 before the three files were wired in).
The Mocha (server-side) test bundle crashed at boot with 0 tests run because
yargs and its ESM dependencies are dragged into the Meteor test server bundle,
where Meteor 3 wraps every module in a CommonJS function and their native-ESM
constructs (import.meta, `const __dirname`, `const require = createRequire(...)`)
are illegal and crash the bundle before any test runs.
Two gaps remained in scripts/patch-yargs-dirname.cjs:
- The `const require = createRequire(...)` removal (and its trigger) only matched
the direct-call form. yargs-parser/build/lib/index.js uses the guarded ternary
`const require = createRequire ? createRequire(import.meta.url) : undefined;`,
which slipped through and crashed at boot with "Identifier 'require' has already
been declared". Generalized the match to line-wise `const require = ...createRequire...;`.
- The scan only walked node_modules/yargs, so yargs-parser (the actual offender)
was never patched. Broadened the scan to yargs and its ESM dependency packages.
Also register the three server test files that were present but missing from the
curated server/lib/tests/index.js (they never ran): the MimeBleed file-validation
bypass regression, the import/export privacy settings, and the impersonation report
query. Server suite now boots and runs (411 passing).
CHANGELOG Upcoming documents the bug and the two-stage debugging journey.
The yargs postinstall patch expanded import.meta.url -> require('url').pathToFileURL(...)
BEFORE removing the const require = createRequire(...) call. The paren-naive removal
regex createRequire([^)]*) then stopped at the first ) of the injected require('url'),
replacing only part of the statement and leaving a dangling
/* Meteor: use wrapper require */.pathToFileURL(__filename).href));
in yargs' esm.mjs. That is invalid JS (a leading .pathToFileURL after a comment), so
Meteor's reify parser threw 'Unexpected token (19:33)' when the test server bundle pulled
yargs in -- meteor test reported 0 tests, even though every repo source file was fine.
Fix: remove the createRequire call BEFORE expanding import.meta.url (so the injected
parens can't break the match), and add a repair rule + trigger so re-running the script
collapses the dangling leftover in already-corrupted node_modules/yargs files back to the
marker. Fresh and already-broken files both end valid.
- models/fileValidation.js: a stray BOM (U+FEFF) inside the looksLikeDangerousMarkup
regex character class broke the server test build with a Babel 'Unexpected token'
syntax error, so Mocha ran 0 tests. JS \s already matches U+FEFF, so the explicit
BOM was redundant; use plain \s*.
- models/import.js: importBoard awaited assertImportEnabled() BEFORE the switch that
check()s the "board" argument. Meteor's audit-argument-checks tracks checks on the
async context, so awaiting first made the later check(board) not count, throwing
'Did not check() all arguments during call to importBoard' -- which broke the WeKan
JSON import (#21) and Excel import (#25) Playwright tests. Check "board" up front
(Match.OneOf(Object, Array)) before the await; the per-source switch still does its
specific check.
- server/publications/activities.js: move the disableActivities gate to AFTER the
check() calls so the argument-checks audit is satisfied when the toggle is on
(latent: it would have thrown 'did not check all arguments' with activities off).
Supersede the earlier 'keep MongoDB 7' approach: the WeKan snap now moves EVERY
existing MongoDB database onto FerretDB (SQLite) on first boot after an upgrade,
then shuts MongoDB down so only WeKan (Node.js) + FerretDB (SQLite) run. Text data
goes into SQLite; CollectionFS + Meteor-Files GridFS attachments/avatars are
extracted to files/attachments and files/avatars.
migration-control now routes by how the source can be READ, not by keep-vs-migrate:
- mongod 7 can open it (modern MongoDB 6/7, incl. a DB earlier migrated up from 3.x)
-> keep that mongod as source, run the modern driver importer
(migrate-mongodb-to-ferretdb.mjs) into a temp FerretDB SQLite target.
- else mongod 3.2 can open it (old MongoDB 3.x) -> run migrate-mongo3-to-ferretdb.mjs
(legacy mongoexport CLI).
- else (unreadable/corrupt, or tools missing) -> fall back to a normal MongoDB start
so the service is never left dead, and retry next boot.
On success: mark done + snapctl set database=ferretdb; mongodb-control then stops and
disables the MongoDB service. This also fixes the reported failure where a healthy
MongoDB 7 database was misdetected as MongoDB 3.x, the 3.x path failed, and — because
mongodb-control exec's this script — mongod never started and the service looped.
Probe readiness lengthened 20s -> 45s; the temp source mongod is tracked by pidfile
and force-stopped so it never leaves the dbpath locked. MongoDB data is never
modified or deleted; the snap only switches to FerretDB once migration succeeds.
Both migration dashboards (Snap MongoDB-3 importer and the Sandstorm/general
MongoDB importer) hard-coded 'WeKan'. If the migrated database has a product name
set in Admin Panel (settings.productName), the dashboard now shows that name and
does not mention WeKan; otherwise it defaults to WeKan.
- snap-src/bin/migrate-mongo3-to-ferretdb.mjs: read settings.productName via
mongoexport early in run() into state.product; title/h1/complete message use it.
The importer->app hand-off poll no longer keys off the product-dependent title
string; it checks a stable '<!--migration-dashboard-->' marker instead.
- releases/migrate-mongodb-to-ferretdb.mjs: read settings.productName via the driver
right after the source connects into state.product; the HTML __PRODUCT__ placeholder
is substituted in buildHtml(). Header clarified to FerretDB (SQLite).
The one-time MongoDB 3 -> FerretDB migration check probed whether mongod 7 could
open the existing data and, if that short probe failed for ANY reason (journal
recovery, stale mongod.lock, slow disk, large oplog), assumed the data was
MongoDB 3.x. A healthy MongoDB 7 database that failed the probe was then sent down
the 3.x path, where mongod 3.2 also could not open it, logging 'could not start
migratemongo mongod on the MongoDB 3.x data'. Because mongodb-control exec's the
migration script, mongod never started -- the MongoDB service failed to activate
and looped on every restart.
Fix: only run the 3.x migration when the data is POSITIVELY confirmed as MongoDB
3.x (mongod 3.2 can actually open it). In every other case -- MongoDB 6/7, data the
probe could not read, or missing tools -- fall back to a normal MongoDB 7 start in
the same service activation (set the marker + exec mongodb-control) instead of
exiting with nothing running. Also: lengthen the mongod-7 readiness probe 20s -> 45s
so large databases needing recovery are not misread, and track the temporary probe
mongod by pidfile + force-stop it so it never leaves the dbpath WiredTiger lock held.
The MongoDB data is never modified or deleted.
- client/lib/tests/renderLinksPlainText.tests.js: the 'render links as plain text'
toggle and XSS hardening. Config assertions (stripLinks adds <a> to FORBID_TAGS,
default does not, KEEP_CONTENT) plus real sanitizeHTML() behavior: <a> stripped to
its text when stripLinks, kept by default, and javascript: hrefs / event handlers /
<script> removed regardless.
- client/lib/tests/alwaysShowCodeAsText.tests.js: escapeHtmlSource (exposed on the
Markdown export) escapes <script>/comments/quotes/ampersand-first so code is shown
not executed; leaves plain text and empty strings unchanged.
- server/lib/tests/impersonationReportQuery.tests.js: the Impersonation report search
selector (extracted to models/lib/impersonationReportQuery.js so it is testable
without re-registering the publication) builds a case-insensitive $or across the
identity fields, escapes regex metacharacters, and is empty for a blank term.
The MimeBleed fix (fileValidationBypass.security.tests.js), SortBleed
(boards.security.tests.js) and import/export anonymization
(importExportSecurity.tests.js) already carry positive + negative tests.
WeKan's upload validation (models/fileValidation.js) detects a file's real MIME
type by running the Unix 'file' command. On minimal Docker/Alpine images without
'file', detectMimeFromFile() silently returned undefined and the code fell back to
the CLIENT-supplied fileObj.type. An authenticated board member (WITH_API=true)
could upload HTML+JS while setting fileType: image/png; the spoofed type is not on
the dangerous-MIME deny-list, so the dangerous-content scan was skipped and the file
was stored, yielding stored XSS served under the WeKan origin (CWE-434).
Fix: the client-supplied type can no longer gate the safety scan. When content-based
detection via 'file' is unavailable, fall back to a dependency-free JS content sniff
(looksLikeDangerousMarkup) that inspects the real bytes for HTML/SVG/XML/<script>
signatures and forces the dangerous-content scan regardless of the claimed MIME, so a
spoofed image/png that is really HTML+JS is rejected. The sniff matches only definitive
markup, so genuine binary uploads (real PNG/JPEG/PDF, incl. large ones) are unaffected.
Also log a one-time warning when 'file' is missing (previously silent).
Regression test server/lib/tests/fileValidationBypass.security.tests.js covers the
spoofed dangerous uploads and safe binaries.
CVSS:3.1 8.3 High (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). Thanks to HNUfwj and xet7.
Disabling activities/notifications/watch can serve any law or policy, not only
GDPR. Remove GDPR mentions from the code comments and the en.i18n.json toggle
descriptions (and the Admin Panel docs), keeping the neutral #5820 reference.
A card description was recorded in the activity log (a-changedDescription, #5482)
but never added to the outgoing-webhook / notification params, so integrations got
an event with no description text.
The Activities.after.insert payload now sets params.description (the card's current
description, only when non-empty) for every card event, and forwards the activity's
oldValue/value alongside the existing timeValue/timeOldValue so a description change
carries the before/after text. New fields are additive and backward-compatible.
Impersonation events are already recorded in the impersonatedUsers collection
whenever an admin impersonates a user (e.g. to export a private board). Surface
them as a new admin report, newest-first, with the same server-side search +
pagination as the other reports.
- server/publications/impersonationReport.js: impersonationReport publication
(admin-only, sort createdAt -1, limit/skip, $or search over admin/user/board/
attachment ids and reason) + getImpersonationReportCount method. Also publishes
the referenced admin/impersonated user docs (same fields the People panel uses)
so usernames resolve and the Edit user popup has its data.
- adminReports.jade/.js: new Impersonation side-menu entry, sub-template (Date,
Admin, Impersonated user, Board, Reason), pagination + search wired through the
shared reportConfig. Clicking a username opens the same editUser popup as
Admin Panel / People.
- i18n: impersonationReportTitle, impersonation-admin, impersonation-user, reason.
- server/lib/tests/importExportSecurity.tests.js: positive and negative unit
tests for the anonymization helpers (map building, user-doc scrubbing, @mention
rewriting that ignores unknown handles and email addresses, requestedBy/assignedBy
identity replacement, and safe no-ops on empty input).
- docs/Features/Admin-Panel/Admin-Panel.md: new Features tab section documenting the
Performance, Security (rich-text hardening + import/export privacy) and
Notifications (GDPR) toggles.
New Notifications category with three GDPR toggles (all default off), enforced
server-side:
- Disable all activities: activity-feed entries are neither recorded
(Activities.before.insert returns false to cancel) nor shown (the activities
publication returns nothing, hiding existing and new entries).
- Disable all notifications: the Activities.after.insert dispatch hook returns
early, so no watch notifications are sent; activity recording is unaffected.
- Disable watch: the watch() method rejects any watch-level change, and the board
header watch button is hidden.
A synchronously-readable server cache (models/lib/featureFlags.js, kept current by
a Settings observer) lets the sync before.insert hook read the flags without
awaiting. Adds settings disableActivities, disableNotifications, disableWatch,
published in the public setting, plus global Blaze helpers activitiesDisabled /
watchDisabled for UI hiding.
Six new optional toggles (all default off) governing how boards and user data
cross the WeKan boundary, enforced server-side so they cannot be bypassed from
the client:
- Disable all import / Disable all export: master switches that turn off every
import/export feature. importBoard and cloneBoard call assertImportEnabled;
every Exporter build()/buildStream() call assertExportEnabled. The import and
export menu options are hidden in the UI via global importDisabled/exportDisabled
Blaze helpers.
- Disable import avatars / Disable export avatars: never carry avatars into/out of
WeKan. Import is gated at the single localizeAvatarFromBuffer choke point (covers
WeKan JSON import, LDAP, OIDC/OAuth2 login sync and board-open localization) plus
Trello inlineMemberAvatars; export skips the base64 avatar embed + avatarUrl in
both Exporter paths.
- Anonymize import users / Anonymize export users: replace username/fullname/initials
with counter placeholders (user1, user2, ...), drop avatars, and rewrite @username
mentions + requestedBy/assignedBy in card/comment text. The placeholder word "user"
is translated to the current user language. Export covers both build() and the
streaming buildStream() (which does an id-only pre-scan so mentions streamed before
the users array resolve to matching labels); import scrubs the incoming board in
WekanCreator before any placeholder user or card is created.
New shared server helper models/lib/importExportSecurity.js holds the settings
read, guards, anonymization map builder and text rewriter. Adds settings
disableAllImport, disableAllExport, disableImportAvatars, disableExportAvatars,
anonymizeImportUsers, anonymizeExportUsers, published in the public setting.
Translate the new render-links-as-plain-text-description, always-show-code-as-text
and always-show-code-as-text-description strings to Finnish, matching existing
terminology (tarkistuslistat, klikattava, 'Kun käytössä...', 'Oletuksena pois
käytöstä...').
New optional toggle under Admin Panel / Features / Security. When enabled,
rich text is never rendered as markdown or HTML -- the entire source is shown
as escaped plain text in every rich text field (board and card titles,
descriptions, comments, checklists, etc.), so hidden content is always
revealed: HTML comments (<!-- -->), the target URL inside a markdown link,
JavaScript and any other code. All code is always visible, not clickable, and
not running.
This extends the existing invisiblebleed protection (which already showed raw
source for description-less markdown links) to all content, and complements
the "Render links as plain text" toggle.
Implementation:
- New global setting alwaysShowCodeAsText (models/settings.js), published in
the public setting publication, default off.
- Admin Panel / Features / Security gains a second materialCheckBox toggle.
- The markdown helper has the raw text, so the decision lives there. Because
the wekan-markdown package cannot import app code, the setting is bridged via
a ReactiveVar on the exported Markdown object; the rich text viewer
(editor.js) keeps it in sync with the setting reactively, and the markdown
helper reads it and renders the fully HTML-escaped source in a <pre> when on.
Full escaping (not DOMPurify stripping) ensures tags like <script> are shown
as text rather than removed. The description-less-link branch now uses the
same full escaping so it too reveals all source.
New optional security feature under Admin Panel / Features / Security. When
enabled, all links -- both markdown links like [label](url) and raw HTML
<a href> tags -- are always shown as plain, non-clickable text in every rich
text field (board and card titles, descriptions, comments, checklists, etc.),
so a link can never be clicked and cannot present misleading anchor text.
This is a hardening option on top of the existing XSS sanitization (which
already strips javascript:/data: schemes, event-handler attributes and
dangerous tags); it addresses #6453, where a board title rendered as a
clickable link. Default off, so links stay clickable.
Implementation:
- New global setting renderLinksAsPlainText (models/settings.js), published in
the public setting publication.
- Admin Panel / Features gains a Security category with a materialCheckBox
toggle that saves the setting immediately.
- The shared DOMPurify sanitizer takes a { stripLinks } option that forbids the
<a> tag while keeping its inner text (KEEP_CONTENT), so links collapse to
plain text. The rich text viewer (mentions helper in editor.js) passes
stripLinks based on the setting, gated reactively so toggling re-renders.
On Sandstorm the grain owner (Sandstorm 'configure' permission) maps to a
WeKan admin, which gates the Admin Panel and its Attachments / Sandstorm
cleanup tools. A new grain worked because Users.after.insert derives the role
for the freshly inserted user, but a grain migrated from an older WeKan
already contains the user, so that insert hook never runs. Sandstorm
auto-login uses connection.setUserId() (bypassing accounts-base, so
Accounts.onLogin never fires), and the remaining observeChanges hook on
services.sandstorm only fires when that field actually changes on login. When
the migrated user's stored permissions already equal the grain's current
permissions, the login $set is a no-op with no oplog entry, so isAdmin was
never derived and the owner had no Admin Panel.
Reconcile at grain startup (a migrated grain reboots once migration
completes): grant WeKan admin to every Sandstorm user whose stored
permissions include 'configure'. Promote only, never revoke here (that is the
change hook's job on a real permission change), so a stale/empty stored
permission set can never lock the owner out of the Admin Panel.
The storage engine was read only from serverStatus, which requires the
cluster-level clusterMonitor role. A per-database WeKan user (readWrite on
the wekan db only, as in docs/Platforms/FOSS/Docker/Meteor3/) is not
authorized to run it, so it was rejected and the field stayed 'unknown'.
Fall back to a $collStats aggregation, which needs only the collStats action
that read/readWrite already grant, and read the real engine (wiredTiger /
inMemory) from storageStats. The MongoDB compatible version and Database
commit were already correct: they come from buildInfo, which needs no
special privileges.
The postinstall script (scripts/patch-yargs-dirname.cjs) added in 6b409c5da
to fix the Meteor/Mocha test boot crash makes npm mark the root package with
hasInstallScript: true. Sync the lockfile to match package.json.
A low-privilege (comment-only / read-only) board member could escalate to
board admin and take over a private board via the board 'sort' collection
allow rule. To support drag-to-reorder, a second Boards.allow({ update })
rule returned true for any board member whenever the update touched the
'sort' field. Meteor applies the WHOLE modifier once any allow rule approves
and no deny rule rejects, and does not scope the approving rule to the field
that satisfied it. Because canUpdateBoardSort only checked that 'sort' was
AMONG the modified fields, a restricted member could smuggle arbitrary board
mutations (members, permission, title) into the same $set as 'sort' in one
DDP Boards.update call, making themselves admin, flipping the private board
to public, and evicting the legitimate owner. The last-admin deny rule only
inspected $pull, so a wholesale $set of the members array bypassed it.
Fix: restrict canUpdateBoardSort so the sort-reorder rule approves an update
only when 'sort' is the sole modified field. Defense in depth: the last-admin
deny rule now also rejects a $set rewrite of the members array that would
drop the last active admin. Regression test covers the multi-field smuggling
case. The legitimate All Boards drag-reorder is unaffected: it persists order
per-user in profile.boardSortIndex, not in the board document.
CVSS:3.1 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), CWE-863, CWE-269.
Thanks to 5ud0 / Tarmo Technologies and xet7.
- .github/workflows/meteor-spk.yml: assembles WeKan's modernized meteor-spk (the
packaging tool + its meteor-spk.deps: Node 24, FerretDB, Mongo 3.x CLIs, modern
sandstorm-http-bridge) via sandstorm-src/build-deps.sh — reusing sandstorm.yml's
setup — and zips it as meteor-spk-YYYY-MM-DD_HH_MM_SS.zip, the same kind of
self-contained bundle sandstorm-io/meteor-spk releases but with WeKan's deps baked in.
Uploaded as an artifact and to the newest GitHub Release. Needs no keyring (it does
not pack/sign a .spk).
- .github/workflows/sandstorm-store.yml: publishes an already-built .spk to the
Sandstorm App Market. Installs Sandstorm + assembles meteor-spk and restores
SANDSTORM_KEYRING like sandstorm.yml, downloads the newest .spk from the WeKan release
(built and attached by sandstorm.yml), renames it to wekan-<VERSION>.spk (VERSION from
the release tag, e.g. 9.88, overridable via inputs), and runs 'spk publish
wekan-<VERSION>.spk' exactly like releases/release-sandstorm.sh. Does not rebuild the
.spk.
Thanks to xet7.
The crash moved from yargs/lib/platform-shims/esm.mjs to yargs/build/lib/utils/
apply-extends.js (import.meta.resolve(config.extends)) — yargs' ESM build has more than
one file using import.meta. Rewrite the workaround to scan the whole node_modules/yargs
tree and rewrite every ESM-only construct (import.meta.url/.resolve/.dirname/.filename,
const require = createRequire(...), and yargs' own const __dirname) to the CommonJS
wrapper equivalents in one pass, so a new yargs file can't reintroduce the boot crash.
yargs is never executed at runtime here, so only valid CommonJS parsing matters.
Thanks to xet7.
Renaming only __dirname was not enough: yargs' esm.mjs also uses import.meta.url (invalid
in Meteor's CommonJS module wrapper -> 'Cannot use import.meta outside a module') and
redeclares require via createRequire(import.meta.url) (collides with the wrapper's
require). reify rewrites the file's import statements but not import.meta. Rewrite all
three to use the wrapper's __filename / __dirname / require, which yargs never actually
exercises here (it is only bundled, never called). Idempotent; also patches a file left
half-rewritten by the previous version of this script.
Thanks to xet7.
.meteorignore does not apply to node_modules under Meteor 3 / rspack (verified: the
rebuilt Mocha bundle still contained node_modules/yargs), so revert that no-op rule and
fix the actual crash instead.
Root cause (pre-existing, unrelated to app code): the whole yargs package gets pulled
into the server bundle as a transitive devDependency, and its ESM shim
node_modules/yargs/lib/platform-shims/esm.mjs declares a top-level
'const __dirname = fileURLToPath(import.meta.url)'. Meteor wraps that .mjs in a CommonJS
module function that already provides __dirname, so it collides —
'SyntaxError: Identifier __dirname has already been declared' — crashing the server
bundle at boot, which is why Mocha reported 0 tests.
Add scripts/patch-yargs-dirname.cjs, run from a package.json postinstall, which renames
yargs' local __dirname (and its .substring/.lastIndexOf uses) to __dirnameYargs so it can
no longer collide with the wrapper's. Idempotent and best-effort (never fails install);
yargs is not used at runtime here so the rename is inert for the app.
Thanks to xet7.
'meteor test' (Mocha) crashed at boot with 'Identifier __dirname has already been
declared'. Root cause is unrelated to app code: node_modules/yargs/lib/platform-shims/
esm.mjs declares a top-level 'const __dirname = fileURLToPath(import.meta.url)', which
collides with the __dirname that Meteor's CommonJS module wrapper already provides. yargs
gets pulled into the bundle only because i18next-sprintf-postprocessor (imported by
imports/i18n/tap.js) ships a stray rollup.config.js that does require('yargs'), and a
devDependency (mongodb) has since installed yargs 18 into node_modules so that require now
resolves. That build config is not part of the package's runtime entry
(index.js -> dist/commonjs), so exclude it from Meteor's bundle via .meteorignore.
Pre-existing (the same boot crash happened before this branch's test work) and independent
of the avatar/import-export feature.
Thanks to xet7.
Two build/load fixes surfaced by running the full test suite:
- server/imports.js: import the new server-only files so they actually load. In
mainModule mode a file not imported here never loads, so Accounts.onLogin avatar
localization (server/avatarLocalizationOnLogin.js) and the imported-user reconciliation
Meteor methods (server/importedUserReconciliation.js) would silently never register.
- Test __dirname collision: 'meteor test' crashed at boot with 'Identifier __dirname has
already been declared'. The client test index (client/lib/tests/index.js) loads
boardTriggersClass.tests.js, which referenced the bare __dirname global; under
meteor test that makes Meteor inject a colliding with its CommonJS
wrapper's, aborting the whole server bundle (the same failure already fixed once in
dependencies.openapi.tests.js — this was the next file to trip it). Replace __dirname
with a process.cwd()/PWD walk-up in boardTriggersClass.tests.js, and defensively in the
node-run tests/rtl.test.js, tests/stickers.test.js and tests/stickers.packs.test.js too.
Thanks to xet7.
Split the testable, security- and correctness-critical logic of the new avatars +
original-members feature into pure Node modules (module.exports, no Meteor) and cover them
with plain-Node tests/*.test.cjs (positive AND negative), matching the repo's existing
unit-test style. Wired into the test:unit:node script.
- models/lib/avatarUrlSafety.js (from server/lib/localizeAvatar.js): isExternalAvatarUrl,
isBlockedIp (SSRF ranges), decodeDataUri, checkUrlHostSync. Tests assert external vs
local/relative/non-http URLs, that private/loopback/link-local/CGNAT/metadata/multicast
IPv4+IPv6 (incl. IPv4-mapped) are blocked while genuine public addresses and near-miss
ranges are allowed, data: decoding, and that non-http(s) schemes, localhost and
metadata hosts are rejected.
- models/lib/importedUserReconciliationPlan.js (from server/importedUserReconciliation.js):
planReconciliation. Tests: matches merge, non-matches deactivate, never match another
placeholder or self or a username-less placeholder.
- packages/wekan-ldap/server/ldapPhoto.js (from sync.js): getLdapPhotoBuffer. Tests the
Buffer / array / {type:'Buffer'} / base64 shapes and jpegPhoto>thumbnailPhoto, and that
missing/empty/short values yield null (never throw).
- models/lib/importedBoardMemberPlan.js (from wekanCreator.js): planImportedBoardMember.
Tests: unmapped member kept by original id but inactive+non-admin, mapped role preserved,
importer and id-less members dropped.
The extractions are behaviour-preserving; the app modules now import these helpers.
Thanks to xet7.
Make it obvious which board members are backed by a real account and which are not,
both on card avatars and in the right sidebar member list.
- userAvatar (js/jade/css): the avatar gets an 'imported-member' class (dashed grey ring
+ amber '?' corner badge) for un-reconciled board-import placeholders
(authenticationMethod:'imported'), and an 'inactive-member' class (greyscale + dim +
grey badge) for members deactivated on the board (e.g. a person not in LDAP after
reconciliation). A placeholder is usually both; a real active account gets neither. The
tooltip explains the state.
- boards.js membersForSidebar(): active members first (as before) then the inactive /
imported ones appended, and sidebar.jade uses it, so the member list shows EVERYONE and
which are active vs not instead of hiding inactive members.
- i18n: imported-member-no-account, inactive-member.
Thanks to xet7.
server/importedUserReconciliation.js turns the inert import placeholders into the right
real members deliberately, after import — never auto-mapped during import.
- reconcileImportedUsers(): for each authenticationMethod:'imported' placeholder, find a
real account with the same username (not another placeholder) — the valid accounts are
whatever real WeKan accounts exist, typically provisioned by LDAP/OIDC at login — and
merge the placeholder into it; if none exists (e.g. the person is not in LDAP) leave the
placeholder and its board memberships inactive (isActive:false).
- mergeImportedUserInto(placeholderId, targetId): reassign every reference (board.members,
card.members, card.assignees, activities.userId, cardComments.userId) from the
placeholder to the target, reusing the target's membership if it already is a member,
then remove the placeholder.
- Admin-only Meteor methods reconcileImportedUsers and mergeImportedUser(placeholderId,
targetId) for the automatic sweep and for one-off deliberate mapping.
Because reconciliation matches against existing real accounts (which LDAP populates on
login) it needs no direct LDAP query and is provider-agnostic. Merges reassign by the
preserved original _id, so nothing is lost.
Thanks to xet7.
Importing a WeKan board no longer collapses unmapped members onto the importing user (a
wrong mapping attaches the wrong person and can leak board permissions). Instead each
original member carried in board.users becomes an inert placeholder user:
- createPlaceholderUsers() reuses the ORIGINAL _id, so every card/comment/activity
member reference resolves to the right person with no remapping, and populates
this.members as an identity map (id -> id) so _user() keeps the original member.
- Placeholders are created with Users.direct.insertAsync (bypassing the after.insert
hooks — Sandstorm identity sync and the disableRegistration/invitation-code gate that
would otherwise reject a non-account user), marked authenticationMethod:'imported',
loginDisabled:true, isActive:false, and carry NO password or email. Username collisions
suffix the placeholder's name so an existing account is never renamed/stolen.
- The member's avatar (embedded as base64 by the exporter) is restored into files/avatars
via the localizer, so the original picture shows and is exportable again (Sandstorm too).
- Board membership adds imported members inactive and non-admin, so they are visible but
hold no permissions until deliberately reconciled later; the importer stays sole admin.
Also guard the Sandstorm Users.after.insert hook to no-op on users without
services.sandstorm (placeholders), which otherwise threw reading preferredHandle.
Mapping/merging to real accounts and LDAP reconciliation are a later, deliberate step.
Thanks to xet7.
Board export already carried the original members' username, fullname and initials (the
field whitelist excludes passwords, emails and services). Now, for each member whose
avatar is a local WeKan file, embed the file itself as base64 (profile.avatarFile +
avatarFileName + avatarFileType) alongside the existing avatarUrl reference, in both the
in-memory build() and the streaming buildStream() paths.
Because this reads a local avatars file (no outbound fetch), it works inside a Sandstorm
grain too: a board that already has many stored avatars can be exported with every
member's picture intact, ready to re-import elsewhere. External, not-yet-localized
avatars are still exported as their URL only (Increment 1 localizes those into files
first, on board open / at login).
Thanks to xet7.
Second set of triggers so external avatars from identity providers become local WeKan
avatars (files/avatars), shown in WeKan and carried by board export/import.
- LDAP (packages/wekan-ldap/server/sync.js): syncUserData now reads the binary
jpegPhoto / thumbnailPhoto (tolerating Buffer / array / {type:'Buffer',data} / base64
shapes) and, unless the user already has a locally-stored avatar, stores it as a
self-contained data: URI in profile.avatarUrl. That displays immediately and needs no
coupling from this package to app code; the app then materialises it to a real file.
- OIDC (packages/wekan-oidc/oidc_server.js): capture the standard claim (with
an OAUTH2_AVATAR_MAP override) into serviceData and set profile.avatarUrl for new users.
- App (server/avatarLocalizationOnLogin.js): Accounts.onLogin localizes the logging-in
user's external avatar immediately (provider-agnostic), complementing the board-open
trigger. Fire-and-forget, idempotent, never blocks login. (Inside a Sandstorm grain
outbound fetch is sandboxed, so a Sandstorm profile picture is a best-effort no-op here
and needs client-assisted capture — a later step.)
Thanks to xet7.
First trigger for external-avatar localization: the single-board publication
(publishComposite('board')) now fires a best-effort, fire-and-forget
localizeBoardMemberAvatars(boardId). For every member whose profile.avatarUrl points
outside WeKan, it copies the image into WeKan's own files/avatars (via the SSRF-guarded
helper) and repoints avatarUrl at the local copy. Idempotent (already-local avatars are
skipped) and it never blocks or delays the publication.
This is the universal catch-all: no matter how a member's avatar became external
(OAuth2/OIDC picture URL, gravatar, a pasted URL), opening the board makes it a local,
exportable WeKan avatar. (Note: inside a Sandstorm grain outbound fetch is sandboxed, so
Sandstorm profile pictures need the provider-side capture added next; LDAP jpegPhoto is
binary and is imported at login, also next.)
Thanks to xet7.
First piece of making external avatars (Sandstorm profile pictures, LDAP jpegPhoto,
OAuth2/OIDC picture claims, gravatar, any URL/data-URI) into local WeKan avatar files
under WRITABLE_PATH/files/avatars, so they display without the identity provider and are
carried by board export/import.
server/lib/localizeAvatar.js:
- isExternalAvatarUrl(url): true unless already a local /cdn/storage/avatars/ (or legacy
/cfs/files/avatars/) URL.
- localizeAvatarFromBuffer(userId, buffer, {type,name}): write bytes into the Avatars
FilesCollection (onAfterUpload sets the user's local profile.avatarUrl).
- localizeAvatarFromUrl(userId, url): fetch (or decode a data: URI) then store.
- localizeUserAvatarIfExternal(user): idempotent no-op when already local.
Every network fetch is SSRF-guarded: http/https only; the host is resolved and any
private, loopback, link-local, CGNAT, multicast or cloud-metadata (169.254.169.254)
address is refused; 8s timeout; 5 MB cap; image content-type only. Not wired to any
trigger yet (board-open / export / login flows come next).
Thanks to xet7.
Now that the grain boots, logs in, lists/opens boards, uploads/downloads attachments,
and migrates an old MongoDB 3 grain end to end, drop the two temporary diagnostics:
- models/attachments.js: revert the Meteor-Files debug flag to false (it was enabled on
Sandstorm to trace the upload 'session expired [408]' issue and was spamming the grain
log with the full FilesCollection dump and every find()).
- server/ferretdbQueryDebug.js: remove the mongodb Collection.find wrapper that logged
FerretDB-rejected selectors (used to find the $elemMatch 'unknown operator: userId'
issue, since fixed in wekan/FerretDB).
Thanks to xet7.
Two UX requests once the migration itself works end to end:
- Auto-open All Boards. Previously the dashboard sat on the grain URL for 60s after
completion and the admin had to reload to reach WeKan. On success the importer now
exits after ~5s (long enough for the browser to fetch the done page), and the done
page runs a JS poller that fetches '/' until WeKan's app shell answers (tolerating the
brief importer->WeKan port hand-off gap) and then location.replace('/') opens All
Boards. The spinner keeps spinning through the hand-off so it is clear the grain is
still working; no meta-refresh on the done page (a refresh mid-hand-off would hit a
dead port). On failure it still stays up a minute with the errors.
- WeKan colours. Recolour the dashboard from the dark scheme to WeKan's blue/white/grey
(belize blue #2980b9 headings and spinner, white card on light-grey background, grey
borders, system font).
Thanks to xet7.
With text collections migrating, the 3 attachments still produced 0 extracted files and
errors like 'parse attachments.chunks: Unexpected Binary Extended JSON format
{"$binary":"iVBOR..."}'. Two causes, both fixed:
1. Legacy Extended JSON v1 binary. mongo 3.x mongoexport writes binary as
{"$binary":"<b64>","$type":"00"} (v1), but modern bson EJSON.parse only
accepts v2 {"$binary":{"base64":...,"subType":...}}. Rewrite v1 -> v2 per line
before parsing, so chunk data (and any binary in text collections) parses.
2. Meteor-Files native GridFS buckets were unhandled. The grain stores attachments in
attachments.files + attachments.chunks (FilesCollection record in the attachments
collection), not CollectionFS's cfs_gridfs.*. Reassemble those buckets to disk,
linking each GridFS file to its record via metadata.fileId/versionName, then repoint
the migrated record's versions.<v> at the file and DROP versions.<v>.meta.gridFsFileId
— otherwise WeKan's getFileStrategy keeps choosing the (now-empty) GridFS backend and
the image 404s. Also exclude <bucket>.files/.chunks (and tempstores) from the text
migration so binary chunks are no longer run through EJSON as documents.
Thanks to xet7.
Text collections exported and 'inserted N/N' was logged, but every document actually
failed with 'Unsupported OP_QUERY command: update' — nothing reached FerretDB. Cause:
requireAny('mongodb') resolved the ANCIENT meteor-spk base driver (v2.x, at the deps
root) because the modern driver is not directly under programs/server/npm/node_modules
— Meteor nests it at .../meteor/npm-mongo/node_modules/mongodb (v6.16). The 2.x driver
speaks legacy OP_QUERY, which FerretDB rejects; the 6.x driver speaks OP_MSG (the same
driver the WeKan app uses against FerretDB). Add the npm-mongo path as the first
resolver anchor, and log the resolved driver version.
(Separately, GridFS attachment/avatar extraction still finds 0 files for this grain —
its attachments appear to be in Meteor-Files buckets (attachments.files/.chunks) rather
than the CollectionFS cfs_gridfs.* the importer currently handles; to be addressed once
the modern driver confirms text migration and the real attachment collection counts.)
Thanks to xet7.
Link the mongodb default-import, mongoexport --quiet removal, diagnostic/dashboard, and
createRequire commits inline within their parent migration bullets.
Thanks to xet7.
The EJSON diagnostic was conclusive: require('bson') returned an object with no EJSON,
mongodb.EJSON was undefined, and mongodb/lib/bson* were MODULE_NOT_FOUND. Cause: the
importer sits at the deps root next to the OLD meteor-spk 0.6.0 base node_modules
(bson 1.x with no EJSON at all, plus an ancient mongodb that has MongoClient but no
EJSON re-export), kept there only for the niscu->3.0 stage — so a bare require resolved
those adjacent copies. WeKan's current bson/mongodb (with EJSON) live under
programs/server/npm/node_modules. Anchor createRequire inside that modern bundle first
(falling back to the deps root), and load both mongodb and bson through it. This also
switches the importer to the modern mongodb driver — the same one the WeKan app uses
against FerretDB.
Thanks to xet7.
The mongodb default import exposed MongoClient but NOT EJSON (mongodbPkg.EJSON was
undefined), so the previous 'take EJSON from mongodb' attempt still hit the FATAL
guard. Node 24's ESM default-interop for these CommonJS packages drops the
getter/re-export used for EJSON. Switch to createRequire (CJS require returns the real
module.exports with getters intact and honors NODE_PATH), and resolve EJSON by trying,
in order: mongodb.EJSON, mongodb/lib/bson.js, mongodb/lib/bson, bare bson's .EJSON,
and bare bson itself — falling back with a diagnostic list of what each attempt
returned so a remaining failure names the cause instead of just 'undefined'.
Thanks to xet7.
With mongoexport connecting, every collection then failed with 'parse <coll>: Cannot
read properties of undefined (reading parse)' — EJSON was undefined. The importer runs
with NODE_PATH=programs/server/node_modules, but the EJSON-bearing bson (7.3.0) lives
under programs/server/npm/node_modules, so a bare 'import bson' did not resolve to it
and EJSON came out undefined. The mongodb driver (which resolves fine — MongoClient
works) bundles bson and re-exports EJSON from lib/bson.js, so take EJSON from the same
default import and drop the separate bson dependency. Add an upfront guard that fails
loudly if EJSON.parse is ever missing instead of spamming one error per collection.
Thanks to xet7.
The real reason every mongoexport failed (revealed once --quiet was dropped):
'error connecting to db server: no reachable servers'. mongoexport is a Go tool
whose --host defaults to 'localhost', which resolves to ::1 (IPv6) first, but the
migration mongod listens only on --bind_ip 127.0.0.1 (IPv4) — so mongoexport could
never reach it, while the mongo shell (whose host defaults to 127.0.0.1) connected
fine and listed all 42 collections. Pass --host 127.0.0.1 explicitly.
Also fill the dashboard Activity panel from the first moment (mongoexport-ready line,
and one line per collection including empty ones) so progress is visible live instead
of the panel sitting on '(waiting…)' while only red connection errors accumulated.
Thanks to xet7.
The --version probe confirmed the bundled mongoexport runs fine (r3.2.22), yet every
per-collection export exited status=1 with empty stderr AND stdout. The empty stderr
was mongoexport's own --quiet flag suppressing its failure message. Drop --quiet so
the real reason (e.g. 'Failed: <reason>') prints to stderr; mongoexport's normal
progress lines also go to stderr, so stdout stays clean Extended JSON either way.
Also surface progress on the dashboard as the user asked: a rolling Activity log
(last ~15 lines: collections found, per-collection export/insert counts, GridFS file
extraction counts) rendered in a panel and mirrored to the grain log, so it is clear
what the migration is doing rather than only red errors appearing later.
Thanks to xet7.
Every per-collection mongoexport failed with 'failed: undefined' because the error
only logged (r.stderr || r.error): an empty stderr with a non-zero/killed status
(e.g. a SIGSEGV, where spawnSync reports status=null, signal='SIGSEGV', stderr='')
printed as 'undefined', hiding the real cause. Log a full describe(r) instead
(status, signal, error code, stderr AND stdout snippets), and probe
'mongoexport --version' / '--help' once at startup so the next run shows whether the
bundled mongoexport binary itself is broken (dies on --version too → an ABI/bundling
problem) or only the export invocation is. The mongo shell on the same libs works
(listCollections succeeds), so this narrows it precisely.
Also make the progress dashboard obviously live: a CSS spinner while running (✅/❌
when done), a server 'updated' timestamp that changes on every 2s auto-refresh, and
an error counter — the previous page refreshed but showed no sign of activity.
Thanks to xet7.
Same CommonJS/ESM interop issue as the bson import: with { EJSON } fixed, the
importer next threw 'Named export MongoClient not found. The requested module
mongodb is a CommonJS module'. The bundled mongodb driver is CommonJS under Node
24's ESM loader, so import the default and destructure MongoClient. These are the
only two third-party imports; the rest are node: builtins.
Thanks to xet7.
With the WiredTiger cache fixed, mongod 3.0 now starts and the migration importer
runs, but it crashed immediately: 'import { EJSON } from "bson"' threw
"SyntaxError: Named export 'EJSON' not found. The requested module 'bson' is a
CommonJS module". bson (as bundled in WeKan's server node_modules) is CommonJS, so
Node 24's ESM loader exposes no named exports on it. Import the default and
destructure EJSON from it, as Node's own error message advises. Shared by the Snap
migration too (same Node 24).
Thanks to xet7.
mongod 3.0.7 parses --wiredTigerCacheSizeGB as an integer number of GB, so the
0.25 from the previous commit failed at startup with 'Error parsing option
wiredTigerCacheSizeGB as int: Bad digit "." while parsing 0.25' and the migration
still crash-looped. Use 1 (GB). It is a cache cap, not a preallocation, so a short
migration of a normal-sized grain never actually uses that much.
Thanks to xet7.
Importing an existing WeKan grain crash-looped in the one-time MongoDB 3 -> FerretDB
migration: mongod 3.0 forked and its child aborted with exit code 14. Two bugs:
1. WiredTiger cache_size=0G. mongod 3.0 sizes the WiredTiger cache from detected
RAM (RAM/2 - 1GB), but inside a Sandstorm grain sandbox RAM detection returns 0,
so it computes cache_size=0G and WiredTiger refuses to open (minimum is 1MB),
logging 'Value too small for key cache_size' and 'Fatal Assertion 28561' to
/var/migration-mongod.log before aborting. Pass an explicit
--wiredTigerCacheSizeGB 0.25 (256MB) to both mongod invocations (the niscu ->
3.0 stage, which writes new WiredTiger data, and the 3.0 -> FerretDB stage) so
the cache size never depends on RAM detection. The data itself is fine (turtle
file: WiredTiger 2.5.3, i.e. written by mongod 3.0; the 'unclean shutdown'
notice is harmless, WiredTiger recovers from the last checkpoint).
2. Wrong source database. Sandstorm WeKan grains store their data in the
Meteor-default database 'meteor' (the niscu stage reads/writes 'meteor', and a
real grain's WiredTiger catalog shows meteor.* collections), but the importer
was told SRC_DB 'wekan', so even once mongod started it would have exported zero
collections. Only the FerretDB target database is 'wekan'.
Thanks to xet7.
Two grain-navigation regressions vs the last working Sandstorm build (v6.15):
1. Opening a grain showed 'Must be logged in' instead of the All Boards page.
On Sandstorm the platform authenticates the user asynchronously over DDP via
connection.setUserId(), which (unlike a password login) does not set
Meteor.loggingIn(), so Meteor.userId() is null for the first moments after the
grain opens. useraccounts' ensureSignedIn route trigger and renderBoardList()
both bounced that brief null window to the atSignIn route — a sign-in page that
does not exist inside a grain. config/router.js now uses a Sandstorm-aware
ensureSignedInUnlessSandstorm wrapper (a no-op on Sandstorm) and renderBoardList()
no longer redirects on Sandstorm; the All Boards list renders and reactively
fills in once the platform login lands.
2. The Sandstorm shell's outer grain URL did not update when switching boards. The
shell rewrites /grain/<id><path> when the app posts a { setPath } message
(sandstorm shell/imports/client/grain-client.js). The sync was a bare top-level
Tracker.autorun that could run before flow-router-extra's reactive path tracking
was initialised and then never re-run. Restore v6.15's event-driven
FlowRouter.triggers.enter (fresh entering path, no dependency on init order) and
additionally keep a watchPathChange() autorun wrapped in Meteor.startup as a
backup. setTitle now also re-runs on route change.
Thanks to xet7.
On Sandstorm the grain download route returned 403 for private boards, so
attachment thumbnails, minicard covers and the slideshow all showed broken
images even though the upload succeeded and the file was on disk. The download
handler (server/routes/universalFileServer.js) authorizes private-board files
via a Meteor login token (Authorization / X-Auth-Token / authToken query /
meteor_login_token cookie), but Sandstorm has none of these: authentication is
via connection.setUserId() and the sandstorm-http-bridge X-Sandstorm-* request
headers, so extractLoginToken() returned null and isAuthorizedForBoard() denied
every request. Sandstorm already gates grain access at the platform level, so any
request reaching WeKan is an authenticated grain user; trust it. Both
isAuthorizedForBoard() and isAuthorizedForAvatar() now short-circuit to allow
when Meteor.settings.public.sandstorm is set and the request carries the
bridge-injected X-Sandstorm-User-Id header. Gating on the setting means a spoofed
header cannot bypass auth on non-Sandstorm deployments.
Thanks to xet7.
Link the fix that switches attachment uploads to DDP transport on Sandstorm, since
the bridge strips Meteor-Files' custom x-* HTTP upload headers.
Thanks to xet7.
Adding a file to a card in the grain failed with HTTP 400 "Can't continue upload,
session expired [408]" and the file silently disappeared. The Meteor-Files HTTP
upload protocol signals the first chunk with an x-start header and tracks the
session with x-mtok / x-chunkid / x-fileid / x-eof, but Sandstorm's request-header
whitelist (web-session.capnp) does not include those, so the sandstorm-http-bridge
strips them: the server never sees x-start, treats every request as a chunk
continuation, cannot find the per-fileId session and returns 408. Use
transport: 'ddp' on Sandstorm (detected via Meteor.settings.public.sandstorm) so
uploads go over DDP method calls instead of custom HTTP headers; HTTP transport is
kept everywhere else (it avoids the Safari DDP-reconnect stall).
Thanks to xet7.
Attachment uploads in the grain fail with HTTP 400 "Can't continue upload, session
expired [408]": the upload start succeeds but the per-fileId session is gone by the
time a chunk arrives (both the in-memory _currentUploads entry and the
__pre_attachments pre-collection doc are missing), which points at the polling
observe (FerretDB has no oplog) firing a spurious removed event and aborting the
upload. The Attachments FilesCollection had debug: false, so nothing about the
upload path is logged. Enable Meteor-Files debug on Sandstorm only (gated on
Meteor.settings.public.sandstorm) so the grain debug log shows the start /
createStream / observer / continueUpload steps and pinpoints where the session is
lost. TEMPORARY: revert to debug: false once the upload is fixed.
Navigating between boards worked but the Sandstorm shell's grain URL never updated
(it stayed on the grain root), unlike standalone WeKan. The path was synced via a
global FlowRouter.triggers.enter callback, which does not fire reliably on client
navigation in this flow-router-extra / Meteor 3 setup. Sync it from a reactive
Tracker.autorun on FlowRouter.watchPathChange() instead (the same mechanism the
setTitle sync already uses), so every route change posts setPath to the shell and
the grain URL becomes /grain/<id><path>.
Thanks to xet7.
Opening a board in the grain fails with "MongoServerError: unknown operator: userId"
(BadValue) - FerretDB rejecting a query MongoDB accepts - but the driver stack trace
has no WeKan call site, so the offending selector is unknown. This wraps the raw
mongodb Collection.find() to log the collection name and exact selector whenever a
cursor iteration throws, to pinpoint the query. TEMPORARY: to be removed once the
query is rewritten FerretDB-compatibly.
Thanks to xet7.
The Sandstorm login was actually succeeding - Meteor.userId() gets set (confirmed:
Meteor.connection._userId held a valid id, Meteor.loggingIn() false) - but the grain
stayed on the sign-in page. WeKan's home route (renderBoardList) checks
Meteor.userId() once and, because the Sandstorm header login is asynchronous and
uses connection.setUserId() (bypassing accounts-base, so no Accounts.onLogin fires),
finds it still null on first render and redirects to 'atSignIn' - where the user is
stranded even after login completes moments later. Add a Sandstorm-only reactive
autorun that sends the user from 'atSignIn' back to 'home' as soon as
Meteor.userId() is set, so the grain lands on the All Boards page.
Thanks to xet7.
Link the fix that rewrites ROOT_URL to the grain's real URL so DDP, dynamic
imports and the Sandstorm auto-login stop failing cross-origin.
Thanks to xet7.
WeKan loaded but stayed on "Must be logged in", and the console showed
"Cross-Origin Request Blocked ... http://127.0.0.1:4000/__meteor__/dynamic-import/fetch".
The launcher sets a fixed ROOT_URL (http://127.0.0.1:4000, the internal bridge
target), but Sandstorm serves each grain at a per-session host (ui-<hash>.<host>)
via sandstorm-http-bridge, so the client sent its DDP connection and dynamic-import
fetches to 127.0.0.1:4000 - cross-origin and unreachable from the browser - and the
header-based login handshake (a DDP method call) never completed. Add a
WebApp.addRuntimeConfigHook that rewrites ROOT_URL to the grain's real base URL
(X-Sandstorm-Base-Path) per request, and re-asserts the SANDSTORM flag, so the
client talks to the same origin it loaded from. DDP, dynamic imports and the
Sandstorm auto-login now work.
Thanks to xet7.
Link the fix that sets process.env.SANDSTORM=1 in the launcher so the
Sandstorm header-based auto-login runs and pages stop showing "Must be logged in".
Thanks to xet7.
WeKan loads in the grain but every page showed "Must be logged in". The
wekan-accounts-sandstorm client only starts the automatic X-Sandstorm-* header
login when __meteor_runtime_config__.SANDSTORM is set, and the package only sets
that when process.env.SANDSTORM is present - which nothing did (start.js set
MONGO_URL/ROOT_URL/PORT/WRITABLE_PATH but not SANDSTORM, and the pkgdef env didn't
either). The launcher now sets process.env.SANDSTORM = '1' before loading the WeKan
bundle, so the package enables the runtime flag and the client auto-logs-in the
Sandstorm user from the X-Sandstorm-* headers. (App code detects Sandstorm via a
separate Meteor.settings.public.sandstorm flag from the pkgdef METEOR_SETTINGS,
which already worked.)
Thanks to xet7.
Link the change that stops auto-creating a board on a new grain and maps the
grain's Sandstorm permissions to the user's global WeKan role.
Thanks to xet7.
WeKan on Sandstorm is multi-board now, so a new grain/user no longer gets a
hard-coded 'sandstorm'/libreboard board auto-created - the user creates their own
boards from the All Boards page. The Users.after.insert hook no longer inserts that
board + Default swimlane. updateUserPermissions previously added the user as a
member of that single board (which would now crash, since the board no longer
exists); it now maps the grain's Sandstorm permissions to the user's GLOBAL WeKan
role instead - 'configure' (grain owner) becomes a WeKan admin, everyone else is a
regular user who can create and manage their own boards. Dropped the now-unused
Boards/Swimlanes imports.
Thanks to xet7.
WeKan on Sandstorm originally opened a single hard-coded board
(/b/sandstorm/libreboard) and redirected "/" to it. It now supports many boards and
that libreboard is no longer the right destination, so redirecting to it is wrong.
Remove the WebApp.handlers.get("/") redirect entirely: "/" now falls through to
WeKan's normal serving, whose client "home" route (config/router.js) renders the
All Boards list - the right landing page for a multi-board grain. This also removes
the server-side redirect that browsers rejected as a Corrupted Content Error
(FlowRouter.path does not resolve on the server + Content-Length mismatch).
Thanks to xet7.
Link the fix that builds the Sandstorm board redirect path directly and sends a
body with a matching Content-Length, so the grain's "/" stops failing with a
browser Corrupted Content Error.
Thanks to xet7.
The grain's "/" served a broken 301: Location was ".../:6080board" because
FlowRouter.path() does not resolve on the server in Meteor 3.x (it returned the
bare route name "board"), and the response advertised Content-Length: 90 while
sending an empty body, which the browser rejected as a "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT). Build the board path directly (/b/:id/:slug, the
'board' route) and send a real HTML body (meta-refresh + link) with a matching
Content-Length, so the redirect to the hard-coded Sandstorm board works. Together
with the Accept-Encoding strip (keeps Content-Length valid) and the modern bridge.
Thanks to xet7.
Link the fix that bundles a modern sandstorm-http-bridge (from the Sandstorm
install) so the grain stops serving Corrupted Content on WeKan's "/" redirect.
Thanks to xet7.
The grain boots, but the page failed with a browser "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT) on WeKan's "/" redirect (0 bytes, ~8.5s, then
failed). The meteor-spk 0.6.0 base bundles an ancient (~2016) sandstorm-http-bridge
that mangles responses - it always advertises Accept-Encoding: gzip to the app and
mishandles redirect/encoding. build-deps.sh now overwrites the bundled
/sandstorm-http-bridge with the modern one from a Sandstorm install
(/opt/sandstorm/latest/bin/sandstorm-http-bridge; override with SANDSTORM_HTTP_BRIDGE).
Sandstorm is amd64-only so one binary covers it. The sandstorm.yml workflow now
installs Sandstorm before assembling the deps so the bridge is available on the CI
runner. Complements the app-side Accept-Encoding strip.
Thanks to xet7.
Link the fix that strips Accept-Encoding in the Sandstorm grain so Meteor serves
uncompressed responses and the browser stops showing a Corrupted Content Error.
Thanks to xet7.
The grain now boots, but the page failed to load with a browser "Corrupted Content
Error" (NS_ERROR_NET_CORRUPTED_CONTENT). sandstorm-http-bridge advertises
"Accept-Encoding: gzip" to the app regardless of what the browser actually sent, so
Meteor served gzip/brotli-encoded responses the browser could not decode. Strip
Accept-Encoding in a WebApp.rawHandlers middleware (which runs before Meteor's
static/boilerplate serving) so responses go out uncompressed; bandwidth is a
non-issue behind the local bridge.
Thanks to xet7.
Link the fix that makes the services.sandstorm.id unique index use boolean
options so FerretDB accepts it and the Sandstorm grain boots past index creation.
Thanks to xet7.
wekan-accounts-sandstorm created the unique index on services.sandstorm.id with
{unique: 1, sparse: 1}. Real MongoDB accepts the truthy 1, but FerretDB (used by
the Sandstorm .spk) is strict and rejects it with "The field 'unique' has value
unique: 1, which is not convertible to bool", crashing the grain at boot during
index creation. Use real booleans (unique: true, sparse: true).
Thanks to xet7.
Link the fix that imports the Users/Boards/Swimlanes/Activities collections and
drops the removed-HTTP-package monkey patch, so the Sandstorm grain boots past
the capnp guard.
Thanks to xet7.
Once the capnp load was made non-fatal, the grain reached the rest of sandstorm.js
and hit two latent Meteor-2.x-isms the Meteor 3.x migration missed (this file only
runs on Sandstorm, so it was not exercised): (1) it referenced the Users, Boards,
Swimlanes and Activities collections as implicit globals, but those are now ES
module default exports, so `Users.after.insert` threw "Users is not defined" at
boot; and (2) it monkey-patched `HTTP.methods` from the meteor/http package, which
was removed in the Meteor 3.x migration, so `HTTP` was undefined. Import the
collections (and Accounts) explicitly, and remove the obsolete HTTP.methods patch
(WeKan's REST API no longer uses it - it uses WebApp/JsonRoutes with header auth).
Thanks to xet7.
Link the release-workflow change that builds amd64/arm64 natively on GitHub and
moves the exotic arches to a non-blocking per-arch Launchpad matrix, so the
release no longer hangs hours on riscv64.
Thanks to xet7.
The single snap job ran `snapcraft remote-build` for all 5 platforms on Launchpad
and blocked until the slowest resolved, so the release hung ~3.5h on riscv64's
Launchpad queue even though amd64+arm64 finished in minutes. Split it in two:
snap-native builds amd64 (ubuntu-24.04) and arm64 (ubuntu-24.04-arm) NATIVELY on
GitHub runners via snapcore/action-build; snap-launchpad builds s390x/ppc64el/
riscv64 on Launchpad in a NON-BLOCKING (continue-on-error), per-arch matrix
(remote-build --build-for <arch>) so a slow or failed exotic arch never delays the
release or the other arches. Each arch publishes to the Snap Store
(candidate,beta,edge) and attaches to the GitHub Release the moment it finishes.
All 5 arches still ship.
Thanks to xet7.
Link the fix that stops capnp.node's Node 24 ABI mismatch from crash-looping
the Sandstorm grain; the grain now boots and core WeKan works via the
sandstorm-http-bridge headers.
Thanks to xet7.
WeKan on Sandstorm bundles Node 24, but capnp.node (node-capnp) is built for an
older Node ABI (NODE_MODULE_VERSION 83 = Node 14), so `Npm.require('capnp')` in
sandstorm.js failed with ERR_DLOPEN_FAILED and crash-looped the whole grain on
boot. Load Cap'n Proto lazily in a try/catch and degrade gracefully: the grain
boots and core WeKan works, because login and user identity come from the
sandstorm-http-bridge X-Sandstorm-* HTTP headers (wekan-accounts-sandstorm) and
need no Cap'n Proto. Only the two capnp-only features are skipped when the addon
cannot load - the Powerbox identity-claim method and the Sandstorm activity
notifications - and they log a clear warning. These can be restored by rebuilding
node-capnp for Node 24, or reimplemented over the bridge's HTTP/JSON API (see the
Sandstorm CHANGELOG: use Cap'n Proto APIs without Cap'n Proto).
Thanks to xet7.
Link the fix that points FerretDB's --state-dir to writable /var so the
Sandstorm grain starts instead of crash-looping on a read-only /state.json.
Thanks to xet7.
FerretDB persists a state.json (version/UUID) via its state provider even
when telemetry is disabled, and its --state-dir defaults to "." — which in a
Sandstorm grain is "/", read-only. So FerretDB failed with "Failed to create
state provider: failed to persist state: open /state.json: read-only file
system", exited (code 1), and the grain crash-looped. sandstorm-src/start.js
now creates /var/ferretdb and passes --state-dir=/var/ferretdb (plus the
FERRETDB_STATE_DIR env) in startFerret(), covering both the migration and the
steady-state FerretDB launches.
Thanks to xet7.
List the glibc-loader grain crash-loop fix, the Sandstorm build-workflow
userns/ref fix, and the Meteor unit tests __dirname server-boot fix in the
Upcoming release section, each linking to its commit.
Thanks to xet7.
server/lib/tests/dependencies.openapi.tests.js is an ES module (top-level
import) that referenced the bare __dirname global. Under Node 24 / Meteor 3.5
the compiler injects "const __dirname = fileURLToPath(import.meta.url)" for
ESM files that use __dirname, which collides with the __dirname the CommonJS
module wrapper already provides, so the whole server bundle failed to boot
with "Identifier '__dirname' has already been declared" and the "Meteor unit
tests" CI job died before any test ran. Drop the __dirname seed
(process.env.PWD already reaches the repo root under `meteor test`, and the
suite skips gracefully otherwise).
Thanks to xet7.
Ubuntu 24.04 defaults kernel.apparmor_restrict_unprivileged_userns=1, which
blocks the unprivileged user namespaces the Sandstorm install and the spk
supervisor rely on, so sandstorm.yml now relaxes it on the runner. Also added
a "ref" workflow_dispatch input and check out that ref instead of a hardcoded
main, so a fix branch can be built and tested before it is merged to main.
Thanks to xet7.
The .spk bundled the host's new glibc libc.so.6 (Ubuntu 24.04, glibc 2.39)
but kept the old glibc 2.31 ld-linux from the meteor-spk 0.6.0 base, so node
failed at startup with "libc.so.6: undefined symbol: _dl_audit_symbind_alt,
version GLIBC_PRIVATE" (HTTP-BRIDGE exit 127) and the grain crash-looped.
sandstorm-src/build-deps.sh now also copies the host's ld-linux-x86-64.so.2
into the deps so the loader and libc are the same glibc, and adds a [verify]
gate that fails the build if the bundled libc and loader glibc versions
differ or the bundled node cannot run under them - turning a silent grain
crash-loop into a loud build failure.
Thanks to xet7.
Dependabot #6451 bumped lib-storage to ~3.1085.0, whose peer requires
client-s3 ^3.1085.0, but client-s3 was left at ^3.1071.0 — breaking
meteor npm install / npm ci with an ERESOLVE peer conflict.
- Drop the ~300 MB of modern MongoDB Database Tools (wekan/mongo-tools) from the
Sandstorm build: they are unused there (Sandstorm backs up/restores grains
itself; the MongoDB 3 -> FerretDB migration uses the legacy migratemongo CLIs +
the .mjs importer). They stay in the .zip bundle / Docker / Snap, which use them.
- Set PUPPETEER_SKIP_DOWNLOAD=1 for the Meteor build (puppeteer is a test-only
devDependency; its ~150 MB Chromium must never end up in the .spk).
- Pack step prints the .spk size and warns when it exceeds 100 MB.
meteor-spk pack crashed with 'open(/home/runner/.sandstorm-keyring): No such file
or directory' because the keyring restore step wrote the decoded SANDSTORM_KEYRING
secret to ~/.sandstorm/sandstorm-keyring, but spk reads the app private key from
~/.sandstorm-keyring (a file directly in $HOME). Write it to the correct path, and
fail early with an actionable message when the secret is missing (the .spk cannot
be signed without it) instead of the cryptic later crash.
The meteor-spk 0.6.0 base ships some runtime libs in meteor-spk.deps/lib as
DANGLING symlinks (e.g. libstdc++.so.6 -> a lib that no longer exists). When step
[3/7] refreshed them with the host's newer libs, 'cp -fL' refused with 'not writing
through dangling symlink' and failed the Sandstorm build. Add --remove-destination
so cp deletes the existing destination (dangling symlink included) before copying
the host's real library. Verified: plain 'cp -fL' reproduces the error; with
--remove-destination it succeeds and writes a real file.
In the swimlanes view, boardBody.js disabled the .js-swimlanes sortable — which
also carries moving a card from one swimlane to another — with !isBoardAdmin(),
though its own comment said it should be disabled only for non-members. So a normal
board member could move a card within a swimlane but not between swimlanes (and
could not reorder swimlanes). Gate it on !Utils.canModifyCard() instead (the client
write-access predicate: board member, not comment-only/worker/read-only), matching
the server's allowIsBoardMemberWithWriteAccess.
The install page (../w/wekan.fi/install/index.html) links to Node.js downloads for
each snap/bundle arch: the OFFICIAL nodejs.org build (amd64/arm64/s390x/ppc64le)
and the UNOFFICIAL unofficial-builds.nodejs.org build (riscv64, which nodejs.org
has no binary for). release-website.sh only updated the displayed Node.js version
number, and version.sh's local website block did not touch Node.js at all, so those
download URLs went stale (404) after a Node.js bump.
Both now rewrite the version in the nodejs.org/dist/vX.Y.Z and
unofficial-builds.nodejs.org/download/release/vX.Y.Z paths and the
node-vX.Y.Z-linux-<arch> filename to the current Dockerfile NODE_VERSION.
FerretDB/ and sandstorm/ were already ignored; add /mongo-tools/ and /mongosh/ so
that temporarily cloning those wekan repos under this repo for editing never risks
committing them (they are separate git repos).
core24 allows the candidate channel (core26 does not), so the automated
release-all.yml snap job publishes to candidate + beta + edge. The stable channel
is not published by the workflow — it is done manually later once proven. Updated
the workflow, snapcraft.yaml header, CHANGELOG and CPU-platforms.md.
The default snapcraft.yaml is base: core24 (grade: stable) so it CAN be published
to the Snap stable channel, but that is done manually once it is proven stable
enough (core26 cannot go to stable). Revert the automated release-all.yml snap job
to publish to edge + beta channels (was candidate + edge). Update the workflow
header, snapcraft.yaml header, CHANGELOG and CPU-platforms.md to match.
Bundle launchers (releases/ferretdb/start-wekan.sh/.bat): run the bundle's own
per-platform binaries — FerretDB (./ferretdb[.exe]) in the background and WeKan
(./main.js on the bundled ./node[.exe]) in the foreground — inside a restart loop,
so if WeKan exits the whole stack restarts. Ctrl-C / closing the window stops both.
Snap wekan part: the wekan-<arch>.zip bundle is now the single source of every
binary. Handle all snap arches (amd64, arm64, s390x, ppc64el->ppc64le, riscv64);
use the bundle's OWN arch-correct Node.js (fixes riscv64, which nodejs.org has no
build for) instead of re-downloading from nodejs.org; move the bundle's per-arch
MongoDB Database Tools into $SNAP/bin; keep the bundle's ferretdb. Drop the
separate mongotools part (the tools already ship in the bundle), so nothing is
downloaded twice.
Previously the per-arch MongoDB Database Tools were only in the Snap and the
Windows bundle. Now every build embeds them from the wekan/mongo-tools fork's
newest release (per-arch <tool>-<arch>):
- Linux .zip bundles (amd64/arm64/s390x/ppc64le/riscv64) download their arch's 8
tools; the win64 and macOS jobs drop the inherited Linux tools and add their own
(win64 .exe, mac-arm64). The Docker image inherits them from the Linux bundle.
- Sandstorm build-deps.sh adds the amd64 tools (separate from the legacy MongoDB
3.2 migratemongo CLIs used for the one-time migration).
All from wekan/mongo-tools, none from mongodb.com.
Databases / MongoDB clients:
- Remove the mongosh binary everywhere (snap + bundles). All scripted DB access
(readiness ping, replica-set initiate/status, v8.43 schema migration) now runs
through the new snap-src/bin/db-eval (bundled Node.js 24 + the mongodb driver).
Ports wekan-control, mongodb-control, migration-control, start-wekan.sh/.bat and
migrate-schema-v843.js -> .mjs off mongosh. The legacy MongoDB 3.2 mongo shell
(migratemongo, amd64) stays for migration-time reads only.
- MongoDB Database Tools now come from the wekan/mongo-tools fork (pure Go, every
arch) instead of mongodb.com, in snapcraft and the Windows bundle. version.sh /
test-download-urls.sh no longer probe mongodb.com for mongosh/tools. The MongoDB 7
server (mongod) is still fetched from MongoDB (amd64/arm64 only).
- FerretDB v1 downloaded per-arch (ferretdb-<arch>) from wekan/FerretDB, no
ferretdb.zip: release-all.yml bundle jobs, docker-compose.yml, sandstorm build.
Snap:
- Default snapcraft.yaml is now the core24 grade:stable build (core26 variant kept
as snapcraft-core26.yaml); release publishes to candidate + edge.
- Add s390x, ppc64el, riscv64 platforms as FerretDB-only (mongod not built there);
new installs default to FerretDB v1 (snap/hooks/install already guards existing
MongoDB data), DDP_TRANSPORT=sockjs. Fix Caddy ppc64el->ppc64le mapping.
Docs: add docs/Platforms/FOSS/Snap/CPU-platforms.md; update CHANGELOG and the
Sandstorm migration doc.
Add a "Build from source & start (up -d --build)" action to the Docker
menus in rebuild-wekan.sh and rebuild-wekan.bat. It builds the wekan-app
image from the local Dockerfile, tags it as the image the chosen compose
file references (ghcr.io/wekan/wekan:latest), and starts that freshly
built container instead of a possibly-stale prebuilt image pulled from
the registry - useful when a source change (e.g. the FerretDB
Version-page detection) is not in the pulled image yet.
Remove the obsolete top-level "version:" attribute from all docker
compose files, which Docker Compose v2 warns about and ignores:
docker-compose.yml, docker-compose-ferretdb-v2-postgresql.yml,
docker-compose-multitenancy.yml, .devcontainer/docker-compose.yml, and
docs/Databases/ToroDB-PostgreSQL/docker-compose.yml.
Thanks to xet7 !
Rename docker-compose-ferretdb-v1-sqlite.yml -> docker-compose.yml (the
new default) and the old MongoDB docker-compose.yml ->
docker-compose-mongodb-v7.yml, so "docker compose up -d" uses the light,
self-contained FerretDB v1 + embedded SQLite from
https://github.com/wekan/FerretDB by default.
Update rebuild-wekan.sh and rebuild-wekan.bat Docker menus to list
FerretDB v1 SQLite first (default) and point at the new filenames.
Update the compose files' own header comments (self-identification and
cross-references), note the new default at the top of docker-compose.yml,
and update the Docker docs (main Docker.md gets a compose-file/database
map; FerretDB2-PostgreSQL.md points at the v2 postgresql compose file).
Thanks to xet7 !
The Dev server options killed only the app port, but "meteor run" also
starts an rspack dev server on 8080 that can outlive the meteor parent.
A leftover one made restarting a dev server crash with
"Error: listen EADDRINUSE ... :8080". The kill now frees both the app
port and the rspack port 8080 (Meteor.devServerPort || 8080), matching
the rspack watcher by its devServerPort env.
Port detection now checks the listening socket directly (ss/lsof with a
bash /dev/tcp fallback that needs no external tools; netstat on .bat)
instead of an HTTP probe, so it also catches a server that is still
building and not yet serving. Fixes a hang where lsof did reverse-DNS
without -nP.
Add a new "Dev server -> Kill all dev servers" menu option that frees
every dev/test port the scripts use at once: dev app 3000 + Mongo 3001,
Mocha test server 3100 + Mongo 3101, Sandstorm standalone dev 4000 +
Mongo 4001, and rspack 8080. It kills meteor, the rspack watcher and
Meteor's bundled --replSet meteor Mongos, never a production/system
Mongo. Both scripts escalate to SIGKILL if a port does not free up.
Update CHANGELOG and Build-from-source.md docs.
Thanks to xet7 !
Every "Dev server" menu option now stops any Meteor dev server already
listening on its port before starting a fresh one, so re-running a dev
option no longer fails because the port is taken. .sh adds a reusable
kill_meteor_on_port helper called by all six dev options; .bat funnels
them through :runlog, which calls a new :kill_meteor_on_port (netstat +
taskkill). Both wait for the port to free up and escalate to SIGKILL.
Also update the build-from-source docs (README.md, Build-from-source.md,
Build-and-Create-Pull-Request.md, Emoji.md, and the two Sandstorm
developer docs) to the new two-level menu (Setup -> Install dependencies,
Setup -> Build WeKan, Dev server -> localhost:3000), fix a stale
dev-server port (localhost:4000 -> 3000), and fix the README anchor link
to the renamed heading. Add a CHANGELOG entry.
Thanks to xet7 !
Use the Finnish term jyva for Sandstorm grain in the sandstorm-migration
strings: Sandstorm-jyvassa, jyvan hakemistoihin, kayttavat jyvassa, jyvan
levytilan.
Translate the 12 sandstorm-migration-* strings in fi.i18n.json, matching the
existing WeKan Finnish terminology (siirto, Liitetiedostot, Profiilikuvat,
tiedostojärjestelmä, Poista, Tallennustila). The proper noun Sandstorm and the
literal paths/DB names are kept as-is.
Remove the en.i18n.json key sandstorm-migration whose value was the bare
proper noun 'Sandstorm' (the https://sandstorm.org project name), and hardcode
'Sandstorm' in the Admin Panel / Attachments tab label and panel title instead.
The other sandstorm-migration-* keys are real translatable strings and stay.
Replace the long flat menu with a short top-level menu (Setup / Dev server /
Tests / Docker / Tools / Quit); each opens a small submenu with 0) Back, so
only a few items show at once and labels are shorter (category gives context).
Docker becomes a two-stage submenu: pick backend, then Start (up -d) / Follow
logs (logs -f) / Stop (down) -- removing 12 near-identical flat entries.
All action handlers are unchanged; only the front-end dispatch is regrouped:
.sh keeps every case handler and reaches them via a choose() 'short|full' map
(dispatch wrapped in 'for _once in 1'); .bat routes category submenus to the
existing :labels. Verified: .sh bash -n clean + all 23 menu labels map to a
handler; .bat has no orphan gotos.
Expands the docker-compose menu so each backend (MongoDB docker-compose.yml,
FerretDB v1 SQLite, FerretDB v2 PostgreSQL, MongoDB Multitenancy) has three
grouped entries -- Start (up -d), Follow logs (logs -f) and Stop (down) --
instead of just Start. The .sh uses a generalized wekan_docker helper that
auto-detects 'docker compose' vs legacy 'docker-compose'.
New menu entries run 'docker compose -f <file> up -d' for each backend:
MongoDB (docker-compose.yml), FerretDB v1 SQLite, FerretDB v2 PostgreSQL,
and MongoDB Multitenancy. Each prints follow-logs + stop commands. The .sh
auto-detects 'docker compose' vs legacy 'docker-compose'.
The MONGODB_LOG_PATH/TOPOLOGY/SERVER_SELECTION/CONNECTION=debug vars were
temporary mongodb-driver diagnostics that got committed by accident; they
flood wekan-app logs with topology JSON. Remove them from the example compose.
Driver debug logs showed a 2nd, TLS-enabled Mongo monitor connection
retrying every ~0.5s against the plaintext FerretDB port; FerretDB logged
each rejection at WARN. Harmless (WeKan uses the real plaintext connection)
but noisy. Start FerretDB with --log-level=error everywhere it is launched
(Docker entrypoint, snap ferretdb-control, release start-wekan.sh, the
v1-sqlite compose example).
Also remove the dead, unused MongoDB Driver System (server/mongodb-driver-startup.js
and models/lib/{meteorMongoIntegration,mongodbConnectionManager,mongodbDriverManager}.js)
- an abandoned MongoDB 3.0-8.0 auto-detect/driver-select experiment whose
driver packages were never installed. WeKan uses the mongodb-7 driver via Meteor.
gitignore the local FerretDB/ reference clone.
The caddy part fetched Caddy from the Cloudsmith apt repo
(curl dl.cloudsmith.io | gpg --dearmor), which fails on Launchpad remote
builders (restricted network via a fetch proxy): gpg got no key
('no valid OpenPGP data found') and override-build failed with code 2 on
both arches. Download the official prebuilt vanilla Caddy static binary from
GitHub releases instead (per-arch, latest stable + pinned fallback). WeKan
uses only built-in Caddy directives, so no apt repo, gpg, or xcaddy needed.
Applied to both snapcraft.yaml and snapcraft-core24.yaml.
The wekan/FerretDB v1 fork's buildInfo reports a top-level ferretdbVersion
string (e.g. v1.24.2-60-gb5523566) + ferretdbFeatures, and its commit in
gitVersion -- not a ferretdb sub-document. Detection only checked
buildInfo.ferretdb, so the Version page showed Database type: MongoDB and
hid the FerretDB rows. Handle both shapes: top-level ferretdbVersion/
ferretdbFeatures and the ferretdb sub-document. Also confirm in the Sandstorm
doc that FerretDB writes <dir>/wekan.sqlite (+ -shm/-wal).
- start.js: chain niscu (2.x) -> MongoDB 3.0 -> FerretDB; keep niscud + old
bundled driver for the niscu->3.0 stage. Migration from old versions is
permanent (not dropped).
- build-deps.sh (new): assemble a modern meteor-spk.deps on top of upstream
meteor-spk 0.6.0 from dl.sandstorm.io (already has mongod 3.0 + niscud);
swap in Node 24, add FerretDB + Mongo 3.x CLIs + launcher/importer, keep
niscud. Extra binaries from GitHub releases. Nothing needed from projects.7z.
- sandstorm.yml: replace the dead releases.wekan.team/projects.7z download with
build-deps.sh.
- sandstorm-pkgdef.capnp: WRITABLE_PATH -> /var/files.
- docs: keep-niscu + build-source corrections; migration support is permanent.
CJS meteor-spk.deps launcher: on grain start, if old MongoDB 3.0 data exists and
migration is not yet done, start mongod 3.0 (wait for ready), start FerretDB v1
(SQLite), run migrate-mongo3-to-ferretdb (text -> FerretDB, GridFS attachments/
avatars -> /var/files), mark done (old data kept); then run WeKan (Node 24) on
FerretDB steady-state. Paths resolve from __dirname (grain mount root). Update
the design doc to point at the canonical file and mark the in-app panel done.
projects/ (unarchived meteor-spk fork binaries) and sandstorm/ (clone of
sandstorm-io/sandstorm) are large, machine-specific working copies used only
when building the Sandstorm .spk; never commit them.
When isSandstorm, show a new Sandstorm section in Admin Panel / Attachments:
one-time MongoDB 3 -> FerretDB migration status, disk space used by the raw
MongoDB 3 files / FerretDB SQLite / attachments / avatars in the grain, and a
guarded button to delete the redundant raw MongoDB files (only after a
confirmed-successful migration, behind a confirm) to free grain disk space.
- server/methods/sandstormMigration.js: admin-gated sandstormMigrationStatus
+ sandstormDeleteRawMongo (isSandstorm-gated, env-overridable grain paths).
- client Attachments: new Sandstorm tab (jade + js), shown only when isSandstorm.
- imports/i18n/data/en.i18n.json: sandstorm-* UI strings.
- migrate-mongo3-to-ferretdb.mjs: optional STATUS_FILE with the final migration
state, which the panel reads (shared by snap + the Sandstorm spk).
Existing Sandstorm docs live under docs/Platforms/FOSS/Sandstorm/; move the
new Meteor3/Migration.md there (was mistakenly at docs/Platforms/Sandstorm/),
fix its relative links (one level deeper) and the CHANGELOG path.
Add docs/Platforms/Sandstorm/Meteor3/Migration.md: how to build a modern
Sandstorm .spk (Node 24, FerretDB v1 SQLite) migrating an existing grain's
MongoDB 3.0 data on first launch, reusing the snap's migrate-mongo3-to-ferretdb
logic. Includes seccomp compatibility analysis, rewritten start.js, and a new
isSandstorm-only Admin Panel / Attachments / Sandstorm section design.
wekan.help backup/migration instructions hardcoded
/var/snap/wekan/common/files, which is wrong for a parallel snap install
(e.g. wekan_customer -> /var/snap/wekan_customer/common). Print
$SNAP_COMMON/files instead, which snapd sets per instance. Display-only;
all functional snap paths already derive from $SNAP_COMMON/$SNAP_DATA.
The .loadLanguage suite stubbed addResourceBundle on the shared
TAPi18n.i18n singleton and asserted the call count. loadLanguage()
reads this.i18n late and each test re-init()s the singleton, so
cross-test state could leave the stub on a different i18next instance
than the bundle was registered on, making the count read 0 and the run
intermittently report 'expected addResourceBundle to be called once'.
Assert on observable i18next state (hasResourceBundle/getResourceBundle
under the normalised toI18nCode) instead, and stop a leaked
Tracker.autorun in the .getLanguage reactive test.
registerCron() used the synchronous BackupSettings.findOne/upsert, which
Meteor 3 forbids on the server, so cron init threw and scheduled backups
never registered. Use findOneAsync/upsertAsync, make registerCron async,
and await it in all callers. Add tests/backupCron.test.cjs (positive +
negative + source guard) to the test:unit:node suite.
The Meteor/rspack build failed on server/methods/backup.js:
- archiver@8 is now pure ESM with no default export and no archiver('zip', …)
factory — it exports classes. Use `import { ZipArchive } from 'archiver'` and
`new ZipArchive({ zlib: { level: 6 } })` (the streaming API — directory/append/
finalize/pipe/on — is inherited from the Archiver core class).
- The S3 streaming upload requires @aws-sdk/lib-storage, which was not a
dependency (only @aws-sdk/client-s3 was), so rspack failed with
"Can't resolve '@aws-sdk/lib-storage'". Add it, pinned to ~3.1073.0 to match
the installed @aws-sdk/client-s3 (3.1073.0) and avoid the newer lib-storage's
peer requirement on a newer client-s3.
Refresh the Upcoming section: re-point every commit link to the current hash
(the earlier links referenced pre-rebase hashes that are not on GitHub), and add
bullets for the newest commits — Transifex duplicate-key fix, Finnish
translations, unit + negative tests, and the Alternative-Architectures doc.
Document which CPU architectures WeKan can run on and why, the architecture
matrix (Node 24 vs MongoDB vs FerretDB v1), why armhf/armv7l/i386/loong64 are
not supported (Node dropped 32-bit; QEMU/base-image gaps for loong64), the
JavaScript-engine alternatives (Deno/Bun cover fewer arches; QuickJS/JSC/
SpiderMonkey/JVM run on 32-bit but cannot run Meteor), and the server-rewrite
options (Go recommended, QuickJS niche, Tcl/Tk a poor fit for a web app), plus
hardware notes (ODroid-U3, Raspberry Pi OS 32-bit, ReactOS).
Extract the pure, security- and correctness-critical logic of the recent features
into Meteor-free models/lib/* modules (so production and tests share one
implementation) and add plain-Node tests with positive AND negative cases, wired
into test:unit:node:
- models/lib/mongoSelectorSafety.js hasWhere() — the windowed card publication
refuses a client selector carrying $where (server-side JS). Tests: safe
selectors pass; $where at top level / in $and / deep in arrays / in $elemMatch
is rejected. (was inline in server/publications/cardsWindow.js)
- models/lib/cardsLoading.js resolveCardsLoadingMode / parseCardsLoadingEnv /
windowCountId — CARDS_LOADING maps to 'lazy' only for exactly "lazy" (garbage
never enables the experimental mode); a falsy swimlane id collapses to one
count-doc id (listBody uses undefined, listHeader uses ''). (used by
server/cards-loading.js, server/models/settings.js, client/lib/lazyCards.js)
- models/lib/base64Chunk.js encodeAligned/encodeFinal — streaming an attachment
into the JSON export as base64 in any chunk sizes reconstructs exactly the
whole-file base64; aligned pieces never carry padding. (used by
Exporter.buildStream)
- models/lib/backupPaths.js filesRootFrom/scheduleText — WRITABLE_PATH -> files
root (no double /files suffix, Windows-safe) and the synced-cron schedule text
(daily/weekly/monthly + defaults, unknown frequency falls back to daily). (used
by server/methods/backup.js)
41 new assertions across 4 files, all passing; whole test:unit:node suite green.
en.i18n.json had two "database-migration" and two "database-migration-description"
keys, so the Transifex push failed with "Duplicate string key". The later
(block-2) copies — "Database Migration" / "Updating database structure..." — were
unused (only the Admin Panel / Attachments migration UI references these keys, and
it needs the MongoDB<->FerretDB wording in the earlier copy). Removed the unused
duplicates; JSON now parses with unique keys.
In lazy mode minimongo holds only the loaded window, so the list card-counter
badge and the WIP-limit checks (reachedWipLimit / exceededWipLimit, the add-card
gate, and setting the WIP limit to the current count) previously reflected only
the cards loaded so far. They now use lazyListCardCount(), which subscribes to
and reads the server-side count for the SAME selector list.cards(swimlaneId) uses
(listCardsSelector + the board Filter) — so the count is exactly the accurate
value list.cards(swimlaneId).length would report if every card were loaded. The
template's whole-list count uses (cards.length) are routed through a new
cardsCountWhole helper the same way. Default 'all' mode falls back to the loaded
length and is unchanged.
Adds the CARDS_LOADING=all|lazy knob everywhere WeKan is configured:
- docker-compose.yml and the two FerretDB compose files (v1-sqlite, v2-postgresql)
- releases/ferretdb/start-wekan.sh and start-wekan.bat (bundle launchers)
- releases/virtualbox/start-wekan.sh
- snap: added to snap-src/bin/config keys + triplet, so
"snap set wekan cards-loading=lazy" works, with an example in wekan-help.
All default to 'all' (unchanged behaviour).
Client side of CARDS_LOADING=lazy. In lazy mode each list's cardsWithLimit
subscribes to boardCardsWindow for just the cards it renders (growing with the
infinite-scroll limit) and to boardListCardCount for the total; showSpinner uses
that server count to decide whether more cards remain, since minimongo now holds
only the loaded window. Default 'all' mode is unchanged (helpers short-circuit on
isLazyCards()).
Adds Admin Panel / Features — a new admin section (route /admin-features, nav
entry, adminFeatures template) intended as the home for optional / performance /
future tier-gated capabilities. First control: "Card loading" (all | lazy), which
writes the cardsLoading Setting; it mirrors, and is overridden on startup by, the
CARDS_LOADING env var. Includes an experimental-note (counters/WIP limits and the
Calendar/Table/Gantt views reflect only loaded cards; reload boards after change)
and the i18n keys.
Adds an opt-in card-loading mode for very large boards. Default 'all' is
unchanged: the board publication still ships every card into minimongo.
When CARDS_LOADING=lazy (env) or Admin Panel / Features = lazy:
- The board publication no longer ships all cards, their comments/attachments,
or the board-level checklists/checklistItems (gated by a lazyCards() check).
- A new publish-composite 'boardCardsWindow' publishes only the cards of one
list/swimlane window (the client passes the exact selector it renders with,
which is ANDed with a server-forced { boardId, archived:false } scope and
refuses $where), plus each visible card's comments, attachments, checklists
and checklist items. As the user scrolls the client raises the limit and
re-subscribes, streaming more cards in on demand.
- A 'boardListCardCount' publication reactively publishes each window's total
card count into the client-only boardListCardCounts collection, so the client
knows whether more cards remain to load.
The mode is a Settings field (cardsLoading), seeded from the CARDS_LOADING env
var (authoritative on startup when set) and mirrored onto
Meteor.settings.public.cardsLoading for the publications to read; an
after.update hook keeps it in sync when an admin changes it.
The client HTML export cloned the live DOM and built the whole .zip as an
in-memory blob. Two problems on large boards: infinite scroll keeps only ~10
cards per list in the DOM, so most cards were missing from the export; and the
entire archive was buffered in browser RAM.
Before snapshotting, every list's cardlimit is now lifted so the whole board
renders (then we wait for Blaze to flush). The zip is written with JSZip's
generateInternalStream piped straight to the chosen file via the File System
Access API, chunk by chunk with backpressure, so the archive is never held
whole in memory; browsers without the API fall back to the previous blob
download.
The board export routes buffered the entire board in memory before sending:
JSON export built one object with every card, comment, activity, checklist and
base64 attachment; CSV called that same build(); Excel additionally loaded data
it never renders (activities/checklists/subtasks/rules) and did O(n^2)
result.cards.find() lookups. On big boards this peaked at gigabytes and could
exceed V8's max string length.
Now:
- Exporter.buildStream(res) writes the JSON document straight to the response a
document at a time from raw Mongo cursors, encoding each attachment file to
base64 in 3-byte-aligned chunks. Same _format/keys, so import round-trips.
- Exporter.buildCsvStream(res) keeps only the small lookup tables in memory and
streams cards from a cursor, writing one CSV/TSV row at a time.
- ExporterExcel uses the exceljs streaming WorkbookWriter, committing each row
to the response, drops the unused collections, and resolves parent/comment
card titles via an id->title map instead of scanning all cards.
Peak memory stays flat regardless of board size.
Admin Panel / Attachments / Backup no longer loads whole files or the whole
zip into memory. The backup zip is written with archiver, streaming each
attachment/avatar straight from disk (archive.directory) and each text
collection a document at a time from a Mongo cursor as NDJSON, piped directly
to the destination (filesystem or S3/Azure/GCS streaming upload) with no temp
file. Restore reads the zip with unzipper, piping each file entry to disk and
each NDJSON data entry line-by-line in 200-doc batches. A board with thousands
of cards or a 5 GB attachment now backs up/restores with flat memory.
Sandstorm packaging is not tested well enough yet, so it should not run as part of
a full release. Remove the build-sandstorm job from release-all.yml, and add a
separate, manually-triggered sandstorm.yml that ONLY builds the Sandstorm .spk and
uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the newest WeKan GitHub
Release (found via `gh release list`), plus a workflow artifact — so it can be
downloaded and tested for errors without affecting releases. Same experimental
caveats (unprivileged user namespaces, SANDSTORM_KEYRING signing secret).
Thanks to xet7.
Adds a Backup section to Admin Panel / Attachments:
- Checkboxes to select Attachments, Avatars and/or Data (all text-based
collections that are not attachments/avatars).
- Storage dropdown (filesystem, S3/MinIO, Azure, GCS).
- Backup now: the .zip is STREAMED DIRECTLY to the selected storage — no temp
file, no extra disk — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip containing
YYYY_MM_DD-HH_MM_SS/{attachments,avatars,data/<collection>.json}. Filesystem
pipes to the file; S3 uses @aws-sdk/lib-storage streaming Upload, Azure
uploadStream, GCS createWriteStream (cloud creds from the storage tabs).
- Scheduler (off/daily/weekly/monthly + time/day) saved and run via synced-cron.
- List backups: table with storage, datetime, path; select one and Restore with
"Add missing data only" or "Replace all data".
Server methods (admin-only): runBackup, restoreBackup, listBackups,
getBackupSchedule, saveBackupSchedule, backupStatus (polled for live progress).
The cloud upload paths and restore are not exercised end-to-end here; the whole
zip is assembled with jszip, so very large attachment sets use notable memory.
Thanks to xet7.
Adds a "Database migration" section to Admin Panel / Attachments with two buttons:
migrate text-based data (everything except attachments/avatars, which stay on the
filesystem) to FerretDB v1 (SQLite), or back to MongoDB.
WeKan is connected to one database at a time, so the new server methods open a
SECOND driver connection to the OTHER database (both MongoDB and FerretDB v1 speak
the MongoDB wire protocol) and copy the text collections from the live database
into it, upserting by _id (idempotent, re-runnable). The target URL is
WEKAN_FERRETDB_URL (default mongodb://127.0.0.1:27018/wekan) or WEKAN_MONGODB_URL
(default mongodb://127.0.0.1:27019/wekan); both must be running. Progress is polled
via migrateTextDatabaseStatus and shown live. After it finishes, point MONGO_URL at
the other database and restart (on Snap: snap set wekan database=ferretdb / =mongodb).
Admin-only. Attachments/avatars are skipped (they live on the filesystem).
Thanks to xet7.
Per the design: a MongoDB 7 database works with newest WeKan as-is and is NOT
migrated; only the old WeKan 6.09 / MongoDB 3.2 data is migrated to FerretDB v1.
- migration-control now: if mongod 7 can open the existing $SNAP_COMMON data it is
MongoDB 6/7 -> no migration (mark done, keep MongoDB); otherwise it is MongoDB
3.x -> migrate to FerretDB. The dump/restore-into-mongod-7 hop is removed.
- New importer migrate-mongo3-to-ferretdb.mjs reads MongoDB 3.2 with the legacy
mongo CLI (mongoexport — the modern Node driver can't talk to 3.2) and inserts
into a running FerretDB with the Node driver: text data streamed directly, and
CollectionFS/Meteor-Files GridFS attachments+avatars reassembled per-file from
exported chunks straight to files/attachments and files/avatars (no duplicate
copy, no mongodump/mongorestore, no intermediate MongoDB 7). Progress at ROOT_URL.
- FerretDB SQLite now lives at <files>/db, next to attachments/avatars (the
"files/<name>" layout), in ferretdb-control, migration-control, the offline
start-wekan.sh/.bat, the Docker entrypoint and docker-compose-ferretdb-v1-sqlite.
The amd64 6.09/MongoDB-3.2 path is not yet tested against real 6.09 data.
Thanks to xet7.
The bundled Node MongoDB driver cannot connect to a MongoDB 3.2 server, and no
single driver version spans 3.2 and MongoDB 7 / FerretDB. So instead of aliasing
an old (EOL) Node driver into package.json, use the migratemongo CLI tools for the
amd64 6.09/3.2 case: migration-control now dumps the 'wekan' database with the old
migratemongo mongodump (MongoDB 3.2, old libraries), then loads it into a fresh
temporary mongod 7 with the snap's modern mongorestore. The existing driver-based
migrator then reads that mongod 7 exactly like the arm64 MongoDB-7 case (both
GridFS types -> filesystem, text -> FerretDB v1 SQLite). The MongoDB-7 path
(arm64 + amd64-on-newest-WeKan) connects the driver directly, unchanged.
Also fix a real bug: the standalone migrator script in $SNAP/bin could not resolve
its `mongodb`/`bson` imports; set NODE_PATH to the WeKan bundle's node_modules.
Thanks to xet7.
Per docs/Backup/Backup.md and https://github.com/wekan/migratemongo, running the
old MongoDB tools/server in the snap needs LC_ALL=C, the mongo binaries on PATH,
and their libraries on LD_LIBRARY_PATH ($SNAP/lib/<arch>-linux-gnu). The 2016
MongoDB 3.2 binaries additionally need old libraries (libssl/libcrypto.so.1.0.0,
libpng12, libexpat) that modern bases lack.
- Add a migratemongo snapcraft part (both snapcraft.yaml and snapcraft-core24.yaml)
that stages https://github.com/wekan/migratemongo at $SNAP/migratemongo (bin/ =
MongoDB 3.2 binaries, lib/x86_64-linux-gnu/ = the old libraries, avx/ = QEMU
wrappers for non-AVX CPUs), amd64 only. This also fills in the
$SNAP/migratemongo/avx path that mongodb-control / mongodb-backup /
mongodb-restore already referenced but was never bundled.
- Replace the naive fresh-download mongodb-old part (a bare 3.2 mongod with no old
libs) with this.
- migration-control now reads the amd64 6.09 MongoDB 3.2 data with
$SNAP/migratemongo/bin/mongod, LD_LIBRARY_PATH=$SNAP/migratemongo/lib/..., and
the legacy $SNAP/migratemongo/bin/mongo shell (mongosh cannot talk to 3.2). The
MongoDB 7 path (arm64 + amd64-on-newest-WeKan) still uses the snap's mongod 7 +
mongosh.
Thanks to xet7.
On first boot after upgrading an old MongoDB-based WeKan snap, mongodb-control now
hands off to a new migration-control before starting mongod:
- Detects existing MongoDB data in $SNAP_COMMON and opens it with the right
bundled mongod: mongod 7 for MongoDB 7 data (all arches, including the arm64
snaps already on newest WeKan), or a bundled old mongod 3.2 (amd64 only, new
mongodb-old part) for WeKan 6.09 / MongoDB 3.2 data.
- Starts a temporary FerretDB v1 (SQLite) and runs migrate-mongodb-to-ferretdb.mjs,
which migrates text data to FerretDB and BOTH CollectionFS GridFS and
Meteor-Files GridFS attachments+avatars to the filesystem, shows a live progress
counter at ROOT_URL, checkpoints to $SNAP_COMMON (resumable), and compacts the
old MongoDB when done.
- On success switches the snap to database=ferretdb.
Idempotent (migration-pending helper + completion marker), resumable, never
deletes the source MongoDB data, only switches on success. The migrator is bundled
into the snap (/bin). Progress dashboard title fixed to 'MongoDB -> FerretDB'.
NOT yet tested end-to-end against a real 6.09 snap; in particular the 2016 mongod
3.2 binary may need extra runtime libraries on a modern base (amd64 6.09 path).
The arm64/MongoDB-7 path uses the already-bundled mongod 7.
Thanks to xet7.
- docker-compose-ferretdb.yml -> docker-compose-ferretdb-v2-postgresql.yml
(FerretDB 2 + PostgreSQL/DocumentDB, unchanged database).
- Add docker-compose-ferretdb-v1-sqlite.yml: FerretDB v1 with the embedded SQLite
backend, no PostgreSQL/MongoDB. Its ferretdb service fetches the v1 binary for
its architecture from the newest wekan/FerretDB release
(https://github.com/wekan/FerretDB/releases), caches it on the volume, and runs
it with --handler=sqlite.
Both compose files now carry the FULL wekan service from docker-compose.yml
(every documented environment variable and feature); the ONLY differences are
database-related: the ferretdb service replaces the mongodb service, MONGO_URL
points at FerretDB, MONGO_OPLOG_URL is dropped and METEOR_REACTIVITY_ORDER is
polling (FerretDB has no MongoDB change streams / replica-set oplog).
Thanks to xet7.
When upgrading from an old WeKan snap, $SNAP_COMMON/Caddyfile may still be in
Caddy v1 format, which Caddy 2 cannot parse (so caddy would fail to start).
caddy-control now runs migrate-caddyfile-v1-to-v2.sh before 'caddy run' when
caddy is enabled. It is safe: a no-op if the file already parses as Caddy 2;
otherwise it backs up the original, converts the common v1 directives (drop the
http:// scheme, 'proxy / TARGET' -> 'reverse_proxy TARGET', drop the v1
'websocket'/'transparent' presets that Caddy 2 does by default, 'gzip' ->
'encode gzip'), validates the result with 'caddy adapt', and only keeps it if
valid — falling back to the shipped Caddy 2 template otherwise, so caddy always
starts with valid config.
Thanks to xet7.
Two additions to the MongoDB -> FerretDB / GridFS -> filesystem migrator used by
the Snap and Sandstorm migrations:
- Resumable: progress is checkpointed to $WRITABLE_PATH/migration-progress.json
(atomic write) after every collection and file phase. On restart the migrator
loads the checkpoint and SKIPS collections it already finished, so an
interrupted migration (snap refresh, Sandstorm grain restart, power loss)
continues instead of starting over. Safe because every document is upserted.
- Compact after success: once the migration is complete (fresh run, or a later
boot that finds the completion marker), it runs 'compact' on each source
MongoDB collection so WiredTiger returns the now-duplicated disk space to the
OS. Runs once (tracked by a 'compacted' flag on the marker) and is best-effort.
The existing ROOT_URL progress dashboard and disk-space checks (statfsSync) are
kept; the dashboard is restored from the checkpoint on resume.
Thanks to xet7.
Mirrors releases/release-sandstorm.sh + releases/install-sandstorm.sh: installs
Meteor, meteor-spk 0.6.0 (from the WeKan projects.7z) and a dev Sandstorm, then
'meteor-spk pack' produces wekan-<version>.spk and attaches it to the GitHub
Release. Decoupled from the release job so it never blocks the core release.
EXPERIMENTAL in CI: Sandstorm needs unprivileged user namespaces (GitHub's Ubuntu
VMs allow them), and meteor-spk signs the .spk with the app private key from the
Sandstorm keyring -> provide it as the SANDSTORM_KEYRING secret. 'spk publish' to
the app market and the scp upload stay manual, as in release-sandstorm.sh.
Thanks to xet7.
Each wekan-<version>-<arch>.zip is now fully offline: its bundle/ directory
contains the WeKan server, a Node.js binary for that platform, a FerretDB v1
(SQLite) binary, and a start-wekan.sh (start-wekan.bat on Windows) that by
default runs WeKan on the bundled Node against the bundled FerretDB SQLite,
storing data and attachments/avatars on the filesystem under WRITABLE_PATH
(like docs/Platforms/Propietary/Windows/Offline.md). Override with WEKAN_DB /
MONGO_URL to use an external database.
- build-amd64/arm64/mac copy the runner's own Node 24 (matching arch); the
emulated ppc64le/s390x/riscv64 builds copy the target-arch Node they already
download; win64 bundles node.exe. Each overwrites the base bundle's node.
- start-wekan.sh supersedes start-wekan-ferretdb.sh (removed).
- Docker and snap have their own Node + entrypoint/wekan-control, so they strip
the redundant bundled node + launchers (keeping the ferretdb binary), saving
~80 MB per image/snap.
Thanks to xet7.
The main snapcraft.yaml uses base core26, which needs build-base devel + grade
devel until core26 is released, so it can only go to Snap Beta/Edge. This adds a
base core24 variant (a released base, grade stable) that builds the SAME newest
WeKan (Meteor 3.5, Node.js 24, FerretDB v1, MongoDB 7, Caddy 2), so the Stable
channel can be updated from the old 6.09 snap. Only base/build-base/grade differ
from snapcraft.yaml; migration wiring is added separately.
Thanks to xet7.
FerretDB is now built and released separately in the wekan/FerretDB repo (its
build.sh cross-compiles every platform into ferretdb.zip and attaches it to a
release there). The WeKan release workflow no longer sets up Go or compiles
FerretDB: build-ferretdb just downloads the newest ferretdb.zip from
https://github.com/wekan/FerretDB/releases/latest and republishes it as a
workflow artifact, and every WeKan build embeds its per-arch binary from that.
ferretdb.zip is no longer attached to the WeKan releases, since it already lives
on the wekan/FerretDB releases.
Thanks to xet7.
Under a sub-path deployment (ROOT_URL like https://host/wekan, typically behind a
reverse proxy that strips the prefix), language selection and other lazy-loaded
features failed with:
Error serving static file ENOENT ... /programs/server/build-chunks/build-chunks/<id>.js
rspack's client runtime builds each chunk URL as __webpack_public_path__ + chunkName,
and chunkName already includes the 'build-chunks/' prefix (u = e => 'build-chunks/'+e+...).
The default public path is '/', so at the site root chunks resolve correctly to
/build-chunks/<chunk>. But client/00-startup.js set the sub-path public path to
_rootPath + '/build-chunks/', so rspack appended a second 'build-chunks/', yielding
/wekan/build-chunks/build-chunks/<chunk> — which 404s. Set the public path to
_rootPath + '/' and let rspack add 'build-chunks/' itself.
Thanks to xet7.
Adds a build-ferretdb job that cross-compiles FerretDB v1 (wekan/FerretDB, pure-Go
SQLite backend, CGO off, no QEMU) for every platform Go + modernc.org/sqlite
support — not only the arches Node.js runs on — and packs them into ferretdb.zip:
ferretdb/<arch>/ferretdb-<arch> (Linux/macOS/BSD, executable)
ferretdb/<arch>/ferretdb-<arch>.exe (Windows only)
ferretdb/README.md (links to https://github.com/wekan/FerretDB)
Targets that do not compile on a given platform are skipped, so the zip contains
exactly what builds. ferretdb.zip is attached to the GitHub Release, and every
WeKan build (amd64, arm64, ppc64le, s390x, riscv64, win64, mac-arm64; and via the
bundle the docker image and snap) now extracts its FerretDB binary from that same
ferretdb.zip, so there is one source of truth. Windows/macOS bundles now include
FerretDB too (previously stripped), tolerant of an arch that FerretDB can't build.
Thanks to xet7.
getStatistics now reports, in Admin Panel / Version:
- Database type: MongoDB or FerretDB (detected from buildInfo.ferretdb).
- MongoDB-compatible version and the server git commit (buildInfo.gitVersion).
- FerretDB version and commit, when the server is FerretDB.
- Storage engine: WiredTiger for MongoDB, SQLite for FerretDB.
- Reactivity mode currently in use: changeStreams / oplog / polling (from
Meteor's oplog handle; FerretDB has no oplog so it shows polling).
Thanks to xet7.
Adds a 'database' snap setting (mongodb|ferretdb). A new ferretdb service runs
FerretDB v1 (SQLite) on the same port MongoDB would use; only one database runs
at a time. The configure hook flips the two services when the setting changes,
mongodb-control disables itself when ferretdb is selected, and wekan-control
drops MONGO_OPLOG_URL for FerretDB (which has no replica-set oplog, so WeKan
uses polling) and waits on a plain ping instead of rs.status().
The FerretDB binary ships inside the WeKan bundle the wekan part already
downloads, so no extra build is needed. New installs default to FerretDB on all
platforms via the install hook (fresh installs only; upgrades keep MongoDB, and
a pre-existing MongoDB data dir is detected and kept). Switch with
'snap set wekan database=mongodb'.
mongosh collects anonymized usage analytics by default and the snap invokes it
several times; disable it (writable HOME + disableTelemetry) so nothing phones
home. mongod itself has no phone-home telemetry (Free Monitoring is opt-in).
Thanks to xet7.
The extra-arch bundle build ran node:24-slim under QEMU, but the official
node:24 image publishes no linux/s390x manifest (only amd64/arm64/ppc64le),
so the s390x leg failed with 'no matching manifest for linux/s390x'. Build
the emulated native-module rebuild on an ubuntu:26.04 base (which publishes
every arch) and install the Node.js binary from nodejs.org instead; riscv64
uses unofficial-builds.nodejs.org (nodejs.org ships no riscv64).
MongoDB Community only ships amd64/arm64 server binaries, so on ppc64le,
s390x and riscv64 WeKan now bundles FerretDB v1 (the wekan/FerretDB fork with
its embedded pure-Go SQLite backend, MongoDB wire protocol). The FerretDB
binary is cross-compiled once for all five arches (CGO off, static, no QEMU)
and embedded in every .zip next to main.js, so amd64/arm64 users can opt in
too via WEKAN_DB=ferretdb. The Docker image now covers all five arches and
auto-starts FerretDB on the MongoDB-less ones. FerretDB telemetry is disabled
and locked (--telemetry=disable, plus DO_NOT_TRACK). armv7l is still excluded:
no Node.js 24 build exists for it anywhere.
Thanks to xet7.
The swimlanes CRUD e2e test (23-rest-api-more.e2e.js:209) regressed on all
browsers. Root cause: on the server ReactiveCache.getSwimlanes is async, so
board.swimlanes() returns a Promise in the REST handler. The #3624 change read
board.swimlanes().map(s => s.sort); .map on a Promise throws, the insert never
ran, the error was swallowed by the handler's catch (returned as 200 with no
_id), and the test read .title off a null swimlane.
The earlier "move the require to a top-level import" commit was not the real
cause (the same module.exports import pattern works server-side, e.g.
ruleDeletePermission in rulesButton.js). The actual fix is to await
board.swimlanes() before mapping. This also means API-created swimlanes now get
a real max(existing sort)+1 sort — previously the un-awaited `.length` yielded
undefined, so they were stored with no sort at all, which is the #3624 symptom.
Verified: the swimlane test passed in every kept local run through
2026-07-09_02-38-07 and failed starting 09-52-25 (right after the #3624 change),
confirming the regression window; the tests/swimlaneSort.test.cjs unit test
still passes.
Two e2e tests failed across Chromium/Firefox/WebKit after this release's changes:
- 23-rest-api-more.e2e.js:209 (swimlanes: create, get, edit, delete): the
#3624 change referenced models/lib/swimlaneSort via a function-scoped
require('/models/lib/swimlaneSort'); the new module was not reliably bundled/
resolved that way, so POST /api/boards/:id/swimlanes threw, was caught, and
returned an error object with no _id — the test then read title off a null
swimlane. Import nextSwimlaneSort at the module top level, matching every
other import in server/models/swimlanes.js, so it is statically bundled.
- 02-cards-open-view.e2e.js:93 (editing the card title does not break the card):
#4236 intentionally made plain Enter insert a newline in the card title
(consistent with the description), with Ctrl/Cmd+Enter saving. The CardPage
page object still saved with plain Enter, so the title never persisted and the
read timed out. Save with Control+Enter (sends ctrlKey on every platform,
which the save handler accepts).
Server-side Mocha, import regression, and Node E2E jobs already passed; this
makes the Playwright jobs green too.
In an RTL language (e.g. Arabic) the board root is dir="rtl". Both the minicard
title (a dir="auto" .viewer) and the add-card composer textarea are dir="auto"
but had no bidirectional isolation, so they shared the surrounding bidi context.
Typing a strong RTL character into one list's composer re-resolved that shared
context and visibly reflowed the displayed minicard titles of the other lists
(no underlying data change — it reverted on refresh).
Each minicard title and the composer textarea now use `unicode-bidi: isolate`,
making them self-contained bidirectional runs that cannot perturb one another.
The isolation is locked in (positive + negative) by tests/minicardBidiIsolation.test.cjs;
full browser RTL behavior is covered by tests/playwright/specs/18-rtl-layout.e2e.js.
Thanks to xet7.
List bodies used overflow-y:auto, so on platforms/browsers with overlay
scrollbars (macOS, iOS, Android, Firefox) the scrollbar auto-hid when idle and
there was no persistent affordance that a list is scrollable.
The list body now shows a scrollbar across all engines: overflow-y:scroll to
always reserve and show the track, ::-webkit-scrollbar width + thumb/track for
Chrome/Safari/Edge and mobile WebKit/Blink, scrollbar-width + scrollbar-color
for Firefox/Gecko (desktop and Android), and scrollbar-gutter:stable so content
does not shift. The cross-browser rules come from a Meteor-free builder
models/lib/scrollbarCss.js and are applied in client/components/lists/list.css;
both the builder contract and the applied CSS are unit tested (positive +
negative, including that no hide values sneak back in) in
tests/scrollbarCss.test.cjs.
Thanks to xet7.
When a card was copied — including generating a card from a template — each
subtask was inserted as a bare card document. The parent card's checklists were
copied, but the subtasks' checklists (and their items) were never copied, so
the new card's subtasks came out empty.
Card.copy() now copies each subtask's checklists (via Checklist.copy, which also
copies the checklist items) onto the newly inserted subtask. The subtask copy
document is built by a Meteor-free helper models/lib/subtaskCopy.js
(buildCopiedSubtaskFields — re-homes the subtask under the new parent and onto
the destination board; subtaskCopyChildKinds declares 'checklists' must be
reproduced), unit tested (positive + negative) in tests/subtaskCopy.test.cjs.
Thanks to ramses345 and xet7.
POST /api/boards/:boardId/swimlanes set the new swimlane's sort to
board.swimlanes().length (a count). When the existing swimlanes' sort values
were not a contiguous 0..n-1 run (after deletions/reordering, or swimlanes
created top-first with decreasing sorts), the count collided with or preceded
existing sorts and the new swimlane appeared FIRST instead of last.
The endpoint now computes the sort with nextSwimlaneSort(): max(existing sort)+1
for a reliable append (0 on an empty board), and honors an optional explicit
`sort` in the request body (the enhancement the reporter asked for). The pure
logic lives in models/lib/swimlaneSort.js, unit tested (positive + negative) in
tests/swimlaneSort.test.cjs.
Thanks to tamasberesoebb and xet7.
The activity feed rendered "edited comment undefined" / "deleted comment
undefined" for comment edits and deletions. The template passed the comment's
ID into the "%s" of activity-editComment / activity-deleteComment: on older
activities the id was absent (literally "undefined"), and a deleted comment's
CardComment document is already removed so it cannot be looked up live either.
The edit and delete activities now store the comment text (commentText), and a
new Activities.commentDisplayText() helper picks the stored text, else the live
comment text (edit case), else an empty string — so the feed never renders the
word "undefined". The template shows the (sanitized) comment text. The
text-selection logic is a Meteor-free helper models/lib/commentActivity.js,
unit tested (positive + negative) in tests/commentActivity.test.cjs.
Thanks to janchuelo and xet7.
Renaming a linked card (or linked-board card) wrote the new title only to the
linked target (via getRealId()/linkedId), leaving the linking card's OWN title
field stale. Filter-by-title queries each card document's own title field, so a
linked card silently dropped out of title filters after being renamed.
Card.setTitle() now also writes the linking card's own title (in addition to the
linked target), keeping it filterable while preserving the existing mirror
behavior. The set of documents to update is a Meteor-free helper
models/lib/linkedCardTitle.js (computeTitleUpdateTargets), unit tested
(positive + negative) in tests/linkedCardTitle.test.cjs.
Thanks to Ben0it-T and xet7.
Creating a board from a template copies the template board. The copy inherited
the template's subtasksDefaultBoardId / dateSettingsDefaultBoardId verbatim.
When those pointed at the template board itself, subtasks created on the NEW
board were dropped onto the TEMPLATE board, and the subtask card linked back
across boards to the parent.
Board.copy() now repoints any subtasks/date-settings default that referenced
the source board to the new copy, and clears the paired list id so it self-heals
to a list on the new board. Defaults that deliberately point at another (shared)
board are left untouched. The remap logic is a Meteor-free helper
models/lib/boardCopyDefaults.js, unit tested (positive + negative) in
tests/boardCopyDefaults.test.cjs.
Thanks to Xilef11 and xet7.
Security: with "Self Registration" and "Forgot Password" disabled in the Admin
Panel, the Register (.at-signup-link) and Forgot Password (.at-pwd-link) links
were hidden only by a one-shot jQuery .hide() in userFormsLayout.onRendered.
When a login failed (notably the LDAP "User not Found" path), useraccounts
re-rendered the form and recreated those link nodes without the inline
display:none, so the links reappeared and exposed registration / password-reset
flows that were meant to be off.
The disable state is now also reflected as a class on the stable <body>
ancestor (registration-disabled / forgotpwd-disabled) with matching CSS in
userForm.css. Because the class sits on <body>, the rule re-hides every copy of
the links that useraccounts recreates after a failed login, instead of relying
on a single inline style that the re-render dropped.
Thanks to Alsterdetektive1 and xet7.
#4055 reported the calendar/date-badge week number was one week too high
(Mon-Sat) and two too high (Sunday) for 2021-10-25..31 (ISO week 43). That was
the old moment-based calculation. The current native, DST-safe
dateUtils.getISOWeek() computes these correctly (verified across a DST timezone).
Adds tests/isoWeek.test.cjs pinning the exact reported dates so the off-by-one/
two cannot silently return.
Thanks to marcungeschikts and xet7.
The card title textarea submitted on plain Enter (only Shift+Enter made a new
line), while the card description textarea and the shared inlinedForm insert a
new line on plain Enter and only save on Ctrl/Cmd+Enter. So Enter could not add
a new line in the title, an inconsistent experience.
The title keydown handler now uses a shared, Meteor-free rule isSubmitKey()
(models/lib/editorSubmitKey.js): submit ONLY on Enter together with Ctrl or Cmd;
plain Enter and Shift+Enter fall through to the textarea default (a new line).
This matches the description field exactly. Unit tested (positive + negative) in
tests/editorSubmitKey.test.cjs.
Thanks to listenerri and xet7.
With LDAP_UTF8_NAMES_SLUGIFY enabled, sync.js slugified the username with
limax(text, { separator: '.' }) to transliterate UTF-8 names. limax replaces
every run of non-alphanumeric characters — including hyphens — with the
separator, so an LDAP username like "p.parta-partb" was turned into
"p.parta.partb". The generated Wekan username no longer matched and the user
could not log in.
slug() now slugifies each hyphen-separated segment on its own and rejoins
them with '-', so hyphens (and existing dots) survive while UTF-8 characters
in each segment are still transliterated. The hyphen-preserving logic is a
Meteor/limax-free helper packages/wekan-ldap/server/usernameSlug.js
(slugifyPreservingHyphens, with the transliterate function injected), unit
tested (positive + negative) in tests/ldapUsernameSlug.test.cjs.
Thanks to RowhamD and xet7.
In the board Calendar view the week-number column was numbered from Sunday
regardless of the user's "start day of week" setting: setting Monday moved
the grid (via firstDay) but the week numbers still referred to Sunday-start
weeks. FullCalendar's default weekNumberCalculation is locale-based, so it
ignored firstDay.
The calendar now passes weekNumberCalculation that computes the number with
weekNumberByFirstDay(date, firstDay) from the same firstDay used to lay out
the grid, so the number matches the displayed week. The Meteor-free week math
lives in models/lib/weekStart.js and is unit tested (positive + negative,
including a case where Monday-start and Sunday-start must differ) in
tests/weekStart.test.cjs.
Thanks to helioguardabaxo and xet7.
The "Due this week" date filter selected the wrong week (it showed next
week's cards) and ignored the user's configured start day of week.
Root cause: relativeWeek() derived its window from startOf(now(), 'week'),
but the native dateUtils.startOf() has no 'week' case, so it returned the
date unchanged. The window therefore started at "today + startDayOfWeek"
(next week) instead of the beginning of the current week, and never took the
configured start weekday into account.
Fix:
- New Meteor-free helper models/lib/weekStart.js computes startOfWeek(),
weekRange() and weekNumberByFirstDay() for an arbitrary start day of week
(0=Sunday..6=Saturday, default Monday). Unit tested (positive + negative)
in tests/weekStart.test.cjs, including a guard that "this week" must
contain today.
- client/lib/filter.js relativeWeek() now uses weekRange(now(), weekStartDay,
0|1) so "this week" and "next week" are the correct, non-overlapping
Monday..Sunday (or configured) windows.
- The this/next-week buttons now toggle per week: clicking the active one
clears it, clicking the other switches to it (the old code reset on either
state, so you could not switch directly from this week to next week).
Thanks to mimZD and xet7.
Switching directly from one board to another using the favorites bar reused
the same boardBody template instance (currentBoard went A -> B, never null),
so boardBody.onRendered never fired again and the one-shot
Utils.setBackgroundImage() call never re-ran. The previous board's inline
background:url(...) on .board-wrapper stayed on screen. Switching via the
"all boards" list worked only because it destroys and re-creates boardBody.
Two-part fix:
- boardBody.onRendered now calls Utils.setBackgroundImage() inside a reactive
this.autorun(), so it recomputes on every board switch (and background edit).
It performs no DB writes, so there is no autorun write loop.
- setBackgroundImage() now clears any stale inline background when the new
board has no image, instead of only ever setting one. The decision is a
Meteor-free helper models/lib/boardBackground.js (computeBoardBackground),
unit tested (positive + negative) in tests/boardBackground.test.cjs. Board
color keeps being applied reactively via the .board-wrapper colorClass.
Thanks to dasarne and xet7.
The rules move-card-to-top/bottom (on another board) and link-card-to-another-board
actions failed in two ways for boards the rule creator did not own:
- The action showed BLANK after creating the rule: the wizard inserted the
trigger/action/rule with three optimistic client Collection.insert() calls
that landed in minimongo limbo once the wizard subscription stopped (and the
client allow/deny rules rejected them outright for non-owner members). These
now create the rule through the server rules.createRule method, which keeps
the action's own destination boardId.
- Execution crashed with a generic Internal Server Error: the destination
swimlane fallback dereferenced ._id on a possibly-undefined swimlane titled
exactly Default, which does not exist when the destination board's default
swimlane was renamed, translated, or the board had no such swimlane.
Resolution now uses the board's real default swimlane and a Meteor-free
resolver (models/lib/ruleActionResolve.js) that returns empty string instead
of throwing. The same guard is applied to the createCard and linkCard actions.
Unit tested (positive + negative) in tests/ruleActionResolve.test.cjs.
Thanks to DarthKillian and xet7.
Translate the strings that were still in English (mostly keys added this
release: the Rules Workflow trigger/action palette, the relative-date
unit selector, the custom color-wheel label, the Activities hide toggle
and the changed-description activity), using the terminology already used
in the existing Finnish translations.
The Organization setting "Automatically add users with the domain name"
(org.orgAutoAddUsersWithDomainName) was never read at sign-up, so a user whose
email domain matched an organization was never added to it.
Add a pure helper models/lib/orgAutoAddByDomain.js (orgsToAutoAddForEmail) that
returns the ids of orgs whose configured domain exactly matches an email domain
(case-insensitive, exact — no subdomain, empty domain matches nothing), and wire
it into Accounts.onCreateUser so every non-admin sign-up path (password, LDAP,
invitation, new OIDC user) gains the matching org memberships using the same
{ orgId, orgDisplayName } shape used elsewhere, without duplicating existing
memberships. Add tests/orgAutoAddByDomain.test.cjs.
Replace the counterintuitive unlabeled material toggle switch on the card
details Activities panel with an eye / eye-slash icon toggle: open eye means
activities are shown, crossed eye means they are hidden. Clicking flips the
state, the icon reflects the current visibility, and the tooltip states the
action (Show activities / Hide activities). The same eye pattern from the
login/register password-visibility toggle is reused for consistency.
For consistency the board sidebar Activities toggle (same showActivities
state) is switched from the check / empty-square icon to the same eye /
eye-slash icon toggle.
The "Link to this card" popup Cards pull-down previously listed cards in
board sort order, making a card hard to find on boards with many cards.
Sort it alphabetically by title (case-insensitive, locale/numeric aware)
via a small pure helper sortCardsByTitle, with unit tests. Thanks to xet7.
The REST endpoint PUT /api/boards/:boardId/lists/:listId already accepted
title, color, starred and wipLimit, but the color was stored unvalidated. It
now validates the color with normalizeListColor (a named palette color OR a
custom #rrggbb hex) and rejects an unknown color with a clear 400 instead of
silently storing None or hanging. The pure field/validation logic is extracted
to a Meteor-free helper models/lib/listApiUpdate.js so it is unit testable
without a running server (tests/listApiUpdate.test.cjs).
api.py gains two commands mirroring editcard/editcardcolor:
editlist BOARDID LISTID NEWLISTTITLE and
editlistcolor BOARDID LISTID COLOR, with usage lines in the help block.
Thanks to C0rn3j and xet7.
Labels are embedded in the board document, not a separate collection, but the
Activities label() helper looked the label id up in the Cards collection and
always returned undefined. So the addedLabel/removedLabel webhook and
notification params never got a label value and the __label__ token in the
message text rendered as a bare, generic "label" with no name.
Resolve the label from the already-loaded board via getLabelById, and add a
pure labelDisplayName() helper (name -> color -> id fallback) so a nameless
label is shown by its color, matching the UI. Add a plain-Node unit test.
Card description changes did not create an activity, so no outgoing webhook
fired (unlike title/dueAt changes, whose Cards before.update hooks log an
activity). Log an a-changedDescription activity in the Cards before.update hook,
mirroring the title-change activity, so the existing Activities.after.insert
webhook hook fires. Fires on first-time set and later edits, not on no-op /
empty->empty saves. Pure descriptionChanged helper + tests, English i18n and
activity rendering.
The workflow trigger/action palette rendered hardcoded English labels
regardless of the UI language. Palette entries now carry an i18n labelKey
translated at render time via TAPi18n.__, reusing existing rule keys where
they fit and adding new r-w-* keys for workflow-only labels. Also routed
the slot clear tooltip through the existing r-remove key.
Deleting a rule ran three separate client-side Collection.remove() calls
(Rules + Triggers + Actions). Each is gated by a per-collection allow() rule
that resolves the board from that document own boardId via
Boards.findOneAsync(doc.boardId). When a trigger/action document has no
resolvable boardId (legacy rules created before boardId was denormalised, or
docs not published to the client), the board resolves to null and
allowIsBoardAdmin returns false, so Meteor rejects the mutation with 403
"Access denied" and the delete fails even for a legitimate board admin.
Route rule deletion through a new server Meteor method rules.deleteRule, which
authorizes ONCE (active board admin or site admin) and removes the rule, its
trigger and its action server-side (bypassing the brittle client allow/deny).
The permission decision is extracted into the Meteor-free helper
models/lib/ruleDeletePermission.js and covered by
tests/ruleDeletePermission.test.cjs. No permission is loosened: only board
admins and site admins may delete, matching the board-admin gate used to
create a rule.
Add a native color wheel (<input type="color">) alongside the existing fixed
named-color swatches in the color pickers for card labels, swimlanes, lists and
cards, so any #rrggbb color can be chosen. The schema color fields accept a
custom hex in addition to the named palette (via a validation that allows the
named values or a ^#[0-9a-fA-F]{6}$ hex), and normalizeListColor now passes hex
through, so existing named-color data keeps working. When a stored value is a
hex, elements apply an inline background-color instead of the card-details- /
swimlane- / list-header- / minicard- CSS class.
A new pure, Meteor-free helper models/lib/contrastColor.js computes a readable
text color from sRGB relative luminance (white on dark, black on light),
maps the named palette to hex, and validates/normalizes hex; it is applied as
an inline text color wherever a chosen color is a background behind text (label
chips, swimlane/list headers, minicard, card details header). Covered by
tests/contrastColor.test.cjs (20 assertions).
PUT /api/boards/:boardId/labels only wrote an HTTP response inside
`if (hasOwnProperty(req.body, "label"))` and read req.body.label.color/.name
unconditionally. Two consequences:
- A body without a `label` key fell through the `if` and NEVER sent a response,
so the request hung until the client timed out (the reported "timeout").
- A body where `label` was a bare string (e.g. { "label": "labelName" }, the
exact reported payload) produced name === undefined / color === undefined and
pushed a schema-invalid label via Boards.direct, returning 200 for garbage.
- The catch returned { code: 200, data: error }, masking real failures as
success (same anti-pattern already fixed for the board create/delete handlers).
The handler now returns a 404 for an unknown board, delegates input handling to
a new pure, Meteor-free helper models/lib/restLabel.js (buildBoardLabel), and
always sends a JSON response: 2xx for a well-formed create (documented contract
unchanged), 400 with a clear message for bad input, or the real error status in
the catch. buildBoardLabel accepts { label: { name, color } } (color defaulted
when absent, rejected when unknown) and, for convenience, a bare string taken as
the label name. Added tests/restLabel.test.cjs covering these cases.
Exporter.buildCsv resolved each card list/swimlane/owner/member/assignee/label/
customField by id and read a property directly on the result of Array.find(),
which is undefined when the referenced document was deleted. Large/old boards
often contain such dangling references, so the read threw "Cannot read property
title of undefined" and crashed the whole CSV export request. Extracted the
per-card row builder into a pure, null-safe helper models/lib/exporterCsvRow.js
(unit-testable without Meteor) that emits a blank cell for a missing reference
while keeping identical output for well-formed cards. Added
tests/exporterCsvRow.test.cjs.
The Calendar view queried cards with only a board+date selector via
Board.cardsInInterval()/cardsDueInBetween() and ignored the active board
Filter (member / assignee / due-date / label / custom-field), so it showed
every card in the interval regardless of the sidebar filter, unlike the
Board / Lists / Swimlanes views.
Extract the calendar selector building into a pure, Meteor-free helper
(models/lib/calendarFilter.js) that ANDs the Filter selector into the
date-interval selector via combineWithFilter, so the Filter is always applied
and can never collide with the interval selectors top-level $or. The calendar
events() callback now passes Filter._getMongoSelector() when Filter.isActive(),
and the onRendered autorun reads Filter.isActive() so events are refetched when
the filter changes. Add tests/calendarFilter.test.cjs.
Thanks to kerier and xet7.
Follow-up to #6439, which restored the drag preview but left the drop a
no-op: dragging a board on the All Boards page in "Custom (drag order)"
mode showed the dashed-border preview, but releasing it snapped the board
back without reordering.
The drop handler built the current on-screen order from
`el.classList[0]` for each `.js-board`. But the board list item is
`li.js-board(class="{{_id}} …")`, and Jade emits the literal `js-board`
class FIRST, so classList[0] is the string "js-board" for every board —
never the board _id. The ordered ids were therefore
['js-board','js-board',…]; computeReorderedSortIndex could not find the
dragged/target ids in that array, returned null, and nothing was written
to profile.boardSortIndex. The preview still worked because it is driven
by the dragover CSS class, independent of the id.
Read each board's _id from its Blaze data context (the same source
dragstart already uses via `this._id`) instead of the literal class, so
the real display order reaches the (already correct, unit-tested) reorder
helper and the drop persists. Add a regression guard in
tests/boardSortReorder.test.cjs asserting the wrong-class extraction
("js-board" repeated) yields no mapping while real ids reorder.
On old boards a swimlane can be deleted while its cards keep the now
dangling swimlaneId (an "orphaned" card). Cards with no swimlane at all
(null / '' / missing) already surface in every swimlane, but an orphaned
card matched no existing swimlane and so appeared in NO swimlane in
swimlane view, while still showing in list view (which applies no
swimlane scope) — the reported "no swimlane" symptom where some boards
show no cards in swimlane mode but work in list mode.
Mirror the existing orphaned-LIST fallback (Swimlanes.orphanedSwimlaneLists,
which surfaces orphaned lists in the first swimlane) for cards: when a
list's cards are fetched for the board's FIRST swimlane, build the
swimlane-membership clause as a single { swimlaneId: { $nin:
<otherSwimlaneIds> } } (everything not owned by another existing
swimlane: own id, null/'', missing, or orphaned). This keeps it a single
field clause with no second $or, so the #6441 board-wide label filter
still holds. Orphaned cards therefore appear once, in the first swimlane,
without a database migration.
Threaded through the pure selector helpers (swimlaneMembershipSelector /
listCardsSelector / filteredListCardsSelector) and the in-memory sibling
filterCardsByListAndSwimlane, a new List.orphanedCardsSwimlaneIds helper,
and the cards()/cardsUnfiltered()/allCards() model methods plus the
listBody cardsWithLimit render helper. Covered by new cases in
tests/swimlaneFilter.test.cjs (18 assertions pass; #6441 guards intact).
The .loadLanguage suite intermittently reported "1 failing" (the failure bouncing between the zh-CN and fr cases). Two causes: stubbing the shared TAPi18n.i18n let state leak between tests, and asserting via sinon-chai routed the matcher through chai-as-promised so it could evaluate after afterEach's sinon.restore(). Give each test a fresh i18next instance via TAPi18n.init() (mirrors the reliable .setLanguage suite) and assert with synchronous sinon.assert.* instead of sinon-chai.
Thanks to xet7.
Registering a new account showed a red "Internal server error" on the sign-up form even
though the account was created and could sign in — which typically happens when SMTP is not
configured. Root cause: useraccounts' `ATCreateUserServer` creates the account and then
calls `Accounts.sendVerificationEmail()` (because `sendVerificationEmail: true`); when SMTP
is missing/misconfigured that send throws **after** the user row is inserted, and with no
try/catch the exception leaves the createUser method as an opaque HTTP 500. The verification
email is best-effort at sign-up, so `Accounts.sendVerificationEmail` is now wrapped to log
and swallow a transport failure — registration completes and redirects to sign-in — while an
"already verified" error is re-thrown so the resend-verification flow still reports it. This
mirrors the [#5706](https://github.com/wekan/wekan/issues/5706) reset-password hardening.
Covered by `tests/verificationEmail.test.cjs`.
Thanks to Firas-Git and xet7.
Fixes#5672
A **linked card** (created by "Link card to this card") is only a placeholder on the board
that links it — its members are stored on the **real card** it points at (`linkedId`), which
lives on another board, and `Card.getMembers()` / `assignMember()` / `unassignMember()`
already read and write that real card. But the member **picker** listed the members of the
board you were *viewing* the linked card on, not the board the real card lives on. So on a
board that links a card from another board, the picker offered the wrong set of members and
toggling them did not behave as a consistent add/remove — "can not add members to the linked
card". The picker now resolves a linked card to its real card's board and offers *that*
board's active members (matching where the membership is actually stored), falling back to
the current board for normal cards or when the real card isn't loaded yet. Extracted the
target-card/target-board resolution into `models/lib/linkedCardMembers.js`, covered by
`tests/linkedCardMembers.test.cjs`.
Thanks to ITT5 and xet7 !
Fixes#5676
Searching a board for the value of a **number** or **currency** custom field (e.g. a
transaction number `2025001`, or a currency amount `123`) found nothing. Those field types
store their value as a JS **Number** (the inputs save `parseInt(...)` / `Number(...)`), but
`Board.searchCards()` only matched custom fields with `{ value: <regex> }` — and a MongoDB /
Minimongo regex only matches **string** values, so it silently skipped every numeric custom
field. The search now also adds an exact numeric-equality clause when the term is a plain
number (a comma is accepted as a decimal separator, matching the currency input), so numeric
custom fields match too; text/title/description matching is unchanged. Card search runs
against Minimongo on the client, which — like MongoDB — cannot regex a numeric field, so
equality is the correct cross-environment match. Covered by `tests/cardSearch.test.cjs`.
Thanks to MarcusDger and xet7.
Fixes#5680
The scheduled notification-tray cleanup logged `Exception in removed observeChanges
callback: Error: Removed nonexistent document …`. The crash itself came from the old
`cottz:publish-relations` package (since replaced by `reywood:publish-composite`), but the
cleanup that provoked it still fired one **un-awaited** `removeNotification()` per expired
notification — a separate `Users.update` `$pull` each time — so a user with K stale
notifications produced K writes to the Users collection, and every publication that
republishes user documents re-ran its observers K times in quick bursts (the churn that
surfaced the removed-document error), while any rejected write went unhandled. The cleanup
now scans only users that have notifications and prunes each user's stale notifications in a
single awaited `$pull … $in`. It also removes an activity's notifications only when *every*
entry for that activity is read and past its removal age (so a freshly re-created unread
notification sharing an activity id is not dropped), and guards missing/invalid `read`
timestamps. Covered by `tests/notificationCleanup.test.cjs`.
Thanks to xet7.
Fixes#5685
In the IFTTT-Rules "Move card to the board" and "Link card to the board" actions, the
board dropdown was empty for some users — the current board included — while colleagues
in the same company could pick boards normally. Root cause: the dropdown filtered the
(already access-scoped) client cache with `'members.userId': me`, i.e. it only kept boards
where the user has a *direct* member entry. A user who reaches a board through an
Organization, Team or email-domain share — but is not listed individually in
`board.members` — matched nothing, so the whole selector came up empty. The dropdown now
filters by the same visibility rule as `Boards.userBoards()` (public OR active member OR
active org OR active team OR active domain), so org/team/domain-shared boards appear too,
while archived boards, template containers, the user's templates board and internal helper
boards stay excluded.
Thanks to Augustin356 and xet7 !
Fixes#5698
A board admin adding another user could make the whole board silently vanish from a
user's board list, with no archive and "nothing out of order" in the logs. Root cause:
the `setBoardTeams` server method (used by the board Teams/Members management popups)
blindly overwrote the board's entire `members` array with a snapshot sent by the client.
When that client's board document was stale — e.g. a member had just been added via
`inviteUserToBoard` on the server and the change had not yet propagated to the client —
the overwrite dropped members, and in the worst case the board's own admin, so the board
no longer matched the board-list publication (which requires an active membership) and
disappeared. Because a wholesale `$set: { members }` never passes through
`foreachRemovedMember()`, no `removeBoardMember` activity was logged and no
card/watcher/star cleanup ran, which is why the logs looked normal. `setBoardTeams` now
reconciles against the authoritative server-side members instead of trusting the client
snapshot: it never drops an active admin, keeps every existing member the client still
lists, adds the members the client introduces, and only removes non-admin members the
client explicitly omitted (an intentional team-leave) — logging and cleaning up each such
removal so it is auditable rather than silent.
Thanks to DVNBLMHC and xet7 !
Fixes#5730
The Snap Store intermittently rejects `sudo snap install snapcraft --classic`
with "error: cannot install snapcraft: too many requests" (a 429-style rate
limit) that instantly failed the whole snap job before any build ran. Retry up
to 5 times with 30s backoff so one transient store throttle no longer fails the
release, matching the remote-build retry already in the same job.
The client board cache (imports/lib/dataCache.js) re-fetches its value inside a
reactive computation. When the board doc is briefly absent from minimongo (a
subscription stops and restarts, momentarily removing the doc), the empty value
was surfaced immediately, flashing the "Board not found" shell — and on WebKit
throwing a Blaze "Can't select in removed DomRange" error tearing down the card
view. It self-recovers when the doc is re-delivered, so it presents as a flicker,
reliably hit only on Firefox/WebKit timing (which is why it surfaced as
browser-specific Playwright failures in 14-voting-watchers and 24-feature-issues).
Fixed with an opt-in stale-while-revalidate mode on DataCache, enabled only for
getBoard: a transient miss over an already-cached board keeps the last value and
re-checks after a short delay, surfacing an empty result only if the board is
still gone then (a genuine deletion / access loss). First-ever loads and caches
that did not opt in are unchanged. Core decision extracted to the pure
shouldDeferCacheMiss helper with unit tests.
Also hardens the related Playwright specs: a retry in CardPage.openActionsMenu, a
longer visibility budget for the WebKit slow card-render in spec 16, and removes a
reload race in the move-list-right spec (moveListBy fires two un-awaited
updateListSort calls, so an immediate reload read a half-applied order).
The "Link to this card" target picker only excluded template cards, so an
existing linked card (or a card that links back to the current board) could be
chosen as a link target. That builds a chain of linkedId pointers, but the card
helpers (getTitle/getBoardTitle/getRealId) resolve linkedId only one hop, so
such a card renders as an empty/broken pointer and becomes inaccessible (the
reported freeze). As the reporter suggested, prevent the configuration: only a
real card — not a linked card/linked board, not one of the linking board's own
cards, and not a card that links back to one of them — may be a link target.
Enforced in the picker query and re-checked at creation time via the pure
isLinkableCardTarget guard, mirroring the #3328 parent/subtask cycle guard.
Two related problems with the default login authentication method.
- **Env var ignored:** the stored setting was only ever seeded as `password` and
`DEFAULT_AUTHENTICATION_METHOD` was never applied, so operators setting it (e.g. Kubernetes/Helm
`DEFAULT_AUTHENTICATION_METHOD: ldap`) saw no effect. Startup now applies the env var
authoritatively: it seeds the value on a fresh install and, on existing installs, keeps the
stored `defaultAuthenticationMethod` in sync with the env var on every boot (the operator's env
is the source of truth), so the method can be configured entirely by env without the Admin Panel.
The value is normalized (trimmed + lower-cased), so `DEFAULT_AUTHENTICATION_METHOD=LDAP` works.
- **Layout save hanging / not persisting:** the authentication-method `<select>` is populated by an
async `Meteor.call`, so clicking **Admin Panel > Layout > Save** before it loaded sent an empty
value for the **required** `defaultAuthenticationMethod` field, which silently failed validation —
the save looked stuck and nothing changed. The save now falls back to the currently stored method
when the select is empty, so a real value is never overwritten by `''`.
Both paths share one pure helper (`resolveDefaultAuthenticationMethod`) that never resolves to an
empty string.
Thanks to joe-speedboat and xet7 !
Fixes#5879
`Card.move()` wrote `boardId`/`swimlaneId`/`listId` into the update unconditionally, even for a
same-board drag to another list (the reported case). Keeping `boardId` in the `$set` on every drag
re-ran the `boardId`-gated `Cards.after.update` hook that re-syncs the card's checklists and
checklist items via `multi` updates, ran the cross-board consistency guard and the
`denyCrossBoardMove` deny-rule DB lookup, and invalidated more reactive dependents than
necessary — server work and reactivity churn that contributed to a ~1s card flicker on large
boards. `Card.move()` now writes **only the fields that actually change** (via the pure
`computeCardMoveModifier` helper) and skips the write entirely when a card is dropped back in the
same place, so a same-board move no longer touches boardId or the cross-board hooks. The
`moveCard`/`moveCardBoard` activity generators already re-check `doc.X !== oldX`, so trimming the
modifier does not drop any activity. Note: this reduces the drag-time work behind the flicker; the
residual reactive re-render cost on very large boards is a separate performance topic.
Thanks to mimZD and xet7 !
Fixes#6430
`Board.getDefaultSwimline()`/`getDefaultSwimlineAsync()` self-heal a missing default swimlane by
reading the board's swimlanes and inserting one if none exist. That check-then-insert is a race:
concurrent or repeated server calls for a swimlane-less board each saw zero swimlanes and each
inserted a new one, so some boards accumulated 30 000+ empty "Default" swimlanes and became
unloadable (the `key 'default (en)' returned an object instead of string` log is a harmless i18n
side effect — the title correctly falls back to the string `Default`). Follow-up to the
client-side [#6382](https://github.com/wekan/wekan/issues/6382) fix, which only stopped the
browser from auto-creating them. Fixed by making the server self-heal **idempotent**: the default
swimlane is now upserted with a deterministic `_id` (`<boardId>-default`), so the `_id` unique
index guarantees at most one default swimlane per board no matter how many times, or how
concurrently, the getters run. `archived`/`type` are set explicitly in the `$setOnInsert` because
their schema autoValue/defaultValue only fire on insert, not upsert. Note: this prevents new
duplicates; boards that already accumulated thousands still need a one-off cleanup.
Thanks to brlin-tw and xet7 !
Fixes#6429
In `.github/workflows/release-all.yml` the `charts` job now depends on the `docker` job
(`needs: docker`) instead of running in parallel right after `bump`. GitHub Actions runs a job
only when all of its `needs` jobs succeed, so the wekan/charts Helm chart is published only after
the multi-arch image is live on Docker Hub / Quay.io / GHCR (and is skipped if `docker` fails).
This prevents ArtifactHub from scanning a freshly published chart whose image tag does not exist
yet and emailing the maintainer about the missing Docker image.
Thanks to xet7 !
`/b/undefined/board/<cardId>` instead of the real board.** On the server
`ReactiveCache.getBoard()` is async and returns a Promise, but `Cards.board()` did not await it,
so the synchronous `Card.originRelativeUrl()`/`absoluteUrl()` interpolated a Promise —
`board._id` and `board.slug` were `undefined`, producing `/b/undefined/board/<cardId>` in
card activity notification emails (the client UI was unaffected because `this.board()` is
synchronous there). Fixed by making `Card.originRelativeUrl(board)`/`absoluteUrl(board)` accept an
already-resolved board and fall back to `this.boardId` (always available synchronously) when the
board is a Promise, and by passing the awaited board from `server/models/activities.js` so the
correct board id and slug are used.
Thanks to titver968 and xet7 !
Fixes#6427
— shell injection (RCE) via a malicious upload filename in the external antivirus scanner command path**
([GHSA-x3xm-pxrv-jg7p](https://github.com/wekan/wekan/security/advisories/GHSA-x3xm-pxrv-jg7p),
CWE-78 OS Command Injection). Same RCE class as
[AvatarBleed](https://wekan.fi/hall-of-fame/avatarbleed/) (CVE-2026-52891, GHSA-35j7-h385-2q9g)
and its follow-up regression (CVE-2026-53447 / GHSA-qfqv-42qw-vvwh area), but in a code path that
was **never** covered by those fixes.
In `models/fileValidation.js`, when an admin has configured an external scanner (antivirus)
command line with a `{file}` placeholder, the uploaded file path was interpolated into the command
and run through `asyncExec` (`promisify(exec)`), which spawns `/bin/sh -c` and interprets **all**
shell metacharacters:
```js
await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"'));
```
Wrapping the path in double quotes is not a shell boundary — inside double quotes the shell still
expands `$(...)`, backticks and `\`, so a filename such as `` a`id`.png `` or `$(touch /tmp/pwn).png`
escaped the argument and executed as the Wekan server process. Any **authenticated** user who can
upload an attachment could trigger it, on servers that have an external scanner configured. Unlike
the sibling MIME-detection path (`detectMimeFromFile`, which already uses `execFile` with no shell)
and unlike the AvatarBleed fix (which strips non-alphanumeric characters from the filename), this
scanner path had **zero** sanitization.
- **Fixed** by POSIX single-quote-escaping the interpolated file path via a new `shellQuote()`
helper (wrap in single quotes, escape embedded `'` as `'\''`). Inside single quotes the shell
interprets no metacharacters, so a malicious filename can no longer break out of the argument or
inject commands, while the admin's arbitrary command line and the exact on-disk path are both
preserved. CVSS:3.1 9.9 Critical (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H).
- Affected Wekan v9.06 and earlier through the current release; fixed at the upcoming WeKan release.
Reported by **DavidCarliez**.
Thanks to DavidCarliez and xet7 !
2026-07-05 21:55:15 +03:00
860 changed files with 167870 additions and 87267 deletions
echo "::error::SANDSTORM_KEYRING secret is not set. 'spk publish' proves ownership of the WeKan Sandstorm app with the private key from this keyring, so publishing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
- name:Download the newest .spk from the WeKan release and name it wekan-<VERSION>.spk
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
- name:Install Meteor npm dependencies
# PUPPETEER_SKIP_DOWNLOAD: puppeteer is a devDependency used only by tests,
# not by the server, so it never belongs in the packed .spk. Skipping its
# Chromium download (~150 MB) avoids any risk of it bloating the build and
echo "::warning::$SPK is $((SPK_BYTES/1024/1024)) MiB — over Cloudflare's 100 MB upload limit. Upload via a DNS-only host / SSH tunnel to Sandstorm, or trim the bundle further."
fi
ls -la "$SPK"
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
- uses:actions/upload-artifact@v7
with:
name:${{ steps.build.outputs.spk }}
path:${{ steps.build.outputs.spk }}
retention-days:7
- name:Upload the .spk to the newest WeKan release
@@ -67,7 +67,7 @@ that by providing one-click installation on various platforms.
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to about 105 languages.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 154 languages.
- [Features][features]: WeKan ® has real-time user interface.
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
@@ -103,10 +103,10 @@ By working directly with WeKan ® you get the benefit of active maintenance and
## Getting Started with Development
The default branch uses Meteor 3.5-rc.2 with Node.js 24.x.
The main branch uses Meteor 3.5 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-install-dependencies-build-wekan-and-run-the-dev-server). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
@@ -138,11 +138,17 @@ curl https://install.meteor.com/ | sh
### Building WeKan
The `rebuild-wekan.sh` script guides you through a three-stage process:
The `rebuild-wekan.sh` script shows a two-level menu. The top level groups options into categories:
1.**Install dependencies (Option 1)** - Downloads all required Meteor packages and npm modules
2.**Build WeKan (Option 2)** - Compiles the application
3.**Run WeKan (Option 3)** - Starts the development server at http://localhost:4000
```
1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit
```
You pick a category number, then the item number inside it (each submenu also has a `Back` entry). Building from source is a three-stage process:
1.**Setup -> Install dependencies** - Downloads all required Meteor packages and npm modules
2.**Setup -> Build WeKan** - Compiles the application
3.**Dev server -> localhost:3000** - Starts the development server at http://localhost:3000
# Press 1 (Setup) and Enter, then 2 (Build WeKan) and Enter
# Step 3: Run WeKan in development mode
# Step 3: Run WeKan in development mode (Dev server -> localhost:3000)
./rebuild-wekan.sh
# Press 3 and Enter
# Press 2 (Dev server) and Enter, then 1 (localhost:3000) and Enter
```
If a dev server is already running on that port, the **Dev server** options stop it automatically and start a fresh server on the same port.
### WSL Users
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
The development server will start at http://localhost:4000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
The development server will start at http://localhost:3000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
@@ -10,8 +10,19 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
- **Report**: Click "New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
More info at [GitHub Docs about reporting privately](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately).
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
-**If GitHub does not work**: Please send report to **security@wekan.fi**
- **Optional Email Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
-DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
- Email is NOT secure or private.
- Using GitHub directly verifies that:
- You are:
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
- A correct GitHub user
- Really reading this SECURITY.md
- Knowing what you are doing
- You are not:
- Someone that tries to waste my time
- Trying to impersonate as some other GitHub user
- Spammer
- Bot
### 2. WeKan Security Team:
@@ -23,30 +34,26 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
### 3. Post-Release and Public Disclosure:
- Publicity: If you publish blog posts, give talks, or write articles about the
reported vulnerabilities, please send the links to us. We will link them
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
## How Should Reports Be Formatted?
```
Name: %name
Twitter: %twitter
Bug type: %bugtype
Domain: %domain
Severity: %severity
URL: %url
PoC: %poc
CVSS (optional): %cvss
CWSS (optional): %cwss
```
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
- Email is NOT secure or private.
- Using GitHub directly verifies that:
- You are:
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
- A correct GitHub user
- Really reading this SECURITY.md
- Knowing what you are doing
- You are not:
- Someone that tries to waste my time
- Trying to impersonate as some other GitHub user
- Spammer
- Bot
## Who can participate in the program
Anyone who reports a unique security issue in scope and does not disclose it to
a third party before we have patched and updated may be upon their approval
added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
a third party before we have patched, and is able to add
"New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
# Example (Issue #2674) - add member when card moved TO list, remove member when moved AWAY FROM list:
# python3 api.py addrule BOARDID 'Add on move to Doing''{"activityType":"moveCard","listName":"Doing"}''{"actionType":"addMember","username":"someuser"}'
# python3 api.py addrule BOARDID 'Remove on move from Doing''{"activityType":"moveCard","oldListName":"Doing"}''{"actionType":"removeMember","username":"someuser"}'
Loaded 100 of 860 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.