Compare commits

..
92 Commits
Author SHA1 Message Date
github-actions[bot] 97cc91cd67 Bump versions for v13.00 2026-11-06 23:05:04 +00:00
Lauri Ojansivu 33c2ad8fb0 Prepare v13.00 release 2026-11-07 01:03:53 +02:00
Lauri Ojansivu c936e7708d CHANGELOG: CreateBleed, only admins create boards on every path.
Thanks to tsurutanmen and xet7 !
2026-11-07 00:53:47 +02:00
Lauri Ojansivu 5c1f6088f4 Fix CreateBleed: "Only admins can create boards" holds on every path.
GHSA-qcfm-2g4w-wqcc. Admin Panel / Settings / Visibility / "Only admins
can create boards" (#4475) was checked only inside
createBoardWithInitialSwimlanes, the Add Board form. A non-admin still
created working boards by a client Boards.insert (Boards.allow.insert
approves any signed-in user), POST /api/boards and both board import
routes, as reported, and also by importBoard, cloneBoard, copyBoard,
POST /api/boards/:boardId/copy and createBoardFromCard.

One decision (models/lib/boardCreationPolicy.js) and one guard
(server/lib/boardCreationGuard.js) are asked by every one of those paths
before they write, and by a new Boards.deny insert rule. A board template
stays every member's own; boards WeKan makes itself (a Templates container,
a board's subtasks board) are not created by the user. A REST refusal is
403. A refusal is recorded in Admin Panel / Problems as CreateBleed
(authz.board-creation, low, CWE-863, blocks no account), and Duplicate and
"Create board from card" are hidden like Add Board, so only a client
bypassing the interface reaches it.

tests/createBleed.test.cjs drives the decision, the guard and the real deny
rule against the reporter's console insert, and fails if any path that
creates a board for a user does not ask the guard, or if anything else
reads the setting. tests/playwright/specs/createbleed-board-creation-admin-only.e2e.js
replays the report against a running server. The guards that pinned the
old inline check now pin the shared one.

Thanks to tsurutanmen and xet7 !
2026-11-07 00:51:53 +02:00
Lauri Ojansivu ceb20a81fc Tests: keyboard-nonlatin-once skips outside Chromium, and every CDP test must.
keyboard-nonlatin-once sends its key events through the DevTools protocol
and had no skip, so in the full Firefox run it failed with "CDP session is
only available in Chromium" - the same error as card-window-touch-drag's
mouse test (2128e6e02f). It now skips outside Chromium like the other CDP
tests. tests/playwrightCdpChromiumOnly.test.cjs requires that skip on every
test that opens a CDP session, itself or through a helper of its file (a
`touch: false` call opens none, a describe-level skip covers its file), and
finds both tests as they were.

Thanks to xet7 !
2026-11-07 00:14:50 +02:00
Lauri Ojansivu 2128e6e02f Tests: the mouse test of card-window-touch-drag runs in every browser.
openCardWindow always opened a DevTools (CDP) session to emulate touch, and
the mouse test, which needs no touch, called it too: in Firefox it failed
with "CDP session is only available in Chromium" before it had moved
anything. The touch is now optional, and the mouse test asks for none; the
touch tests still skip outside Chromium. It passes in Firefox and in
Chromium.

Thanks to xet7 !
2026-11-06 23:53:09 +02:00
Lauri Ojansivu 49ee37a3a8 Tests: Card's settings menu is opened again until its panel shows.
In the full two-worker Firefox run, board-settings-columns' non-admin test
clicked Card's hamburger right after Board Settings / Card was drawn and
the panel never opened; alone it passes 4 of 4. The button toggles the
panel, so the helper now clicks again only while the panel is still closed,
up to 15 seconds. The whole spec passes in Firefox.

Thanks to xet7 !
2026-11-06 23:27:52 +02:00
Lauri Ojansivu 19350f2611 A code block in a description reads in the dark theme.
.viewer pre and code have their own light background (#dedede) in
layouts.css but inherited the theme's text colour. In the dark theme's
opened card that is #bbb, 1.42:1, and axe-core's color-contrast rule
failed the dark theme in the full Firefox run (46-axe-theme-contrast,
".viewer > pre #bbbbbb/#dedede 1.42"). The block now names its text colour,
#222, 11.8:1 on its background - the background codeHighlight.css's token
colours are chosen for. tests/viewerCodeContrast.test.cjs requires an
explicit text colour of at least 4.5:1 on the block's own background, and
fails on the old rule.

Thanks to xet7 !
2026-11-06 23:22:54 +02:00
Lauri Ojansivu 2306a36814 A long popup menu is laid out before it is drawn, not a frame later.
The column layout of a long menu (Board View, the member menu, the card's
menu) was applied on the animation frame after the menu's contents arrived,
so for a frame the popup was drawn - and could be clicked or dragged - at
its narrow size, then jumped to 960px under the pointer. In the full Firefox
run a click on Board View's "Lists" landed where "Calendar" ends up, the
view stayed Swimlanes and 06-views-layout's #6659/#6660 test failed; the
same jump was behind the popup drag of card-title-edit-and-move-6839. The
MutationObserver callback, which already runs before the next paint, now
applies the layout itself. tests/popupMenuLayout.test.cjs drives the
observer and fails if the layout is deferred to an animation frame again.

Thanks to xet7 !
2026-11-06 23:11:29 +02:00
Lauri Ojansivu a4d2313a68 Password policy specs no longer run side by side or leave a minimum enforced.
In the full Chromium run (two workers) password-length-report.e2e.js and
password-min-length-signup-login.e2e.js ran at the same time. Both write
settings.loginOptions.passwordMinLength and passwordMinLengthEnforce, one
document for every worker, so each turned enforcement on or off under the
other and one test of each failed. Both also restored the loginOptions they
had seen before each test, so once a test was interrupted the snapshot
already held an enforced minimum, and every later test put it back: the
test database was left with a minimum of 10 enforced.

tests/playwright/helpers/sharedSettingLock.js is a lock across workers (an
atomic mkdir under TMPDIR, counted per process, and taken over from a
worker that died); both specs hold 'password-policy' for the whole file,
and put the policy back to its default with $unset after each test. Run
together on two workers both files pass, 6 of 6.
tests/playwrightSharedSettingLock.test.cjs requires the lock and the
$unset in every spec that writes the policy, refuses a restored snapshot,
and drives the lock: shared within a process, released by the last holder,
taken over from a dead one, and waited for from a live one.

Thanks to xet7 !
2026-11-06 22:12:49 +02:00
Lauri Ojansivu 570cc34552 Stopping a timer adds its time to the total, never replaces it.
Flowtime's Stop and Pomodoro's completed or stopped work interval read the
total on the client (getSpentTime()) and $set the sum. For a card that
links to a board or a card, that total is the linked document's, which can
still be on its way to the client: getSpentTime() then read null, and the
Stop replaced a linked board's 4 logged hours with the 1 hour of the
session (linked-timer-sessions.e2e.js failed 4 of 6 repeats). The session
length is now added on the server with addSpentTime(), an $inc on the same
spentTime field of the linked board, the linked card or the card. The
Flowtime and Pomodoro guards check addSpentTime and no longer pin
setSpentTime, and tests/linkedTimerSessions.test.cjs credits correctly
with no total on the client, pins the $inc targets, refuses a zero,
negative or non-number amount, and fails if a timer helper computes a total
from getSpentTime() again.

Thanks to xet7 !
2026-11-06 21:35:36 +02:00
Lauri Ojansivu 7ae45651dc A popup that grows while it is dragged stays inside the window.
A long menu, such as the member menu, gets its 960px column layout a frame
after it is drawn (client/lib/popupMenuLayout.js). A drag begun in that
frame clamped every move with the 380px the popup started at, so the grown
popup was put at left 850, mostly outside a 1280px window - and each move
undid the ResizeObserver that had just put it back inside. The drag now
clamps with the popup's current size. tests/calendarPopupResize.test.cjs
grows the popup mid-drag and fails on the old clamp; the Playwright spec
card-title-edit-and-move-6839 measures a popup only after its box has
settled, and passes 24 of 24 repeats where it failed 3 of 20.

Thanks to xet7 !
2026-11-06 21:00:30 +02:00
Lauri Ojansivu db52764592 Docker Playwright browsers open WeKan at ROOT_URL, so uploads work in tests.
build.sh starts the test server with ROOT_URL=http://localhost:3000 but sent
the Docker Playwright browsers and the Node E2E container to
http://127.0.0.1:3000. Meteor-Files sends every upload to ROOT_URL +
/cdn/storage/<collection>/__upload and answers no CORS preflight for another
origin, so the browser blocked it ("Access to fetch at
'http://localhost:3000/cdn/storage/attachments/__upload' from origin
'http://127.0.0.1:3000' has been blocked by CORS policy") and the four drawing
specs whose Save uploads an attachment timed out in the full Chromium run.
The default is now http://localhost:3000, like playwright.config.js and the
macOS path; with it the eight drawing tests pass in the same container.
tests/playwrightBaseUrlMatchesRootUrl.test.cjs checks that every browser
default in build.sh and the Playwright config is the test server's ROOT_URL
origin, and fails on the old default.

Thanks to xet7 !
2026-11-06 20:56:02 +02:00
Lauri Ojansivu 2b99ffe288 Source-scanning tests skip every _build* directory, not only _build.
After the Mocha stage of a full test run had compiled _build-local-test/,
tests/publishFilesCursor.test.cjs read server-rspack.js there and failed
on nine lines of bundled code. 62 tests walked the tree skipping _build by
exact name, so each of them read rspack's second copy of every source
file whenever a test build was present. They now skip by the _build
prefix, as tests/noIdentityReplacement.test.cjs already did, and
tests/sourceWalkersSkipBuildOutput.test.cjs fails on any walker that
skips _build by exact name only.

Thanks to xet7 !
2026-11-06 20:20:10 +02:00
Lauri Ojansivu b4b130858f CHANGELOG: built API page spells the name WeKan.
Thanks to xet7 !
2026-11-06 19:43:28 +02:00
Lauri Ojansivu 0275363043 The built API page public/api/wekan.html spells the name WeKan.
The v12.99 version bump rebuilt public/api/wekan.html with the generator
from before the spelling fix, and releases/release-website.sh copies it
to wekan.fi as api/v12.99/index.html, so that page would have said
Wekan again. Its visible text and the Redoc JSON now say WeKan, and
tests/openapiBrandSpelling.test.cjs checks the built page too.

Thanks to xet7 !
2026-11-06 19:43:20 +02:00
Lauri Ojansivu 42d8341d47 Merge remote-tracking branch 'origin/main' 2026-11-06 19:42:55 +02:00
Lauri Ojansivu a9aaa22592 CHANGELOG: REST API documentation spells the name WeKan.
Thanks to xet7 !
2026-11-06 19:25:20 +02:00
Lauri Ojansivu 4d5fe63fc4 The name is WeKan in the REST API documentation.
openapi/generate_openapi.py's header and the models' JSDoc said
"Wekan REST API", "extend Wekan", "a production Wekan server" and
"in the Wekan board", so every release's API pages on wekan.fi failed
its brand-spelling check (v12.95-v12.97 did). The generator, the model
comments and public/api/wekan.yml now say WeKan, and
tests/openapiBrandSpelling.test.cjs checks the generated file, the
generator header and every model JSDoc block.

Thanks to xet7 !
2026-11-06 19:24:04 +02:00
github-actions[bot] 1f12b20291 Bump versions for v12.99 2026-11-06 17:05:44 +00:00
Lauri Ojansivu bf8ecbbc19 Prepare v12.99 release 2026-11-06 19:04:35 +02:00
Lauri Ojansivu e89a3450cd CHANGELOG: mac-arm64 on macos-15, weba instructions moved.
Thanks to xet7 !
2026-11-06 18:57:40 +02:00
Lauri Ojansivu 7e4483b1a9 Build mac-arm64 release bundle on macos-15, macos-14 runner is retired.
The v12.98 release run's build-mac-arm64 job was cancelled with
"The configuration 'macos-14-arm64-us-default' is not supported",
so no mac-arm64 bundle was built. macos-15 is Apple Silicon too and
is what mac.yml already uses. tests/workflowRunnerLabels.test.cjs now
lists macos-14 as retired and checks that build-mac-arm64 asks for a
current Apple Silicon runner.

Thanks to xet7 !
2026-11-06 18:52:56 +02:00
Lauri Ojansivu a4eacfd9bf Merge remote-tracking branch 'origin/main' 2026-11-06 17:02:46 +02:00
Lauri Ojansivu b4cb989391 Move weba instructions into its own repository
Remove the weba-specific section from the instruction twins now that the companion repository maintains its own scoped AGENTS.md and CLAUDE.md.
2026-11-06 17:01:28 +02:00
github-actions[bot] 60377a4dc2 Bump versions for v12.98 2026-11-06 14:54:56 +00:00
Lauri Ojansivu 5c45d55d70 Prepare v12.98 release 2026-11-06 16:53:41 +02:00
Lauri Ojansivu e5a84cbcef CHANGELOG: weba in AGENTS.md and CLAUDE.md.
Thanks to xet7 !
2026-11-06 16:51:08 +02:00
Lauri Ojansivu 1ef7a57ce4 Added weba info to AGENTS.md and CLAUDE.md.
Thanks to xet7 !
2026-11-06 16:49:47 +02:00
Lauri Ojansivu 9caf0c8653 CHANGELOG: notificationAttachments publication.
Thanks to xet7 !
2026-11-06 16:42:30 +02:00
Lauri Ojansivu adb73fd8ee Notifications drawer: notificationAttachments publishes a cursor again.
Every subscription failed on the server with "Exception from sub
notificationAttachments ... Publish function can only return a Cursor or an
array of Cursors". Attachments is an ostrio:files FilesCollection, so
ReactiveCache.getAttachments(..., true) answers with a FilesCursor; the
publication returned that instead of the Mongo cursor it wraps, and the
drawer never received the attachments its activities name. It now returns
`ret.cursor || ret`, as every other attachment publication does.

tests/publishFilesCursor.test.cjs runs the publication with a FilesCursor
and requires the Mongo cursor back, and scans every publishing file so no
getAttachments/getAvatars cursor query is published without its .cursor.

Thanks to xet7 !
2026-11-06 16:42:21 +02:00
Lauri Ojansivu ea5724babd CHANGELOG: REST card update sort, isOverTime and description.
Also points the Upcoming entries at their commits after the pull rebased them.

Thanks to AhmedLukman and xet7 !
2026-11-06 16:24:40 +02:00
Lauri Ojansivu b895e61237 REST card update: sort is a number, isOverTime can be turned off, a description can be cleared.
#6877 made the card PUT write `sort` whenever it is present, so 0 works -
and so would null, '' or 'abc', written as the card's position. The same
handler read `isOverTime` and `description` by truthiness, so false could
not be set and an empty description could not clear the old one.

parseCardScalarFields() in server/lib/restCardHelpers.js now decides all
three by presence and checks them before the first write: sort must be a
finite number (a form-encoded '0' becomes 0), isOverTime true/false or
'true'/'false', description a string (null clears it). Anything else is a
400 and the card is left as it was, rather than half of the edit applied.

tests/restCardSort.test.cjs drives the handler for the accepted values,
refuses the invalid ones with no writes, and fails if any of the three is
read by truthiness again.

Thanks to AhmedLukman and xet7 !
2026-11-06 16:24:31 +02:00
Lauri Ojansivu 3108e017fc CHANGELOG: public CSV export fields, REST card 404s, test guards.
Also points the GroupLinkBleed entry at its commit after the pull rebased it.

Thanks to AhmedLukman and xet7 !
2026-11-06 16:15:34 +02:00
Lauri Ojansivu 081690875d Tests: the real single executable test says why it cannot run on a kernel with large pages.
On Linux, --build-sea injects the SEA blob with LIEF, which adds PT_LOAD
segments aligned to 4 KiB. A kernel with 16 KiB pages (Apple-silicon Linux)
or 64 KiB pages (some ARM servers) cannot map them, and the executable dies
with SIGSEGV before it runs a line. A one-line console.log SEA built with
Node.js v26.13.0 crashes the same way, so it is the injection, not WeKan.
The test failed there as `null !== 0`, which said nothing; it is now skipped
when the page size is over 4 KiB, with that reason. Release builders have
4 KiB pages, so they still run it.

Thanks to xet7 !
2026-11-06 16:15:34 +02:00
Lauri Ojansivu 68b575637d Tests: three client guards follow the code they pin again.
The behaviour did not change; each guard read source that later commits
moved, and failed on what it could no longer find:

- sidebarBelowHeader: Template.header.onRendered also wires the full
  screen button since f25957dcf3, and the slice now ran into the new
  onDestroyed. The slice ends at onDestroyed, and the fake environment has
  an inert Fullscreen API.
- maximizedCardPosition: since efaf94242e the maximized selector is also in
  a narrow-screen @media rule that only moves `top` below the header bar on
  a phone. The regex found that rule first; it now reads the top-level one.
- titleClickToEdit: since 1b05673200 the opened card moves with Pointer
  Events, and what a header press does is cardHeaderPress() in
  models/lib/windowDrag.js. The guards read the pointerdown handler and call
  that decision, keeping every assertion: the title edits, links stay links,
  the drag handle wins when drag handles are on, the editor is never dragged.

Each still fails when the behaviour it pins is removed (checked by deleting
onTitleEditZone and by making the maximized card absolute).

Thanks to xet7 !
2026-11-06 16:15:34 +02:00
Lauri Ojansivu 6d5f660c27 REST custom-field update answers JSON 404 for an unmatched card instead of 500.
POST /api/boards/:boardId/lists/:listId/cards/:cardId/customFields/:customFieldId
threw Meteor.Error(404) when no unarchived card matched the board, list and
card id. A WebApp handler is a plain connect handler, so that reached the
client as HTTP 500. It now answers through sendJsonResult with a JSON 404,
as the card archive/restore endpoints do since #6876.

tests/restHandlerNotFound.test.cjs drives the handler for a matching card,
a missing card, the wrong list, the wrong board and an archived card, and
scans every WebApp.handlers route in the source tree so no REST handler
can answer not-found by throwing Meteor.Error(404) again.

Thanks to AhmedLukman and xet7 !
2026-11-06 16:15:34 +02:00
Lauri Ojansivu f54aa93dc1 CHANGELOG: GroupLinkBleed.
Thanks to bhaswanthc and xet7 !
2026-11-06 16:15:34 +02:00
Lauri Ojansivu e3458004ec Fix GroupLinkBleed: board admins can no longer attach foreign teams or add members by direct board writes.
GHSA-v544-rmr5-38gx. Boards.allow lets a board admin update any field of
their board, and two checks existed only inside Meteor methods: a board
admin may add only the organizations and teams they belong to (#5141,
setBoardOrgs/setBoardTeams), and inviteUserToBoard's checks (who may
invite, disabled accounts, the #6116 same organization/team policy). A
client calling /boards/update or /boards/insert directly skipped both.

New Boards.deny rules refuse any client insert or update naming orgs,
teams or domains - the UI changes them only through the board methods,
which write server-side - and any client update that adds a member or
switches a removed member back on (models/lib/boardWriteGuards.js). Role
changes and removals, which the client writes itself, still pass. A
refusal is recorded in Admin Panel / Problems as GroupLinkBleed
(authz.group-link, authz.member-add, medium).

tests/groupLinkBleed.test.cjs runs the real deny rules in a vm against
the reported writes and every legitimate client write, and fails if a
method-guarded board field is client-writable; it fails without the fix.
tests/playwright/specs/group-link-bleed.e2e.js replays the attack over DDP.

Thanks to bhaswanthc and xet7 !
2026-11-06 16:15:34 +02:00
Lauri Ojansivu 6ae4ad3545 Merge pull request #6877 from AhmedLukman/codex/rest-card-sort-zero
Allow zero sort values in REST card updates
2026-11-06 16:15:10 +02:00
Ahmed Lukman b0a71ba57b Allow zero sort values in REST card updates
Check whether sort is supplied so JSON updates can persist a zero position. Keep the existing scoped update and history handling, and add handler and browser regressions for ordering, persistence, omitted values and rejected requests.
2026-11-06 16:54:07 +03:00
Lauri Ojansivu f3d013339c Merge pull request #6876 from AhmedLukman/codex/rest-card-archive-not-found
Fix 404 responses for card archive and restore
2026-11-06 15:42:23 +02:00
Lauri Ojansivu 95ea04b049 Merge pull request #6875 from AhmedLukman/codex/public-csv-export-fields
Honor field selections in public CSV and TSV exports
2026-11-06 15:42:08 +02:00
Ahmed Lukman 3b33c7d7ba Return JSON 404 for unmatched card archive and restore requests
Use the existing JSON response helper when the board, list, card and archive-state selector finds no card. Preserve authorization checks and the existing archive and restore methods.

Add handler and browser regressions for missing cards, incorrect scopes, wrong states, repeated requests, successful transitions and unauthorized callers.

Thanks to AhmedLukman and xet7 !
2026-11-06 14:34:33 +03:00
Ahmed Lukman 4762bed8c1 Honor field selections in public CSV and TSV exports
Share validated export options between public and private CSV routes. Add handler regressions and live export-menu coverage for comma, semicolon and tab delimiters, default columns and private-board access.
2026-11-06 12:20:48 +03:00
github-actions[bot] 029ecb6b39 Bump versions for v12.97 2026-11-06 02:01:17 +00:00
Lauri Ojansivu 49c309b324 Prepare v12.97 release 2026-11-06 04:00:13 +02:00
Lauri Ojansivu e09267cc90 CHANGELOG: BSD smoke test cleanup and snap Launchpad retries.
Thanks to xet7 !
2026-11-06 03:52:17 +02:00
Lauri Ojansivu b46f155fa3 Fix v12.96 release builds: BSD smoke test cleanup and snap Launchpad retries.
openbsd-arm64 bundle: releases/bsd/smoke.sh returned OK with WeKan and
FerretDB still running, because start-wekan.sh runs its TERM trap only
after the foreground node exits. FerretDB then removed wekan.sqlite-wal
and -shm while the VM copyback rsync walked the workspace, and rsync exit
24 failed a bundle that had built and started. smoke.sh now stops node
and FerretDB, waits until both are gone (SIGKILL after 30 s) and removes
its work directory before returning.

s390x snap: all three attempts met Launchpad "HTTP Error 503: Service
Unavailable" / "Error: Timeout", but snapcraft printed only its last
exception, "Could not find repository at path ...", and the 503 was only
in snapcraft's execution log, which infra_failure() did not read. The
attempts were counted as WeKan failures instead of earning the extra
retries. infra_failure() now reads this attempt's execution logs, and
also counts the push refusal "You do not have permission to create
refs/..." from the repository those timed-out requests half-created.

Tests reproduce both from the v12.96 logs, with negatives: an earlier
attempt's execution log and a log with no outage do not count, and no
process or smoke-test file is left behind when smoke.sh returns.

Thanks to xet7 !
2026-11-06 03:52:04 +02:00
Lauri Ojansivu 0c0288a6c5 CHANGELOG: build.sh and releases scripts work on Arch Linux and Omarchy.
Thanks to xet7 !
2026-11-06 01:57:16 +02:00
Lauri Ojansivu c61a1b7f41 build.sh and releases/*.sh work on Arch Linux and Omarchy.
Omarchy (https://omarchy.org) is Arch with Hyprland: pacman, the Arch
repositories and yay for the AUR. The shared detector in
releases/ensure-tools.sh now names Omarchy and the other Arch derivatives
(EndeavourOS, CachyOS, Manjaro) explicitly, and what the scripts did there
is fixed:

- No more `pacman -Sy <package>`: that is a partial upgrade, which Arch does
  not support. _et_pacman installs with `-S --needed`, does a full `-Syu`
  only when pacman has never synced (a fresh archlinux container) or when
  WEKAN_PACMAN_SYU=1 asks for it, and otherwise says to update first, naming
  omarchy-update on Omarchy.
- 7zip instead of p7zip, which Arch has replaced.
- snapcraft: snapd from the AUR with yay or paru (never as root), then
  snapd.socket, /snap and the snapcraft snap, instead of only an error.
- The SSH server menu entries use sshd.service outside Debian/Ubuntu, where
  the unit is not called ssh.
- Playwright: `install --with-deps` runs apt-get, so outside Debian/Ubuntu
  the browser libraries come from pacman (or dnf) and the browsers are
  installed without --with-deps. WebKit, which Playwright builds for
  Debian/Ubuntu only, runs in the Playwright Docker image on Arch when
  Docker is there.
- The en_US.UTF-8 locale note gives the Arch/Omarchy steps too.

tests/archOmarchyScripts.test.cjs drives the helpers offline with stubbed
sudo/pacman/yay, and its negative test fails if any tracked shell script
does a `pacman -Sy` partial upgrade or installs p7zip.

Thanks to xet7 !
2026-11-06 01:57:00 +02:00
github-actions[bot] 9ce35869a7 Bump versions for v12.96 2026-11-05 19:41:40 +00:00
Lauri Ojansivu 2017d07e37 Prepare v12.96 release 2026-11-05 21:40:23 +02:00
Lauri Ojansivu 9ea5c81932 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-11-05 21:39:54 +02:00
Lauri Ojansivu 45ccdd9c3c CHANGELOG: the bundle starts again, and v12.95 records that its bundle did not.
Thanks to xet7 !
2026-11-05 21:39:02 +02:00
Lauri Ojansivu 5a21b074d4 The bundle starts again: nodemailer 10 in meteor/email gets back the lib/ paths it was built against.
releases/bump-bundle-npm-deps.mjs raises the nodemailer inside Meteor's email
package from 8.0.3 to 10.3.1 (dad03959d9), and nodemailer 10 moved lib/ to
dist/cjs/. packages/email.js, compiled against 8.0.3, links
lib/nodemailer.js and lib/well-known/index.js and Npm.requires
lib/mail-composer by path, so v12.95's build-amd64 failed its smoke boot:
"Cannot find module .../meteor/email/node_modules/nodemailer/lib/nodemailer.js".

releases/bundle-npm-security-bumps.json now shims nodemailer the way it
already shims uuid 11: lib/nodemailer.js and every lib/<name> directory the
exports map lists are written back, each forwarding to dist/cjs, and
lib/package.json marks lib/ as CommonJS because nodemailer is "type":
"module". A fresh local bundle taken through build-amd64's install, bump,
trim and prune steps now passes releases/bundle-smoke-boot.sh.

tests/bundleNpmSecurityBumps.test.cjs applies the shim to node_modules'
nodemailer 10, requires the recorded paths, checks that every exported
lib/<name> has a forward to the same module, and fails without the CommonJS
marker.

Thanks to xet7 !
2026-11-05 21:39:02 +02:00
github-actions[bot] e9c5888485 Bump versions for v12.95 2026-11-05 19:04:13 +00:00
Lauri Ojansivu 09c9d72398 Prepare v12.95 release 2026-11-05 21:03:04 +02:00
Lauri Ojansivu 4f3d45f3de CHANGELOG: the attachment upload policy method is loaded.
Thanks to Raymond-Voogt and xet7 !
2026-11-05 20:57:07 +02:00
Lauri Ojansivu 3aaa46fdae The attachment upload policy method is loaded, so a card hides the "+" when uploads are blocked.
d8b3579117 added server/methods/attachmentUploadPolicy.js but nothing
imported it, so the method did not exist: the card's
Meteor.callAsync('attachmentUploadPolicy') failed, the client treated that
as "not blocked", and the "+" stayed. server/imports.js now imports it with
the other server methods. tests/serverModulesLoaded.test.cjs, which failed
on it, passes.

Thanks to Raymond-Voogt and xet7 !
2026-11-05 20:57:01 +02:00
Lauri Ojansivu 59f1b705e0 CHANGELOG: missing users, organizations and teams return 404, Requested by on the minicard, and the pull request rules.
Thanks to AhmedLukman, mueschel and xet7 !
2026-11-05 20:56:19 +02:00
Lauri Ojansivu 22e5df55d7 Requested by and Assigned by show on the minicard when a member was picked, with the same icon everywhere.
The opened card's + button picks board members into card.requesters /
card.assigners; the free-text requestedBy / assignedBy is optional. The
minicard only drew the free text, so a requester picked the usual way never
showed, although Show on Minicard was on. It now draws the picked members
as avatars beside the typed name, as the opened card does.

Card Settings showed Requested by with a user-plus icon and Assigned by with
a shopping cart, the other way round from the opened card and the minicard.
The settings rows now use the cart for Requested by and user-plus for
Assigned by.

tests/minicardRequestedAssignedBy6872.test.cjs pins the avatars, the
either-or condition and one icon per field across all three places, and
fails on a minicard person block gated on the free text alone.
tests/playwright/specs/minicard-requested-by-6872.e2e.js drives it.

Thanks to mueschel and xet7 !

Fixes #6872.
2026-11-05 20:55:28 +02:00
Lauri Ojansivu 016b4bf3bd CLAUDE.md, AGENTS.md: only maintainer mode commits directly and updates changelogs; one pull request per feature or fix.
A pull request contributor, in wekan/wekan and in the maintainer's
repositories under .tools/, sends each feature or fix as its own pull
request, with only related changes in it, and changes no changelog: the
maintainer writes the entry after merging, when there is a commit to link.
The CHANGELOG section and the security CRITICAL entry say they are
maintainer mode only, and "good practice for everyone" no longer includes
the CHANGELOG structure. tests/changelogFormat.test.cjs checks both files.

Thanks to xet7 !
2026-11-05 20:55:28 +02:00
Lauri Ojansivu 4d44349ab6 Merge pull request #6874 from AhmedLukman/codex/rest-people-features-not-found
Return 404 for missing organization and team feature updates.
2026-11-05 20:53:48 +02:00
Lauri Ojansivu d3edc56170 Merge pull request #6873 from AhmedLukman/codex/user-update-not-found
Return 404 for updates to missing users
2026-11-05 20:53:12 +02:00
Ahmed Lukman 5134e3d2a8 Return 404 for missing organization and team feature updates.
Return a JSON not-found response when the feature update lookup finds no organization or team. Cover missing records, successful updates, authorization, and reactive People-table checkboxes.

Thanks to AhmedLukman and xet7 !
2026-11-05 21:28:38 +03:00
Ahmed Lukman f686b76023 Keep the user update fix limited to code and tests.
Thanks to AhmedLukman and xet7 !
2026-11-05 20:54:17 +03:00
Ahmed Lukman cd2adcad02 Document user update not-found responses.
Thanks to AhmedLukman and xet7 !
2026-11-05 20:48:43 +03:00
Ahmed Lukman 0766a65fef Return 404 for updates to missing users.
Thanks to AhmedLukman and xet7 !
2026-11-05 20:47:06 +03:00
Lauri Ojansivu 58ad81e301 continuousBackup integration suite: stop each engine before its directory is removed.
Three tests registered the hook that removes their temp directory before the hook
that stops their engine. node:test runs after-hooks in the order they were
registered, not last-in first-out, so the directory was gone when stop() sealed
into it, and the ENOENT (files/index.ndjson) surfaced after the test as
"generated asynchronous activity after the test ended" - which failed the whole
suite although all 8 tests passed. One hook now stops the engine first and then
removes the directory. Five runs in a row, and the suite runner, pass.

Thanks to xet7 !
2026-11-04 19:20:58 +02:00
Lauri Ojansivu 2912f4b080 Close #6870: attachments are already off site-wide in Admin Panel / Attachments / Transfer limits.
How: Admin Panel / Attachments / Transfer limits, set "Attachments upload" and
"API upload" to Blocked. That is the site-wide "Disable attachments" the issue
asks for, as two settings instead of one checkbox.

Comment for the issue:

  A site-wide switch for this already exists, so a new "Disable attachments"
  setting is not needed. Open Admin Panel / Attachments / Transfer limits and set
  "Attachments upload" and "API upload" to Blocked. While they are on, the server
  refuses an upload on every path - the upload pipeline, the api.attachment.upload
  and api.attachment.copy methods, and the /api/attachment upload,
  upload-background and copy routes - on every board, including boards created
  later, and no board's allowsAttachments flag has to be touched. Download, move
  and delete are not affected, so files uploaded earlier stay reachable. What was
  missing is that a card still showed the "+" button and accepted a drop for an
  upload that would then be refused; since d8b3579117 the card asks the server
  once a minute and hides both. There is no second setting, so nothing can
  disagree with what the server enforces. Two settings instead of one checkbox
  is the one difference from the request.

Thanks to Raymond-Voogt and xet7 !

Fixes #6870,
2026-11-04 19:17:53 +02:00
Lauri Ojansivu 97d1eca09d CHANGELOG: In short for the upcoming release. 2026-11-04 19:12:14 +02:00
Lauri Ojansivu 95b75177b8 CHANGELOG: continuous backup runs on one server at a time, with a lease. 2026-11-04 19:12:06 +02:00
Lauri Ojansivu 880ec2d707 Continuous backup runs on one server at a time, with a lease.
With several WeKan servers on one database, every server applied the continuous
backup setting, so each of them tailed the oplog and wrote its own archives into
the same target directory (#6861, item 3).

The leader holds a lease: one continuousBackupLease document with its id and an
expiry. Taking it is one atomic operation (an insert while there is none, else
an update that matches only an unheld, own or expired lease), the leader renews
it every 15 seconds and it lapses after 45, so another server takes over within
a minute when the leader stops. A server that loses the lease stops its stream.
Startup, saving the settings and a change saved on another server all converge
through that loop, and a restore or a fetch from the cloud takes the lease and
waits one renewal so it is the only writer. One server is always its own leader.

Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader; renewal, expiry, loss of the old leader and release behave.
tests/continuousBackupLease.test.cjs pins the same and the wiring in
server/continuousBackup.js. docs/Backup/Continuous-Backup.md no longer says
single-server only.

Thanks to Raymond-Voogt and xet7 !

Refs #6861, not closed: the remaining items of the issue were fixed in 4e28465964.
2026-11-04 19:11:32 +02:00
Lauri Ojansivu dbc7cccea3 CHANGELOG: a card no longer offers the attachment plus when uploads are blocked for the whole site. 2026-11-04 19:07:11 +02:00
Lauri Ojansivu d8b3579117 A card no longer offers the attachment "+" when uploads are blocked for the whole site.
#6870 asks for a site-wide switch that turns attachments off, because the
per-board allowsAttachments flag has to be set on every board. That switch is
there already: Admin Panel / Attachments / Transfer limits, "Attachments upload"
and "API upload" set to Blocked, and the server refuses on every path - the
upload pipeline, api.attachment.upload and .copy, and the /api/attachment
upload, upload-background and copy routes. What was missing is that the card
still showed the "+" button and accepted a drop for an upload that would be
refused.

attachmentUploadPolicy tells a signed-in client the one boolean (nothing about
the limits), asked at most once a minute, and the "+" and the drop/paste upload
follow it. No second setting is added, so there is nothing that could disagree
with what the server enforces. tests/attachmentUploadPolicy.test.cjs pins the
client, the method, and the server enforcement it relies on, and that no
"disableAttachments" setting exists.

Thanks to Raymond-Voogt and xet7 !

Refs #6870, not closed: the reporter asked for a single Features checkbox, and the
two Blocked limits are two settings.
2026-11-04 19:07:09 +02:00
Lauri Ojansivu c7e53e9f38 CHANGELOG: an integration update for a missing or wrong-board integration returns 404. 2026-11-04 19:04:14 +02:00
Lauri Ojansivu 6012521ccc An integration update for a missing or wrong-board integration returns 404.
PUT /api/boards/:boardId/integrations/:intId answered 200 with the requested id
when no integration matched that id and board (PR #6871). The two activities
routes (POST and DELETE .../activities) had the same fault: they updated
nothing and answered 200 with `data: null`. All three now look the integration
up on that board after board-admin authorization and answer the JSON 404 the GET
route already uses (#6862), before validating or writing anything.

tests/restIntegrationUpdate.test.cjs and the Playwright spec are the PR's.
tests/restIntegrationWriteRoutes404.test.cjs adds the activities routes and
pins, for the whole file, that every handler that updates an integration looks
it up first and only after authorization.

Thanks to AhmedLukman and xet7 !
2026-11-04 19:04:12 +02:00
Lauri Ojansivu 0195a5b3d4 Merge remote-tracking branch 'origin/main' 2026-11-04 15:02:36 +02:00
Lauri Ojansivu b12e761afb CHANGELOG: missing-board exports return 404 and an over-long export token 400. 2026-11-04 14:50:49 +02:00
Lauri Ojansivu 0c0d666ffa Missing-board exports return 404, and an over-long export token returns 400.
The board Excel export answered a missing board with HTTP 200 and the text
"Board not found" (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 too, while
export.js already used 404 and 400. They now choose their status the same way.

tests/boardExcelNotFound.test.cjs and the Playwright spec are the PR's.
tests/exportErrorStatuses.test.cjs scans every models/export*.js route for a
text answer without a status, so the fault cannot return anywhere there.

Thanks to AhmedLukman and xet7 !
2026-11-04 14:50:33 +02:00
Lauri Ojansivu fc2ad0a48b CHANGELOG: nodemailer, body-parser, qs, uuid and postcss-selector-parser raised in the bundle. 2026-11-04 14:45:57 +02:00
Lauri Ojansivu dad03959d9 Raise nodemailer, body-parser, qs, uuid and postcss-selector-parser in the bundle.
A Grype scan of the image found advisories in packages that Meteor's own
packages carry in programs/server/npm/node_modules/meteor/<package>, which
package.json cannot reach.

nodemailer 8.0.11 -> 10.3.1: meteor/email only calls createTransport, the
'stream' plugin hook, nodemailer/lib/well-known and nodemailer/lib/mail-composer,
and all four work in 10.3.1 (checked with the bundle's own package specifiers,
through its exports map). body-parser 1.20.6/1.20.8 -> 1.20.9 and 2.3.0 ->
2.4.0: a minimum may now be "name@MAJOR", so each major is raised inside
itself. qs -> 6.16.1, postcss-selector-parser 6.1.4 -> 6.1.5.

uuid 8.3.2 and 9.0.1 -> 11.1.1: 11 moved its entry point, which is why it was
refused before. The bumper now writes the recorded dist/index.js back as an
ESM re-export of dist/esm/index.js, which require() loads and which leaves
uuid's own ESM and CJS entry points as they are.

glob 14 and minimatch 11 are already in package.json. Run against a copy of
the published image's bundle, the bumper raises all of these and every raised
copy loads.

Thanks to the Grype scan reporter and xet7 !
2026-11-04 14:45:04 +02:00
github-actions[bot] ac960e7d81 Bump versions for v12.94 2026-11-04 01:07:15 +00:00
Lauri Ojansivu 3213d7fa1d Prepare v12.94 release 2026-11-04 03:06:07 +02:00
Lauri Ojansivu 4757676abf CHANGELOG: on a phone in desktop mode the opened card starts below the header bar. 2026-11-04 03:04:05 +02:00
Lauri Ojansivu efaf94242e On a phone in desktop mode the opened card starts below the header bar.
The header wraps to several rows on a phone, far taller than the 8px the
floating card docks at, so the card's top row sat among the header's rows and
was only seen by dragging. Below 801px a card without an inline top, popup
cards included, starts at --wekan-header-height + 8px; a dragged place is an
inline top and is never overridden.

Thanks to the iPhone 12 mini reporter and xet7 !
2026-11-04 03:03:59 +02:00
Lauri Ojansivu d87baa0244 CHANGELOG: the opened card covers the header bar on an iPad. 2026-11-04 02:56:44 +02:00
Lauri Ojansivu 006d24502c The opened card covers the header bar on an iPad: the header drops to the content's level.
On iPad Chrome and Safari (both WebKit) the opened card's top row - the collapse
caret, the card number, the menu, maximize and X - was behind the header bar,
although the card has z-index 2001 and the header 1000. iOS WebKit makes every
scrolling element (#content, the board wrapper) a stacking context of its own, so
the card's z-index is compared only inside it while the header bar, a flex item of
body and a stacking context too, is painted over all of it. While a card is open
the header bar (#header-quick-access) is now at z-index 0, the level of the
content, which comes after it in the page and so paints over it; with no card open
it keeps 1000.

A browser spec makes #content and the board wrapper stacking contexts with
isolation: isolate, which is the same trap, and shows the card over the header open
and collapsed, its close button reachable, in Chromium and WebKit; without the rule
it fails with the card's top at 8 behind a header 88 high, as in the screenshot.

Thanks to xet7 !
2026-11-04 02:56:33 +02:00
Lauri Ojansivu 8f4efbca57 Merge remote-tracking branch 'origin/main' 2026-11-04 01:35:33 +02:00
Lauri Ojansivu dde09dead5 CHANGELOG: where the time goes when a WeKan board is opened. 2026-11-04 01:21:48 +02:00
Lauri Ojansivu 3621413d6f Speed test: where the time goes when a WeKan board is opened, on FerretDB and on MongoDB.
app/board-open.cjs seeds a board of 200, 2000 or 5000 cards, opens it in Chromium and
records the wall clock to the first card and until the DDP WebSocket is quiet, what
crossed it, the Meteor server's CPU by who spends it (Node inspector) and FerretDB's
CPU (pprof). Two runs on each database: the Meteor server's CPU is the same on both
(0.1-0.5 s) and the DDP server is 1% of it; a 5000-card board takes FerretDB (SQLite)
400-600 ms longer to open, the data sent to the browser being the same ~210 KB, a
small board 70-100 ms. FerretDB uses about 4 s of CPU for the 5000-card open: SQLite
39%, filtering in Go 34%, find/getMore/aggregate 0.7-0.8 s each. The README says what
was measured and what was not: the development build, one machine, the browser's own
time not separated, and the aggregate's source not identified.

Thanks to Raymond-Voogt and xet7 !
2026-11-04 01:21:16 +02:00
196 changed files with 6305 additions and 412 deletions

No files matched your search

+26 -4
View File
@@ -2624,7 +2624,12 @@ jobs:
permissions:
contents: write
needs: [prepare, build-amd64, release]
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
# Apple Silicon (arm64). It was `macos-14`, and GitHub RETIRED that image:
# the v12.98 run's build-mac-arm64 was cancelled with "The configuration
# 'macos-14-arm64-us-default' is not supported", so v12.98 shipped no
# mac-arm64 bundle. macos-15 is Apple Silicon too (the same label mac.yml
# already builds on).
runs-on: macos-15
timeout-minutes: 60
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -2900,7 +2905,8 @@ jobs:
contents: write
needs: [prepare, build-amd64, release]
# Intel (x86_64) macOS. It was `macos-13`, and GitHub RETIRED that image -
# actions/runner-images publishes macos-14, macos-15 and macos-26 only, and
# actions/runner-images publishes macos-15 and macos-26 only (macos-14 was
# retired later too), and
# macos-13 is gone from the hosted-runner table. A retired label does not
# fail: the job QUEUES FOREVER waiting for a runner that will never come, so
# every release since then had a build-mac-x64 sitting there for hours until
@@ -2908,7 +2914,7 @@ jobs:
# what skipped the charts jobs (they had not started yet).
# No release has ever carried a wekan-<version>-mac-x64.zip because of it.
# Intel macOS is still offered, under new labels: macos-15-intel and
# macos-26-intel (macos-14/15/26 without the suffix are Apple Silicon). This
# macos-26-intel (macos-15/26 without the suffix are Apple Silicon). This
# is the same label .tools/TSC already builds its Intel Mac on.
runs-on: macos-15-intel
timeout-minutes: 60
@@ -5070,13 +5076,29 @@ jobs:
[ -e "$1" ] || return 1
case "$logs_before" in *" $1 "*) [ "$1" -nt "$attempt_marker" ] ;; *) return 0 ;; esac
}
# snapcraft's execution logs. v12.96 s390x: every attempt printed only
# "Could not find repository at path ...", because snapcraft reports
# the LAST exception; the cause before it - "HTTP Error 503: Service
# Unavailable" / "Error: Timeout" creating that repository - was in
# the "Full execution log" alone. Unread, three Launchpad outages
# counted as three WeKan failures and the arch was not published.
snapcraft_log_dir="$HOME/.local/state/snapcraft/log"
infra_failure() { # did THIS attempt fail on Launchpad, not on WeKan?
grep -qsE 'HTTP Error 5[0-9][0-9]|Error: Timeout|Service Unavailable' "$remote_log" && return 0
# The same v12.96 s390x run's last attempt: the repository the
# timed-out requests had half-created refused our push with
# "You do not have permission to create refs/heads/main" - the
# same credentials pushed every other architecture in that run.
grep -qsE 'remote: You do not have permission to create refs/' "$remote_log" && return 0
local log
for log in snapcraft-wekan-*.txt; do
this_attempts_log "$log" || continue
grep -qsE 'RPC failed;|GnuTLS recv error|fatal: early EOF|Gateway error \[IP: ' "$log" && return 0
done
for log in "$snapcraft_log_dir"/*.log; do
this_attempts_log "$log" || continue
grep -qsE 'HTTP Error 5[0-9][0-9]|Error: Timeout|Service Unavailable' "$log" && return 0
done
return 1
}
# Set once an attempt ends with Launchpad reporting the build as
@@ -5099,7 +5121,7 @@ jobs:
# earlier attempts' logs stay in the directory for the diagnostics
# below, but must not be printed again as if they were this one's.
touch "$attempt_marker"
logs_before=" $(ls snapcraft-wekan-*.txt 2>/dev/null | tr '\n' ' ')"
logs_before=" $(ls snapcraft-wekan-*.txt "$snapcraft_log_dir"/*.log 2>/dev/null | tr '\n' ' ')"
if [ "$fresh_next" = 1 ]; then
echo "The previous attempt's Launchpad build finished without a snap; submitting a new build instead of recovering it."
run_remote_build && rc=0 || rc=$?
+35 -9
View File
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
| Who | Where the work goes |
| --- | --- |
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
Nothing else is a third option. If a tool, a task runner or an agent harness offers
some other arrangement — a topic branch for the maintainer, a direct push for a
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
The contributor's half is review. Nobody but the maintainer commits to `main` in
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
the only place it can be discussed before it lands.
the only place it can be discussed before it lands. That is why each pull request
holds one feature or one fix, and leaves the changelogs to the maintainer: see
"Contributor mode" below.
Check which branch you are on before committing:
@@ -143,12 +145,29 @@ git config user.name && git config user.email
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
steps** below are available to a human maintainer only: an AI stops after
committing locally and never runs a step that pushes or publishes.
**Maintainer mode is the only mode that commits directly and the only mode that
updates changelogs**, in WeKan and in the maintainer's repositories under
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
changes of an agent working as the maintainer according to this file.
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
own — a person, or an AI that files pull requests on its own account. Then: do
**not** commit to `main` and do **not** run any release/publishing step. Make the
changes on a branch and open a **pull request** for the maintainer to review. The
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
instructions below are **maintainer-only and do not apply to you**.
**not** commit directly to the repository or to `main`, and do **not** run any
release/publishing step. Make the changes on a branch and open a **pull request**
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
`main`", changelog and release instructions below are **maintainer-only and do
not apply to you**.
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
follows two more rules:
- **One feature or one fix per pull request.** Changes share a pull request only
when they are related to that one feature or fix. Anything unrelated, however
small, goes in a pull request of its own, so each can be reviewed, merged or
declined without holding up the others.
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
entry, not TODO Later. Describe the change in the pull request instead. The
maintainer writes the entry after merging, when there is a commit for it to link.
Who that pull request credits follows the attribution rule above, and the two
cases differ:
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
cloned for reference only.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
only in maintainer mode. The rest (translation policy, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
Fixes #1234,
```
A contributor instead commits on a branch for one pull request that fixes this
one issue, with no changelog changes (see "Contributor mode" above).
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
whose message ends `Fixes #1234,`), noting where/when it was fixed.
@@ -407,6 +429,10 @@ directly after the merge.
### CHANGELOG
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
section is for the maintainer and for an agent working as the maintainer, in WeKan
and in the repositories under `.tools/`. A pull request contributor changes no
changelog in any of them (see "Contributor mode" above).
- **Every repository writes its changelog in the format that repository's own file
already uses.** Open its changelog, read the entries above the place you are
adding one, and match them — never import another project's shape into it. The
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
fault cannot exist ANYWHERE in the codebase — not just at the place it was
+652
View File
@@ -124,6 +124,658 @@ its reason.
</details>
# v13.00 2026-11-07 WeKan ® release
**In short:** **CreateBleed** is fixed: "Only admins can create boards" now
holds on every path that creates a board - the browser console, the REST API,
imports, copies, clones and cards - not only on the Add Board form. The **REST
API documentation** spells the name **WeKan**, so the API pages published on
wekan.fi pass its brand-spelling check again.
This release fixes the following CRITICAL SECURITY ISSUE of [CreateBleed](https://wekan.fi/hall-of-fame/createbleed/):
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c1f6088f4">Only admins create boards on every path, not only through Add Board</a>. Thanks to tsurutanmen and xet7.</summary>
[CreateBleed](https://wekan.fi/hall-of-fame/createbleed/),
[GHSA-qcfm-2g4w-wqcc](https://github.com/wekan/wekan/security/advisories/GHSA-qcfm-2g4w-wqcc).
Admin Panel / Settings / Visibility / "Only admins can create boards"
([#4475](https://github.com/wekan/wekan/issues/4475)) was checked only inside
`createBoardWithInitialSwimlanes`, the Add Board form. A user who is not an
admin still created working boards with a console `Boards.insert` - the
board collection's allow rule approves any signed-in user - with
`POST /api/boards` and with both board import routes, as reported, and also
with `importBoard`, `cloneBoard`, `copyBoard`, `POST /api/boards/:boardId/copy`
and `createBoardFromCard`. The boards belonged to their creator; no other
user's data was read or changed.
One decision (`models/lib/boardCreationPolicy.js`) and one guard
(`server/lib/boardCreationGuard.js`) are now asked by every one of those paths
before they write, and by a new `Boards.deny` insert rule. A board template
stays every member's own, and boards WeKan makes by itself - a user's
Templates board, a board's subtasks board - are not user creations. A refused
REST call answers 403. A refusal is recorded in Admin Panel / Problems as
CreateBleed (`authz.board-creation`, low, CWE-863, never disables the
account), and Duplicate and "Create board from card" are hidden like Add
Board, so only a client bypassing the interface reaches it.
`tests/createBleed.test.cjs` drives the decision, the guard and the real deny
rule against the reporter's console insert, and fails if any path that creates
a board for a user does not ask the guard, or if anything else reads the
setting; `tests/playwright/specs/createbleed-board-creation-admin-only.e2e.js`
replays the report against a running server.
</details>
and fixes the following documentation bugs:
**REST API documentation** - the name is WeKan in the API reference, from the
generator to the page wekan.fi publishes.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/4d5fe63fc4">The REST API documentation spells the name WeKan</a>. Thanks to xet7.</summary>
The header of `openapi/generate_openapi.py` and the JSDoc of six models said
"Wekan REST API", "extend Wekan", "a production Wekan server" and "in the
Wekan board", so every `public/api/wekan.yml` and the API pages built from it
said Wekan too, and the v12.95, v12.96 and v12.97 pages on wekan.fi failed its
`tests/brand-spelling.test.cjs`. The generator, the model comments and
`wekan.yml` now say WeKan. `tests/openapiBrandSpelling.test.cjs` checks the
generated file, the generator header and every model JSDoc block, and fails on
the files before this change.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0275363043">The built API page says WeKan, so the v12.99 page on wekan.fi does too</a>. Thanks to xet7.</summary>
The v12.99 version bump rebuilt `public/api/wekan.html` with the generator from
before the fix, and `releases/release-website.sh` copies that file to wekan.fi
as `api/v12.99/index.html`. Its visible text and the JSON Redoc rebuilds the
page from now say WeKan, and `tests/openapiBrandSpelling.test.cjs` checks the
built page as well.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.99 2026-11-06 WeKan ® release
**In short:** The **mac-arm64** release bundle builds again: its job asked for
GitHub's retired **macos-14** runner and was cancelled in the v12.98 release
run. The **weba** instructions now live in the weba repository itself.
This release fixes the following release build bug:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7e4483b1a9">The mac-arm64 bundle is built on macos-15, because GitHub retired macos-14</a>. Thanks to xet7.</summary>
In the v12.98 release run, build-mac-arm64 was cancelled with "The
configuration 'macos-14-arm64-us-default' is not supported", so v12.98 has no
mac-arm64 bundle. The job now runs on `macos-15`, which is Apple Silicon too
and is what `mac.yml` already uses. `tests/workflowRunnerLabels.test.cjs` lists
`macos-14` as retired, so no workflow can ask for it again, and checks that
build-mac-arm64 asks for a current Apple Silicon runner.
</details>
and has the following developer documentation change:
- [The weba instructions moved from AGENTS.md and CLAUDE.md into the weba repository](https://github.com/wekan/wekan/commit/b4cb989391).
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.98 2026-11-06 WeKan ® release
**In short:** **GroupLinkBleed** is fixed: a board admin can no longer skip the
board methods by writing the board directly, to attach an organization or team
they do not belong to or to add a member past the invitation checks.
**Public board CSV and TSV exports** honour the selected columns, and the REST
**card archive, restore and custom-field** endpoints answer an unmatched card
with a JSON 404 instead of 500, and a REST **card update** can set sort 0, turn
overtime off and clear a description. The **notifications drawer** gets its
attachments again, and the developer instructions cover the **weba** Gambas
companion repository.
This release fixes the following CRITICAL SECURITY ISSUE of [GroupLinkBleed](https://wekan.fi/hall-of-fame/grouplinkbleed/):
<details>
<summary><a href="https://github.com/wekan/wekan/commit/e3458004ec">Board admins can no longer attach foreign teams or add members by direct board writes</a>. Thanks to bhaswanthc and xet7.</summary>
[GroupLinkBleed](https://wekan.fi/hall-of-fame/grouplinkbleed/),
[GHSA-v544-rmr5-38gx](https://github.com/wekan/wekan/security/advisories/GHSA-v544-rmr5-38gx).
The board allow rule lets a board admin update any field, and two checks lived
only in Meteor methods: adding only the organizations and teams the admin
belongs to ([#5141](https://github.com/wekan/wekan/issues/5141)), and the
invitation's checks, including the same organization/team policy
([#6116](https://github.com/wekan/wekan/issues/6116)). A client writing the
board over DDP skipped both, so a whole team could be given an attacker's
board and any user added to it. Client writes naming `orgs`, `teams` or
`domains`, and client updates that add or reactivate a member, are now
refused and shown in Admin Panel / Problems; role changes and removals still
pass. `tests/groupLinkBleed.test.cjs` runs the real deny rules and fails if a
method-guarded board field is client-writable.
</details>
and fixes the following bugs:
**Exports** - the CSV and TSV columns a public board exports.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/4762bed8c1">Public board CSV and TSV exports honour the selected fields</a>. Thanks to AhmedLukman and xet7.</summary>
On a public board the CSV and TSV export ignored `fields=` and wrote all 20
columns, while a private board wrote only the selected ones (PR
[#6875](https://github.com/wekan/wekan/pull/6875)). The validated selection is
now parsed once, before the public/private branch, so both get the same
columns; a missing, empty or unrecognised selection still exports all of them,
and the export guards are unchanged. `tests/publicCsvExportFields.test.cjs`
drives the route for comma, semicolon and tab, and a Playwright spec checks the
export menu's checkboxes on a public board.
</details>
**REST API** - what the card endpoints accept, and answer when no card matches.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/3b33c7d7ba">Card archive and restore answer a JSON 404 for an unmatched card instead of 500</a>. Thanks to AhmedLukman and xet7.</summary>
`POST …/cards/:cardId/archive` and `…/unarchive` threw `Meteor.Error(404)`
when no card matched the board, list, id and archive state. A WebApp handler is
a plain connect handler, so that reached the client as HTTP 500 (PR
[#6876](https://github.com/wekan/wekan/pull/6876)). They now answer through
`sendJsonResult` with `{ error: 'Card not found' }` and 404, writing nothing.
`tests/restCardArchive.test.cjs` and a Playwright spec cover a missing card,
the wrong list, the wrong board, the wrong state and refused callers.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b0a71ba57b">A card update can set sort to 0</a>. Thanks to AhmedLukman and xet7.</summary>
`PUT …/cards/:cardId` with `{ "sort": 0 }` answered 404 and left the card
where it was, because the handler read `sort` by truthiness (PR
[#6877](https://github.com/wekan/wekan/pull/6877)). It now writes any sort that
is present. `tests/restCardSort.test.cjs` and a Playwright spec check 0, other
numbers, form-encoded `'0'`, an omitted sort and refused callers.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b895e61237">A card update checks sort, isOverTime and description, so false and empty work</a>. Thanks to AhmedLukman and xet7.</summary>
Writing `sort` whenever it is present would also have written `null`, `''` or
`'abc'` as the card's position, and the same handler read `isOverTime` and
`description` by truthiness, so overtime could not be turned off and a
description could not be cleared. `parseCardScalarFields()` decides all three
by presence before the first write: sort a finite number, isOverTime a boolean
(`'true'`/`'false'` from a form too), description a string or `null` to clear.
Anything else is a 400 and the card is unchanged.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6d5f660c27">The card custom-field update answers a JSON 404 for an unmatched card instead of 500</a>. Thanks to AhmedLukman and xet7.</summary>
`POST …/cards/:cardId/customFields/:customFieldId` had the same
`throw new Meteor.Error(404)` as the archive endpoints, and now answers the
same JSON 404. `tests/restHandlerNotFound.test.cjs` drives it for a matching,
missing, wrong-list, wrong-board and archived card, and scans every
`WebApp.handlers` route in the tree so no REST handler answers not-found by
throwing again.
</details>
**Notifications** - what the notifications drawer receives.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/adb73fd8ee">The notifications drawer receives its attachments again, without a server exception</a>. Thanks to xet7.</summary>
Every `notificationAttachments` subscription failed with `Publish function can
only return a Cursor or an array of Cursors`: Attachments is an ostrio:files
FilesCollection, and the publication returned its FilesCursor instead of the
Mongo cursor it wraps. It now returns `.cursor`, as every other attachment
publication does. `tests/publishFilesCursor.test.cjs` runs the publication and
scans every publishing file so no attachment or avatar cursor query is
published without it.
</details>
and has the following developer-facing changes:
**Tests** - guards that had drifted from the code they pin.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/68b575637d">Three client guards follow the code they pin again</a>. Thanks to xet7.</summary>
`sidebarBelowHeader`, `maximizedCardPosition` and `titleClickToEdit` read
source that the full screen button, the phone-below-header rule and the
Pointer Events drag had since moved. The behaviour had not changed; the guards
now read the new places, `titleClickToEdit` calls `cardHeaderPress()` from
`models/lib/windowDrag.js`, and each still fails when its behaviour is removed.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/081690875d">The real single executable test says why it cannot run on a kernel with large pages</a>. Thanks to xet7.</summary>
`--build-sea` injects the SEA blob with LIEF, which adds 4 KiB-aligned
segments; a Linux kernel with 16 KiB or 64 KiB pages cannot map them, and the
executable dies with SIGSEGV - a one-line `console.log` SEA does too. The test
failed there as `null !== 0`; it is now skipped on such a kernel with that
reason. Release builders have 4 KiB pages and still run it.
</details>
**Developer instructions** - what an assistant working in this repository is
told.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1ef7a57ce4">AGENTS.md and CLAUDE.md describe the weba companion repository</a>. Thanks to xet7.</summary>
`.tools/weba` is the separate `wekan/weba` repository, a Gambas Web UI Kanban
with SQLite by default. The instructions say where its project, forms, browser
reports and database modules are, that WeKan's author, local-commit and no-push
rules apply there too, how to compile and run it with `gbc3`/`gbx3` from the
local Gambas under `.tools/bin/gambas3`, how its tests run on a copy of the
database, and to keep its SQL portable across Gambas database drivers.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.97 2026-11-06 WeKan ® release
**In short:** `build.sh` and the `releases/` scripts now work on **Arch Linux**
and **Omarchy**, and the release builds no longer lose the **OpenBSD arm64
bundle** to a smoke test that left WeKan running, or the **s390x snap** to
Launchpad outages that were counted as build failures.
This release fixes the following release build bugs:
**Release builds** - bundles and snaps that built correctly but failed the job.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b46f155fa3">The BSD smoke test stops WeKan and FerretDB and removes its files before returning</a>. Thanks to xet7.</summary>
v12.96 openbsd-arm64: `releases/bsd/smoke.sh` returned OK with WeKan and
FerretDB still running, because `start-wekan.sh` runs its TERM trap only after
the foreground node exits. FerretDB then removed `wekan.sqlite-wal` and `-shm`
while the VM's copyback rsync walked the workspace, and rsync exit 24 failed a
bundle that had built and started. The smoke test now stops both, waits until
they are gone and deletes its work directory. `tests/bsdBundles.test.cjs`
runs it against a launcher of `start-wekan.sh`'s shape and fails if a process
or a smoke-test file is left behind.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b46f155fa3">Launchpad outages seen only in snapcraft's execution log earn snap retries</a>. Thanks to xet7.</summary>
v12.96 s390x: all three attempts met Launchpad's `HTTP Error 503` /
`Error: Timeout`, but snapcraft printed only its last exception, "Could not
find repository at path ...", so the outage was counted as three WeKan
failures. `infra_failure()` now reads this attempt's snapcraft execution logs
too, and counts the "You do not have permission to create refs/" push refusal
from the half-created repository. `tests/snapLaunchpadInfraRetry.test.cjs`
pins both, and its negatives an earlier attempt's log and one with no outage.
</details>
and has the following developer-tooling fix:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/c61a1b7f41">build.sh and releases scripts work on Arch Linux and Omarchy</a>. Thanks to xet7.</summary>
Omarchy is Arch with Hyprland: pacman, the Arch repositories and yay for the
AUR. `releases/ensure-tools.sh` names it and the other Arch derivatives
explicitly. Packages are installed with `pacman -S --needed` instead of the
unsupported `-Sy` partial upgrade; a full `-Syu` runs only on a never-synced
pacman or with `WEKAN_PACMAN_SYU=1`, and a failed install says to update first
(`omarchy-update` on Omarchy). 7zip replaces Arch's retired p7zip, snapcraft
gets snapd from the AUR with yay or paru, the SSH server menu entries use
`sshd.service` outside Debian/Ubuntu, and Playwright's browser libraries come
from pacman (or dnf) instead of the apt-only `--with-deps`, with WebKit in the
Playwright Docker image on Arch. `tests/archOmarchyScripts.test.cjs` drives
the helpers offline, and its negative test fails if any shell script does a
`pacman -Sy` partial upgrade or installs p7zip.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.96 2026-11-05 WeKan ® release
**In short:** The release **bundle starts again**: v12.95's build stopped on
boot because the **nodemailer** 10 raised inside Meteor's email package no
longer had the files that package was built against.
This release fixes the following bug:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5a21b074d4">The bundle starts again, with nodemailer 10 inside Meteor's email package</a>. Thanks to xet7.</summary>
v12.95 raised the nodemailer in `meteor/email` from 8.0.3 to 10.3.1, and
nodemailer 10 moved `lib/` to `dist/cjs/`. `packages/email.js`, compiled
against 8.0.3, links `lib/nodemailer.js` and `lib/well-known/index.js` and
`Npm.require`s `lib/mail-composer` by path, so build-amd64 failed its smoke
boot. `releases/bundle-npm-security-bumps.json` now shims nodemailer as it
already shims uuid 11: `lib/nodemailer.js` and every `lib/<name>` directory the
exports map lists are written back, forwarding to `dist/cjs`, with a
`lib/package.json` marking them CommonJS because nodemailer is `"type":
"module"`. A fresh local bundle taken through build-amd64's install, bump, trim
and prune steps passes `releases/bundle-smoke-boot.sh`.
`tests/bundleNpmSecurityBumps.test.cjs` requires the recorded paths through the
shim, checks every exported `lib/<name>`, and fails without the CommonJS marker.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.95 2026-11-05 WeKan ® release
**In short:** **Continuous backup** now runs on one server at a time, with a
lease, when WeKan has several servers. Missing-board **exports** and REST
updates of missing **integrations**, **users**, **organizations** and **teams**
answer 404 instead of 200, the **minicard** shows a picked Requested by and
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
**postcss-selector-parser** are raised past the advisories a Grype scan found.
This release fixes the following bugs:
**Exports** - what the export routes answer when something is wrong.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0c0d666ffa">Missing-board exports return 404, and an over-long export token returns 400</a>. Thanks to AhmedLukman and xet7.</summary>
The board Excel export answered a missing board with HTTP 200 and only the text
`Board not found` (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 as well,
while `export.js` already used 404 and 400. All of them now choose their status
the same way.
`tests/exportErrorStatuses.test.cjs` scans every `models/export*.js` route for a
text answer without a status, so the fault cannot return anywhere there; the
PR's route and browser tests stay.
</details>
**Continuous backup** - which server streams it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/880ec2d707">Only one server streams it at a time, holding a lease</a>. Thanks to Raymond-Voogt and xet7.</summary>
With several WeKan servers on one database, every server applied the continuous
backup setting, so
each of them tailed the oplog and wrote its own archives into the same target
directory
(\#6861). The leader now holds a lease, one `continuousBackupLease` document
with its id and an
expiry. Taking it is one atomic operation, the leader renews it every 15 seconds
and it lapses after
45, so another server takes over within a minute when the leader stops, and a
server that loses the
lease stops its stream. Startup, saving the settings and a change saved on
another server converge
through that loop; a restore or a fetch from the cloud takes the lease and waits
one renewal so it
is the only writer. One server is always its own leader.
Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader, and
renewal, expiry, the old leader losing the lease and release behave.
`tests/continuousBackupLease.test.cjs`
pins the same and the wiring.
</details>
**Attachments** - what a card offers when the administrator blocked uploads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d8b3579117">A card no longer offers the attachment "+" when uploads are blocked for the whole site</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Attachments / Transfer limits already has "Attachments upload" and
"API upload" set to
Blocked, and the server refuses on every path: the upload pipeline,
`api.attachment.upload` and
`.copy`, and the `/api/attachment` upload, upload-background and copy routes.
The card still showed
the "+" button and accepted a drop or paste for an upload that would then be
refused. It now asks
the server one boolean, at most once a minute, and hides both. No second setting
was added
(\#6870 proposed a `disableAttachments`), so nothing can disagree with what the
server enforces.
`tests/attachmentUploadPolicy.test.cjs` pins the client, the method, the server
enforcement it relies
on, and that no `disableAttachments` setting exists.
The method was not imported at first, so the call failed and the "+" stayed;
[a follow-up](https://github.com/wekan/wekan/commit/3aaa46fdae) loads it, and
`tests/serverModulesLoaded.test.cjs` now passes.
</details>
**REST API** - what the integration, user, organization and team update routes
answer for something that is not there.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6012521ccc">An integration update for a missing or wrong-board integration returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
`PUT /api/boards/:boardId/integrations/:intId` answered 200 with the requested
id when no
integration matched that id and board (PR #6871). The two activities routes
(`POST` and `DELETE`
`.../activities`) updated nothing and answered 200 with `data: null`. All three
now look the
integration up on that board after board-admin authorization and answer the JSON
404 the `GET`
route already uses, before validating or writing anything.
`tests/restIntegrationWriteRoutes404.test.cjs` pins, for the whole file, that
every handler that
updates an integration looks it up first and only after authorization; the PR's
route and browser
tests stay.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d3edc56170">An update of a missing user returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
`PUT /api/users/:userId` answered 200 with no data when no user had that id,
for every action (PR #6873). After the admin check it now answers a JSON 404
`User not found` before taking ownership, disabling or enabling a login.
`tests/restUserUpdate.test.cjs` runs the handler for every action against a
missing user, and checks that authorization still comes first, that
self-disable stays a no-op and that a database failure stays a safe 500;
`tests/playwright/specs/rest-user-update.e2e.js` drives it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/4d44349ab6">A feature update of a missing organization or team returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
`PUT /api/admin/orgs/:orgId/features` and `PUT
/api/admin/teams/:teamId/features`
answered 200 with `data: undefined` when there was no such organization or team
(PR #6874). They now answer a JSON 404 `Organization not found` / `Team not
found`. `tests/restPeopleFeatures.test.cjs` runs both handlers for missing and
existing records, empty bodies, denied authorization and database failures;
`tests/playwright/specs/rest-people-features.e2e.js` checks the Admin Panel /
People feature checkboxes.
</details>
**The minicard** - who requested and who assigned a card.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/22e5df55d7">Requested by and Assigned by show when a member was picked, with one icon per field everywhere</a>. Thanks to mueschel and xet7.</summary>
The opened card's + button picks board members into `requesters` /
`assigners`, and the free-text name is optional, but the minicard drew only the
free text: a requester picked the usual way never showed although Show on
Minicard was on (#6872). The minicard now shows the picked members as avatars
beside any typed name. Card Settings also had the icons swapped - a head with a
plus for Requested by and a cart for Assigned by - and now uses the cart and
user-plus that the opened card and the minicard use.
`tests/minicardRequestedAssignedBy6872.test.cjs` pins the avatars, the
either-or condition and one icon per field, and fails on a minicard block gated
on the free text alone;
`tests/playwright/specs/minicard-requested-by-6872.e2e.js`
drives it.
</details>
and updates the following dependencies:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/dad03959d9">nodemailer 10.3.1, body-parser 1.20.9 and 2.4.0, qs 6.16.1, uuid 11.1.1 and postcss-selector-parser 6.1.5 in the bundle</a>. Thanks to the Grype scan reporter and xet7.</summary>
A Grype scan of the image found High and Medium advisories in packages that
Meteor's own packages carry inside `programs/server/npm/node_modules/meteor/`,
where `package.json` and its `overrides` cannot reach.
`releases/bump-bundle-npm-deps.mjs` raises them after `meteor build`.
- **nodemailer 8.0.11 to 10.3.1** (GHSA-2x7j-588g-ccc2, GHSA-v53p-9fqp-m79j,
GHSA-p6gq-j5cr-w38f and the rest fixed in 10.1.4 or later). It was listed as
not fixable because the fix is a new major. `meteor/email` only calls
`createTransport`, the `stream` plugin hook, `nodemailer/lib/well-known` and
`nodemailer/lib/mail-composer`, and all four were run against 10.3.1 through
the bundle's own package specifiers.
- **body-parser 1.20.6 and 1.20.8 to 1.20.9, and 2.3.0 to 2.4.0**
(GHSA-27r3-jxmh-p828). A minimum may now be written `body-parser@2`, which
applies to copies of that major only, so each major is raised inside itself.
- **qs to 6.16.1** (GHSA-6pq8-jhm2-2vcw) and **postcss-selector-parser 6.1.4 to
6.1.5** (GHSA-rj75-hqrm-r3gf).
- **uuid 8.3.2 and 9.0.1 to 11.1.1** (GHSA-w5hq-g745-h8pq). uuid 11 moved its
entry point from `dist/index.js` to `dist/cjs/index.js`, which killed the
server on boot the one time it was tried. The bumper now writes the old
`dist/index.js` back as `export * from './esm/index.js'`: it is an ES module
because uuid is `"type": "module"`, and Node 26 `require()`s it.
glob 14 and minimatch 11 were already in `package.json`; the scanned image
predates them. The runtime image installs no compiler (`gcc-14-base` is Debian's
one-file package), so the multi-stage build the report asks for is already how
it is built. Run against a copy of the published image's bundle, the bumper
raised all of the above and every raised copy loaded.
**This release's bundle did not start.** "Every raised copy loaded" was checked
by loading each package, not by booting the bundle: nodemailer 10 moved `lib/`
to `dist/cjs/`, Meteor's `email` package still links the `lib/` paths of 8.0.3,
and build-amd64 failed its smoke boot with "Cannot find module
.../meteor/email/node_modules/nodemailer/lib/nodemailer.js". The fix is in the
next release.
`tests/bundleNpmSecurityBumps.test.cjs` pins the minimums, the per-major
rule and the shim, and runs the bumper with `--dry-run` on a bundle of
body-parser, uuid and nodemailer copies at, below and above their minimums.
</details>
and has the following documentation change:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/016b4bf3bd">Only maintainer mode commits directly and updates changelogs, and a pull request has one feature or fix</a>. Thanks to xet7.</summary>
CLAUDE.md and AGENTS.md now say that a pull request contributor, to wekan/wekan
or to the maintainer's repositories under `.tools/`, sends each feature or fix
as its own pull request with only related changes in it, and changes no
changelog. The maintainer, or an agent working as the maintainer, writes the
entry after merging, when there is a commit to link.
`tests/changelogFormat.test.cjs` checks both files.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.94 2026-11-04 WeKan ® release
**In short:** On an iPad the **opened card covers the header bar** instead of
sitting
behind it, and the speed test measures where the time goes when a WeKan board is
opened, on FerretDB and on MongoDB.
This release fixes the following bugs:
**Opened cards** - where a card opens.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/efaf94242e">On a phone in desktop mode the opened card starts below the header bar</a>. Thanks to xet7.</summary>
The header wraps to several rows on a phone, far taller than the 8px the
floating card docks at, so the card's top row was among the header's rows and
was only seen by dragging it down. Below 801px a card without an inline top,
popup cards included, now starts at `--wekan-header-height` + 8px. A dragged
place is an inline top, so it is kept when the finger is lifted.
`tests/cardBelowHeaderOnPhone.test.cjs` pins the rule and that it never
matches a dragged card.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/006d24502c">The opened card covers the header bar on an iPad instead of sitting behind it</a>. Thanks to xet7.</summary>
On iPad Chrome (and Safari) the opened card's top row - the collapse caret, the
card
number, the menu, maximize and X - was behind the header bar, although the
card's
`z-index` is 2001 and the header's 1000. iOS WebKit makes every scrolling
element
(`#content`, the board wrapper) a stacking context of its own, so the card's
`z-index`
is compared only inside it, and the header bar, itself a stacking context, is
painted
over all of it. While a card is open the header bar is now at the level of the
content,
which comes after it in the page and paints over it; with no card open it keeps
its
own level. A browser spec makes the same trap with `isolation: isolate` on
`#content`
and the board wrapper and shows the card over the header, open and collapsed,
with its
close button reachable, in Chromium and WebKit; without the rule it fails as the
screenshot shows. `tests/cardOverHeaderStacking.test.cjs` pins the rule and its
selector. An iPad is the final check.
</details>
and has the following documentation change:
**Database speed test** - how fast the databases answer WeKan's queries.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/3621413d6f">The speed test measures where the time goes when a WeKan board is opened, on FerretDB and on MongoDB</a>. Thanks to Raymond-Voogt and xet7.</summary>
`docs/Databases/FerretDB/1/SpeedTest/app` opens a board of 200, 2,000 and 5,000
cards in
Chromium on WeKan with FerretDB (SQLite) and with MongoDB 8, and profiles the
Meteor
server (Node inspector) and FerretDB (pprof). Two runs each. The Meteor server
uses
0.1-0.5 s of CPU, the same on both, and its DDP server is 1% of that; the
browser gets
the same ~210 KB at every size. A 5,000-card board takes FerretDB 400-600 ms
longer to
open (first card +42-48%), a small board only 70-100 ms, and FerretDB spends
about 4 s
of CPU on it, 39% in the SQLite engine and 34% filtering in Go what it read. So
a
backend rewritten in another language would save part of a fraction of a second,
while
the part that scales with the board is the database layer. The README says what
was not
measured: a production build, the browser's own time, and which query sends the
`aggregate`. `tests/boardOpenProfile.test.cjs` pins the analysis of the profile
and what
the README claims.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.93 2026-11-04 WeKan ® release
**In short:** The database **speed test** has a new small-scale result, ordered
+34 -26
View File
@@ -5,43 +5,51 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v12.93 2026-11-04 WeKan ® release
# v13.00 2026-11-07 WeKan ® release
The database **speed test** has a new small-scale result, ordered
from fastest to slowest with how many times slower each database is, and two
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
and CockroachDB were looked at and cannot be used, for reasons the README gives.
**CreateBleed** is fixed: "Only admins can create boards" now
holds on every path that creates a board - the browser console, the REST API,
imports, copies, clones and cards - not only on the Add Board form. The **REST
API documentation** spells the name **WeKan**, so the API pages published on
wekan.fi pass its brand-spelling check again.
[All changes in v12.93](CHANGELOG-big.md#v1293-2026-11-04-wekan--release)
[All changes in v13.00](CHANGELOG-big.md#v1300-2026-11-07-wekan--release)
# v12.92 2026-11-03 WeKan ® release
# v12.99 2026-11-06 WeKan ® release
A missing **REST checklist** answers a JSON 404 instead of an
empty 500.
The **mac-arm64** release bundle builds again: its job asked for
GitHub's retired **macos-14** runner and was cancelled in the v12.98 release
run. The **weba** instructions now live in the weba repository itself.
[All changes in v12.92](CHANGELOG-big.md#v1292-2026-11-03-wekan--release)
[All changes in v12.99](CHANGELOG-big.md#v1299-2026-11-06-wekan--release)
# v12.91 2026-11-03 WeKan ® release
# v12.98 2026-11-06 WeKan ® release
A **GitHub CodeQL** finding in a test is fixed, and the guard
against
replacing text with itself now also sees patterns with a `^` or `$` anchor.
**GroupLinkBleed** is fixed: a board admin can no longer skip the
board methods by writing the board directly, to attach an organization or team
they do not belong to or to add a member past the invitation checks.
**Public board CSV and TSV exports** honour the selected columns, and the REST
**card archive, restore and custom-field** endpoints answer an unmatched card
with a JSON 404 instead of 500, and a REST **card update** can set sort 0, turn
overtime off and clear a description. The **notifications drawer** gets its
attachments again, and the developer instructions cover the **weba** Gambas
companion repository.
[All changes in v12.91](CHANGELOG-big.md#v1291-2026-11-03-wekan--release)
[All changes in v12.98](CHANGELOG-big.md#v1298-2026-11-06-wekan--release)
# v12.90 2026-11-03 WeKan ® release
# v12.97 2026-11-06 WeKan ® release
The login page can have its own **background color**, Member
Settings
has a per-member switch, **Show focus outline**, to hide the blue focus ring,
and
**Escape closes the add-list composer**.
`build.sh` and the `releases/` scripts now work on **Arch Linux**
and **Omarchy**, and the release builds no longer lose the **OpenBSD arm64
bundle** to a smoke test that left WeKan running, or the **s390x snap** to
Launchpad outages that were counted as build failures.
[All changes in v12.90](CHANGELOG-big.md#v1290-2026-11-03-wekan--release)
[All changes in v12.97](CHANGELOG-big.md#v1297-2026-11-06-wekan--release)
# v12.89 2026-11-03 WeKan ® release
# v12.96 2026-11-05 WeKan ® release
Keyboard **shortcuts run once on a non-Latin keyboard**: `d` no
longer opens the Due date popup and closes it again on a Farsi layout.
The release **bundle starts again**: v12.95's build stopped on
boot because the **nodemailer** 10 raised inside Meteor's email package no
longer had the files that package was built against.
[All changes in v12.89](CHANGELOG-big.md#v1289-2026-11-03-wekan--release)
[All changes in v12.96](CHANGELOG-big.md#v1296-2026-11-05-wekan--release)
+35 -9
View File
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
| Who | Where the work goes |
| --- | --- |
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
Nothing else is a third option. If a tool, a task runner or an agent harness offers
some other arrangement — a topic branch for the maintainer, a direct push for a
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
The contributor's half is review. Nobody but the maintainer commits to `main` in
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
the only place it can be discussed before it lands.
the only place it can be discussed before it lands. That is why each pull request
holds one feature or one fix, and leaves the changelogs to the maintainer: see
"Contributor mode" below.
Check which branch you are on before committing:
@@ -143,12 +145,29 @@ git config user.name && git config user.email
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
steps** below are available to a human maintainer only: an AI stops after
committing locally and never runs a step that pushes or publishes.
**Maintainer mode is the only mode that commits directly and the only mode that
updates changelogs**, in WeKan and in the maintainer's repositories under
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
changes of an agent working as the maintainer according to this file.
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
own — a person, or an AI that files pull requests on its own account. Then: do
**not** commit to `main` and do **not** run any release/publishing step. Make the
changes on a branch and open a **pull request** for the maintainer to review. The
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
instructions below are **maintainer-only and do not apply to you**.
**not** commit directly to the repository or to `main`, and do **not** run any
release/publishing step. Make the changes on a branch and open a **pull request**
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
`main`", changelog and release instructions below are **maintainer-only and do
not apply to you**.
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
follows two more rules:
- **One feature or one fix per pull request.** Changes share a pull request only
when they are related to that one feature or fix. Anything unrelated, however
small, goes in a pull request of its own, so each can be reviewed, merged or
declined without holding up the others.
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
entry, not TODO Later. Describe the change in the pull request instead. The
maintainer writes the entry after merging, when there is a commit for it to link.
Who that pull request credits follows the attribution rule above, and the two
cases differ:
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
cloned for reference only.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
only in maintainer mode. The rest (translation policy, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
Fixes #1234,
```
A contributor instead commits on a branch for one pull request that fixes this
one issue, with no changelog changes (see "Contributor mode" above).
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
whose message ends `Fixes #1234,`), noting where/when it was fixed.
@@ -407,6 +429,10 @@ directly after the merge.
### CHANGELOG
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
section is for the maintainer and for an agent working as the maintainer, in WeKan
and in the repositories under `.tools/`. A pull request contributor changes no
changelog in any of them (see "Contributor mode" above).
- **Every repository writes its changelog in the format that repository's own file
already uses.** Open its changelog, read the entries above the place you are
adding one, and match them — never import another project's shape into it. The
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
fault cannot exist ANYWHERE in the codebase — not just at the place it was
+2 -2
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=12.93
ARG VERSION=13.00
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
@@ -151,7 +151,7 @@ ENV \
NODE_VERSION=v26.13.0 \
METEOR_RELEASE=METEOR@3.6-rc.1 \
USE_EDGE=false \
NPM_VERSION=12.2.1 \
NPM_VERSION=12.2.2 \
SRC_PATH=./ \
WITH_API=true \
MONGO_OPLOG_URL="" \
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v12.93.0"
appVersion: "v13.00.0"
files:
userUploads:
- README.md
+35 -10
View File
@@ -1,9 +1,10 @@
#!/bin/bash
if [ -n "${ZSH_VERSION:-}" ]; then exec /bin/bash "$0" "$@"; fi
echo "Recommended for development: Newest Debian or Ubuntu amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
echo "Recommended for development: Newest Debian, Ubuntu, Fedora or Arch/Omarchy amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
echo "Note1: If you use other locale than en_US.UTF-8 , you need to additionally install en_US.UTF-8"
echo " with 'sudo dpkg-reconfigure locales' , so that MongoDB works correctly."
echo " so that MongoDB works correctly: Debian/Ubuntu 'sudo dpkg-reconfigure locales',"
echo " Arch/Omarchy: uncomment en_US.UTF-8 in /etc/locale.gen and run 'sudo locale-gen'."
echo " You can still use any other locale as your main locale."
echo "Note2: Console output is also logged to the operation-specific path printed below."
echo "Note3: All logs use .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/."
@@ -605,6 +606,14 @@ function browser_needs_docker(){
if [ "$PLATFORM_OS" = "linux" ] && [ "$PLATFORM_ARCH" = "arm64" ]; then
return 0
fi
# Playwright builds WebKit for Debian/Ubuntu only. On Arch and Omarchy its
# WebKit is linked against other ICU/libjpeg/libwebp versions than the
# rolling release has and does not launch, while Chromium and Firefox do.
# So WebKit alone goes to the Docker image there, when Docker is installed.
if [ "$browser" = webkit ] && [ "$PLATFORM_OS" = "linux" ] && \
[ "$(_et_linux_family)" = arch ] && docker_available; then
return 0
fi
return 1
}
@@ -632,8 +641,14 @@ function docker_exec(){
# Run a Playwright browser project (chromium / firefox / webkit) inside the
# official Playwright container. First arg is the browser; any extra args are
# passed through to `playwright test`. WeKan must already be running on the host
# (default http://127.0.0.1:3000, Meteor's bundled Mongo on 3001); the container
# (default http://localhost:3000, Meteor's bundled Mongo on 3001); the container
# shares the host network so it can reach both.
#
# The browser must use the test server's ROOT_URL origin, http://localhost:3000.
# Meteor-Files sends every upload to ROOT_URL + /cdn/storage/.../__upload, and
# answers no CORS preflight for another origin, so a page opened on
# http://127.0.0.1:3000 could not upload anything: the drawing specs' Save never
# finished. Health checks with curl may still use 127.0.0.1; a browser may not.
function run_playwright_docker(){
local browser="$1"; shift
local reporoot="$WEKAN_DIR"
@@ -649,7 +664,7 @@ function run_playwright_docker(){
pwver="$(node -e "console.log(require('$pwdir/node_modules/@playwright/test/package.json').version)" 2>/dev/null)"
[ -z "$pwver" ] && pwver="1.60.0"
local image="mcr.microsoft.com/playwright:v${pwver}-noble"
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
local docker_base_url="${WEKAN_BASE_URL:-http://localhost:3000}"
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
local docker_host_proxy_port=""
local docker_network="host"
@@ -798,7 +813,7 @@ function run_node_e2e_docker(){
-e HOME=/repo/.tools/tmp \
-e TMPDIR=/repo/.tools/tmp \
-e NODE_OPTIONS="${NODE_OPTIONS:-}" \
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://127.0.0.1:3000}" \
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://localhost:3000}" \
-e WEKAN_MONGO_URL="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}" \
-v "$reporoot":/repo -w /repo \
"$image" \
@@ -825,8 +840,17 @@ function install_playwright_browsers(){
browser_needs_docker "$b" || nativeList="$nativeList $b"
done
if [ -n "$nativeList" ]; then
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
if [ "$PLATFORM_OS" = "linux" ] && [ "$(_et_linux_family)" != debian ]; then
# `--with-deps` runs apt-get, which only Debian/Ubuntu have. Elsewhere
# install the libraries the browsers load with the native package
# manager, then the browsers themselves without --with-deps.
echo "Installing native Playwright browsers:$nativeList (system deps from the distribution's packages; may use sudo)."
ensure_playwright_system_deps
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install $nativeList )
else
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
fi
else
echo "All browsers are configured to run via Docker; skipping native browser install."
fi
@@ -2732,8 +2756,8 @@ RELEASE_SCRIPTS=( "Release|Release All: audit, commit, push and build with Actio
"Git and repo|Migrate a MongoDB database to FerretDB (--help first)|releases/migrate-mongodb-to-ferretdb.mjs|Arguments, e.g. --help||"
"Git and repo|Export local Git history to a new Fossil repository|releases/fossil.sh|||fossil-export"
"Git and repo|Open the local Fossil repository UI|releases/fossil-ui.sh|||fossil-ui"
"Server and VM|Enable and start the SSH server|!sudo systemctl enable ssh && sudo systemctl start ssh||linux|ssh-start"
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable ssh && sudo systemctl stop ssh||linux|ssh-stop"
"Server and VM|Enable and start the SSH server|!sudo systemctl enable \"\$(_et_ssh_unit)\" && sudo systemctl start \"\$(_et_ssh_unit)\"||linux|ssh-start"
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable \"\$(_et_ssh_unit)\" && sudo systemctl stop \"\$(_et_ssh_unit)\"||linux|ssh-stop"
"Server and VM|Enable the ufw firewall|!sudo ufw enable||linux|ufw-enable"
"Server and VM|Disable the ufw firewall|!sudo ufw disable||linux|ufw-disable"
"Server and VM|Remove the Multipass VM|!multipass stop --all && multipass delete --all && multipass purge && sudo snap remove multipass||linux|multipass-remove"
@@ -2999,7 +3023,8 @@ for _once in 1; do
sudo apk add --no-cache bash build-base git curl wget p7zip zip unzip file npm
;;
arch)
sudo pacman -Sy --needed --noconfirm base-devel git curl wget p7zip zip unzip file npm
# Arch and Omarchy: no `pacman -Sy` partial upgrade (see _et_pacman).
_et_pacman base-devel git curl wget 7zip zip unzip file npm
;;
fedora)
sudo dnf group install -y development-tools
@@ -112,9 +112,10 @@ template(name="allBoardsMultiSelectionSidebar")
a.sidebar-btn.js-archive-selected-boards
i.fa.fa-archive
span {{_ 'archive-board'}}
a.sidebar-btn.js-duplicate-selected-boards
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}
if boardCreationAllowed
a.sidebar-btn.js-duplicate-selected-boards
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}
a.sidebar-btn.js-export-selected-boards
i.fa.fa-download
span {{_ 'export-selected-boards'}}
+1 -1
View File
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
.attachment-gallery
//- #6796: a small "+" like every other section's, not a full-width box.
if canModifyCard
if canAddAttachment
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
i.fa.fa-plus
+8
View File
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
import { formatDateTime } from '/imports/lib/dateUtils';
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
});
Template.attachmentGallery.helpers({
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
canAddAttachment() {
return Utils.canModifyCard() && !attachmentUploadsBlocked();
},
// #6796: the small file button instead of the preview tile
// (models/lib/attachmentTile.cjs).
attachmentTileCompact(file) {
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
return [];
}
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
if (attachmentUploadsBlocked()) return [];
// Check if board allows attachments
const board = card.board();
if (!board || !board.allowsAttachments) {
+17
View File
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
bottom: 8px;
}
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
the board controls open), far taller than the 8px the floating window docks
at, so the card's own top row (caret, number, menu, X) sat among the header's
rows and was only seen by dragging the card down. Below 801px the card starts
BELOW the header, at the height Utils publishes as --wekan-header-height.
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
place a finger dragged it to is an inline top and is kept when the finger is
lifted - this rule never competes with it. */
@media screen and (max-width: 800px) {
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
}
}
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
with viewport insets), never laid out inside — or measured from — the
scrolled swimlane/board container. Historically the maximized pane was an
+5 -4
View File
@@ -1270,10 +1270,11 @@ template(name="cardDetailsActionsPopup")
i.fa.fa-link
| {{_ 'cardMorePopup-title'}}
unless canModifyCard
li
a.js-create-board-from-card
i.fa.fa-external-link
| {{_ 'linkCardToNewBoard'}}
if boardCreationAllowed
li
a.js-create-board-from-card
i.fa.fa-external-link
| {{_ 'linkCardToNewBoard'}}
span.copied-tooltip {{_ 'copied'}}
//- #1173: the card export popup IS the shared export body - the same checkbox
+13 -4
View File
@@ -419,18 +419,27 @@ template(name="minicard")
.minicard-location
i.fa.fa-map-marker
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
//- #6872: the + button on the opened card picks board members into
requesters / assigners; the free-text name is optional. Show both,
as the opened card does, or a picked member never reached here.
if $eq field "requestedBy"
unless $eq currentBoard.allowsRequestedByOnMinicard false
if getRequestedBy
if hasRequestedByOnMinicard
.minicard-requested-by(title="{{_ 'requested-by'}}")
i.fa.fa-shopping-cart
| {{ getRequestedBy }}
each userId in getRequesters
+userAvatar(userId=userId noRemove=true)
if getRequestedBy
span {{ getRequestedBy }}
if $eq field "assignedBy"
unless $eq currentBoard.allowsAssignedByOnMinicard false
if getAssignedBy
if hasAssignedByOnMinicard
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
i.fa.fa-user-plus
| {{ getAssignedBy }}
each userId in getAssigners
+userAvatar(userId=userId noRemove=true)
if getAssignedBy
span {{ getAssignedBy }}
if $eq field "flowtime"
if currentBoard.allowsFlowtimeOnMinicard
if isFlowActive
+7
View File
@@ -261,6 +261,13 @@ Template.minicard.helpers({
isWatching() {
return this.getRealCard().findWatcher(Meteor.userId());
},
// #6872: a picked member (requesters / assigners) or a free-text name.
hasRequestedByOnMinicard() {
return !!this.getRequestedBy() || (this.getRequesters() || []).length > 0;
},
hasAssignedByOnMinicard() {
return !!this.getAssignedBy() || (this.getAssigners() || []).length > 0;
},
showMembers() {
const board = minicardBoardOf(this);
+12
View File
@@ -20,6 +20,18 @@
background: #2573a7;
z-index: 17;
}
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
flex item of body and so a stacking context itself) is painted over all of it: the
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
BEHIND the header bar. While a card is open the header drops to the level of the
content, which comes after it in the page and so paints over it; a browser without that
trap shows no difference. */
body:has(.card-details) #header-quick-access {
z-index: 0;
}
#header #header-main-bar {
/* Flex row (was float-based) so the right button group — which ends with the
sidebar hamburger — is always pinned to the right edge at every window width,
+5
View File
@@ -725,6 +725,11 @@ a:not(.disabled).is-active i.fa {
.viewer code {
padding: 10px 12px 7px;
background: #dedede;
/* Its own text colour with its own light background: a dark theme's light
text (#bbb in the dark theme's opened card) was inherited onto #dedede,
1.42:1 (46-axe-theme-contrast). #222 on #dedede is 11.8:1, and
codeHighlight.css's token colours are chosen for this background too. */
color: #222;
overflow-y: auto;
}
.viewer a {
+20 -10
View File
@@ -123,8 +123,16 @@ Popup.template.events({
const drag = tpl._dateMove;
if (!drag || drag.pointerId !== evt.pointerId) return;
evt.preventDefault(); evt.stopPropagation();
const boxLeft = Math.max(12, Math.min(window.innerWidth - drag.width - 12, drag.left + drag.dx + evt.clientX - drag.x));
const boxTop = Math.max(12, Math.min(window.innerHeight - drag.height - 12, drag.top + drag.dy + evt.clientY - drag.y));
// The popup's size NOW, not when the drag began: a long menu gets its
// 960px column layout a frame after it is drawn (popupMenuLayout.js), so a
// drag that starts in that frame would keep clamping for the narrow size
// and push the grown popup most of the way out of the window - and undo
// the ResizeObserver that had just put it back inside.
const size = drag.element.getBoundingClientRect();
const width = size.width || drag.width;
const height = size.height || drag.height;
const boxLeft = Math.max(12, Math.min(window.innerWidth - width - 12, drag.left + drag.dx + evt.clientX - drag.x));
const boxTop = Math.max(12, Math.min(window.innerHeight - height - 12, drag.top + drag.dy + evt.clientY - drag.y));
const left = boxLeft - drag.dx;
const top = boxTop - drag.dy;
// Dragging uses physical pointer coordinates in either text direction.
@@ -230,13 +238,16 @@ Popup.template.onRendered(function () {
this._focusTrap = event => trapTabKey(event, this._popupElement);
this._popupElement?.addEventListener('keydown', this._focusTrap);
if (this._popupElement) {
const update = () => {
this._menuLayoutFrame = null;
updatePopupMenuColumns(this._popupElement);
};
this._menuLayoutObserver = new MutationObserver(() => {
if (this._menuLayoutFrame == null) this._menuLayoutFrame = requestAnimationFrame(update);
});
const update = () => updatePopupMenuColumns(this._popupElement);
// At once, not on the next animation frame: a MutationObserver callback
// already runs before the browser paints, and a frame later the popup had
// been drawn - and could be clicked or dragged - at its narrow size, then
// jumped to the 960px column layout under the pointer. A click aimed at
// Board View's "Lists" landed on "Calendar"'s place, and a drag clamped
// for the narrow size (06-views-layout, card-title-edit-and-move-6839).
// update() toggles a class only when it changes, so the attribute it
// observes settles after one more call.
this._menuLayoutObserver = new MutationObserver(update);
this._menuLayoutObserver.observe(this._popupElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
update();
// A movable popup that grows - Drawing replacing the card's menu in this
@@ -278,6 +289,5 @@ Popup.template.onRendered(function () {
Popup.template.onDestroyed(function () {
this._menuLayoutObserver?.disconnect();
this._boundsObserver?.disconnect();
if (this._menuLayoutFrame != null) cancelAnimationFrame(this._menuLayoutFrame);
this._popupElement?.removeEventListener('keydown', this._focusTrap);
});
+23
View File
@@ -0,0 +1,23 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
// #6870: whether the administrator blocked attachment uploads for the whole site
// (models/lib/attachmentUploadPolicy.js). Asked of the server once a minute at most; until it
// answers - or if it cannot - uploads are shown as before, because the server refuses them either
// way and a wrong guess here only costs a button.
const blocked = new ReactiveVar(false);
let askedAt = 0;
export function refreshUploadPolicy() {
if (Date.now() - askedAt < 60 * 1000) return;
askedAt = Date.now();
Meteor.callAsync('attachmentUploadPolicy')
.then(policy => blocked.set(!!(policy && policy.uploadsBlocked === true)))
.catch(() => { askedAt = 0; });
}
// reactive
export function attachmentUploadsBlocked() {
refreshUploadPolicy();
return blocked.get();
}
+9 -4
View File
@@ -1,12 +1,14 @@
import { Template } from 'meteor/templating';
import { ReactiveCache } from '/imports/reactiveCache';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
// #4475: Admin Panel -> Settings -> Visibility -> "Only admins can create
// boards" (tableVisibilityMode-boardCreationAdminOnly). Shared by every
// client-side "Add board" entry point so the button hides consistently;
// the real enforcement is server-side, in createBoardWithInitialSwimlanes
// (server/models/boards.js) - this only avoids showing a control that
// would be rejected.
// client-side entry point that makes a new board - Add Board, and with it
// Import and Template, Duplicate in the All Boards sidebar, and "Create
// board from card" - so they hide consistently. The real enforcement is on
// the server, on every path (CreateBleed, server/lib/boardCreationGuard.js);
// this only avoids offering a control the server would refuse.
export function boardCreationAllowed() {
const restricted = TableVisibilityModeSettings.findOne(
'tableVisibilityMode-boardCreationAdminOnly',
@@ -16,3 +18,6 @@ export function boardCreationAllowed() {
}
return !!ReactiveCache.getCurrentUser()?.isAdmin;
}
// For templates: {{#if boardCreationAllowed}}.
Template.registerHelper('boardCreationAllowed', () => boardCreationAllowed());
+22 -10
View File
@@ -20,17 +20,29 @@ only what changes: documents, files and log lines. It writes them seconds after
they happen, to a directory that can be another disk, an NFS share or an
[rclone mount](Rclone/Rclone.md).
## One server only, for now
## Several servers: one of them streams
Continuous backup is for installations with **one WeKan server**. When it is
switched on in the Admin Panel, every WeKan server that shares the database
applies the setting, so with several servers (for example the replicas of a
Kubernetes Deployment) each of them tails the oplog and writes its own archives
into the same target directory: the same backup stream, many times, into one
place. It has no leader: one server should run the stream and another take it
over when that one stops (a lease), and that is not built yet
([#6861](https://github.com/wekan/wekan/issues/6861)). With several servers, use
the database's own backup instead.
When WeKan runs as several servers on one database (for example the replicas of a Kubernetes
Deployment), **one of them runs the stream** and the others stand by
([#6861](https://github.com/wekan/wekan/issues/6861)). They coordinate through a *lease*: one
document, `continuousBackupLease`, holding the leader's id and an expiry.
- Every server with the feature switched on tries to take the lease. Taking it is one atomic
database operation, so two servers can never both lead.
- The leader renews it every 15 seconds. It lapses 45 seconds after the last renewal, so when the
leader stops (a pod restart, a rollout, a crash) another server takes over within about a minute
and continues from the position the stream saved in the target.
- A server that finds it has lost the lease stops its stream, so two never write one target.
- Saving the settings, switching the feature off and on, and a change saved on another server all
converge through the same loop: the leader follows the change on its next tick.
- A restore, and *fetch from the cloud*, must be the only writer: they take the lease from whoever
holds it and wait one renewal, so the former leader has stopped before anything is written. The
stream resumes through the loop afterwards.
An installation with one server is always its own leader, so nothing changes for it. The target
directory is still one place: with several servers it must be on storage they all reach (a shared
volume, NFS, an [rclone mount](Rclone/Rclone.md)), or the server that takes over starts a new
generation instead of continuing.
## Licences: only copyfree code by default
@@ -410,6 +410,16 @@ FerretDB's builds do not use) and SingleStore (not open source, and its image
needs a licence key). A MongoDB-protocol database needs no FerretDB and joins the
MongoDB rows; FerretDB 2 with DocumentDB is already there.
## Opening a board in WeKan
The same databases, one level up: [app/](app/) opens a WeKan board of 200, 2,000 and
5,000 cards on FerretDB (SQLite) and on MongoDB 8 and profiles both the Meteor server
and FerretDB. The Meteor server's CPU is the same on both (0.1-0.5 s); on a 5,000-card
board FerretDB (SQLite) takes **400-600 ms longer** (to the first card +42-48%), a
small board only 70-100 ms, and it uses about 4 s of CPU to open the 5,000-card board,
a third of it filtering in Go what it read; the browser receives the same ~210 KB at
every size.
## Go databases linked into FerretDB
Could a database implemented in Go be linked into the FerretDB binary beside its
@@ -0,0 +1,108 @@
# Opening a WeKan board: where the time goes
The [speed test](../) asks the database alone. This asks what a person waits for: the
whole of opening a board in WeKan, with WeKan on FerretDB (SQLite) and on MongoDB 8,
and how much of it is the database. [board-open.cjs](board-open.cjs) seeds a board of
N cards, logs in, opens it in Chromium and records
- the wall clock to the first minicard and until the DDP WebSocket has been quiet for
1.5 s (**settled**), and what crossed the WebSocket;
- the Meteor server's CPU, by who spends it (a Node inspector profile of the server);
- FerretDB's CPU (its pprof), against what it spends with nobody in the browser.
```sh
# WeKan on FerretDB: MONGO_URL=mongodb://127.0.0.1:27040/wekan?directConnection=true,
# DEFAULT_METEOR_REACTIVITY_ORDER=polling (as WeKan is deployed), meteor run --inspect=9229,
# and FerretDB with --debug-addr=127.0.0.1:8098
WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000,5000 \
--inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
```
Run on 2026-11-03/04, Apple M2 Max, **the development build of Meteor** (so the browser's
own part is larger than in production), one user, `polling` reactivity, FerretDB v1.105.0
with the SQLite membership-test change, MongoDB 8.0 in Docker. Each board is opened
twice (the second is warm), and the whole measurement was run **twice** (run 1 / run 2
below), because one run is not enough: the same board's "settled" time moved by up to
300 ms between runs.
## Result: the database is not what you wait for on a small board, and is on a big one
Milliseconds, first open of a board with this many cards (run 1 / run 2). WeKan loads
cards lazily, so the browser gets **the same ~210 KB and draws 70-120 minicards at every
size**.
| Cards | FerretDB (SQLite): first card | settled | MongoDB 8: first card | settled |
| ---: | ---: | ---: | ---: | ---: |
| 200 | 750 / 764 | 966 / 880 | 752 / 698 | 865 / 813 |
| 2,000 | 931 / 1,031 | 1,763 / 1,555 | 829 / 856 | 1,457 / 1,582 |
| 5,000 | 1,331 / 1,334 | 2,166 / 2,065 | 938 / 899 | 1,562 / 1,622 |
What is the same in both runs:
- **5,000 cards: FerretDB is 400-600 ms slower**, to the first card by 42-48% and until
settled by 27-39%.
- **2,000 cards: within the noise** for "settled" (-30 to +300 ms), and 100-175 ms
slower to the first card.
- **200 cards: 70-100 ms**, which is 9-12%.
The warm second open of run 2: 5,000 cards 1,321 against 865 ms to the first card
and 1,946 against 1,594 ms settled; 2,000 cards 904 against 824 and 1,628 against
1,446; 200 cards 721 against 689 and 934 against 805.
The Meteor server's CPU is the same on both databases within the noise (first open, run
1 / run 2: 102 / 119, 276 / 421 and 470 / 517 ms on FerretDB; 178 / 93, 245 / 342 and
521 / 483 ms on MongoDB), so the difference is **time spent waiting for the database**,
and it grows with the size of the board while the data the browser receives does not.
For a board of a few hundred cards, 0.7-0.75 s to the first card is nearly the same on
both: that is the application (the development bundle, Blaze, the DDP round trips), not
the database.
## Where the Meteor server spends its CPU (5,000 cards, first open, run 2)
| | FerretDB (517 ms) | MongoDB 8 (483 ms) |
| --- | ---: | ---: |
| Garbage collector | 31% | 24% |
| MongoDB driver (wire, decode, encode) | 19% | 22% |
| Meteor mongo (observers, polling, diff) | 12% | 13% |
| Node internals | 11% | 12% |
| WeKan code | 11% | 10% |
| Other Meteor packages and dependencies | 8% | 9% |
| Native, EJSON, DDP server (publish, mergebox) | 8% | 10% |
The DDP server itself, publish and mergebox, is 1%. The largest single functions are
the garbage collector, `CollectionDocument` (WeKan's model class), `replaceTypes`
(Meteor's BSON-to-EJSON step) and the BSON decoder.
## Where FerretDB spends its CPU
With nobody in the browser FerretDB uses 110-510 ms of CPU in the 14 s window the
profile covers. Opening a board adds, net: about nothing at 200 cards, **about 1.6 s at
2,000 and about 3.9-4.1 s at 5,000** - eight times the Meteor server's CPU for the same
open. For the 5,000-card board, of 4.41 s (run 2):
- the SQLite engine, 1.72 s (39%), a good part of it moving through the pages of the
cards table (`btreeInitPage` 18% by itself);
- 34% is `filterIterator.Next`: documents read from SQLite and filtered again in Go;
- by MongoDB command: `find` 0.79 s, `getMore` 0.74 s, `aggregate` 0.68 s (a `$group`
done in Go over the documents it read);
- the Go garbage collector, 0.57 s (13%).
Which WeKan query sends the `aggregate` during the open was **not identified**; the
debug log of a 2,000-card open contains none, so the 5,000-card profile may have
included the All Boards page's card counters (`getAllBoardsTileData`, a `$group` over
the user's boards) that the login lands on first. Treat that line as a lead.
## What this does and does not say
- It says the database layer is the part of opening a board that **scales with the board
size**, and that on FerretDB (SQLite) a 5,000-card board takes 400-600 ms longer than on
MongoDB, with the same data sent to the browser.
- It says the **Meteor server is not the bottleneck**: 0.1-0.5 s of CPU, the same on both
databases, and the DDP server is about 1% of it. A backend rewritten in another language
could save a part of that, and would not change the database or the browser.
- It does not separate the browser's own time from the wait for the server: the first
card at 0.7 s on a small board is mostly the development bundle and rendering.
- Single machine, the development build, one user, two runs, two opens per board, `settled`
meaning 1.5 s without a DDP frame. A production build and a loaded server would differ.
@@ -0,0 +1,188 @@
'use strict';
// Where the time goes when a WeKan board is opened, with WeKan running on a database
// (FerretDB on SQLite, or MongoDB): the browser's wall clock, what crosses the DDP
// WebSocket, the Meteor server's CPU by who spends it (Node inspector profile), and
// FerretDB's CPU (its pprof). The speed test asks the database alone; this asks whether
// the database is what a person waits for.
//
// # WeKan on FerretDB: meteor run --port 3310 --inspect=9229 with MONGO_URL set,
// # FerretDB with --debug-addr=127.0.0.1:8098
// WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
// node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000 \
// --inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
//
// Dev servers: the Meteor build is the development one, so the client's own time is
// larger than in production; the comparison between databases is what it is for.
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '../../../../../..');
const pw = require(path.join(ROOT, 'tests/playwright/node_modules/playwright-core'));
const { MongoClient } = require(path.join(ROOT, 'tests/playwright/node_modules/mongodb'));
const WebSocket = require(path.join(ROOT, 'node_modules/ws'));
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASE = process.env.WEKAN_BASE_URL || 'http://localhost:3310';
process.env.WEKAN_BASE_URL = BASE;
const MONGO = process.env.WEKAN_MONGO_URL || 'mongodb://127.0.0.1:27040/wekan';
process.env.WEKAN_MONGO_URL = MONGO;
const db = require(path.join(ROOT, 'tests/playwright/helpers/db.js'));
const { loginWithToken } = require(path.join(ROOT, 'tests/playwright/helpers/auth.js'));
const SIZES = arg('sizes', '200,2000').split(',').map(Number);
const INSPECT = arg('inspect', '');
const PPROF = arg('ferret-pprof', '');
const LABEL = arg('label', 'run');
const GO = arg('go', path.join(ROOT, '.tools/go/bin/go'));
const FERRET_BIN = arg('ferret-bin', path.join(ROOT, '.tools/FerretDB/bin/ferretdb'));
const QUIET_MS = 1500;
// ── seeding ─────────────────────────────────────────────────────────────────
async function seedBoard(user, size) {
const board = db.seedBoard({ ownerId: user.id, title: `Open ${size}`, listCount: 6, cardTitlesPerList: [['Template']] });
const template = db.findOne('cards', { boardId: board.boardId, title: 'Template' });
const client = await new MongoClient(MONGO).connect();
try {
const cards = client.db().collection('cards');
const docs = [];
for (let i = 0; i < size; i += 1) {
docs.push({
...template,
_id: `card${board.boardId}x${i}`,
title: `Card ${i} ${['plan', 'build', 'review', 'ship'][i % 4]} the thing`,
listId: board.listIds[i % board.listIds.length],
sort: Math.floor(i / board.listIds.length) * 100,
cardNumber: i + 2,
createdAt: new Date(), modifiedAt: new Date(), dateLastActivity: new Date(),
});
}
for (let i = 0; i < docs.length; i += 1000) await cards.insertMany(docs.slice(i, i + 1000));
} finally {
await client.close();
}
return board;
}
// ── the Meteor server's CPU, from the Node inspector ────────────────────────
async function inspector() {
const list = await (await fetch(`http://127.0.0.1:${INSPECT}/json`)).json();
const ws = new WebSocket(list[0].webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
let id = 0;
const pending = new Map();
ws.on('message', (m) => {
const msg = JSON.parse(m);
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
});
const send = (method, params = {}) => new Promise((resolve) => { id += 1; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); });
await send('Profiler.enable');
await send('Profiler.setSamplingInterval', { interval: 250 });
return {
start: () => send('Profiler.start'),
stop: async () => (await send('Profiler.stop')).result.profile,
close: () => ws.close(),
};
}
const { analyse } = require('./profileAnalysis.cjs');
// ── FerretDB's CPU, from its pprof ──────────────────────────────────────────
async function ferretProfile(seconds) {
if (!PPROF) return null;
const res = await fetch(`${PPROF}/debug/pprof/profile?seconds=${seconds}`);
const file = path.join(process.env.TMPDIR || '/tmp', `ferret-open-${Date.now()}.prof`);
require('fs').writeFileSync(file, Buffer.from(await res.arrayBuffer()));
const env = { ...process.env, HOME: path.join(ROOT, '.tools') };
const top = execFileSync(GO, ['tool', 'pprof', '-top', '-nodecount=80', FERRET_BIN, file], { encoding: 'utf8', env });
const total = /Total samples = ([\d.]+)(m?s)/.exec(top);
const cpuMs = total ? Number(total[1]) * (total[2] === 's' ? 1000 : 1) : null;
// A line reads: flat flat% sum% cum cum% name - SQLite's share is the cumulative time of its VM.
const line = top.split('\n').find((l) => /_sqlite3VdbeExec/.test(l));
const sqlite = line ? /([\d.]+)(m?s)\s+[\d.]+%\s+[\d.]+%\s+([\d.]+)(m?s)\s+[\d.]+%/.exec(line) : null;
// The cumulative top, for the first open: who in FerretDB spends it.
const cum = execFileSync(GO, ['tool', 'pprof', '-top', '-cum', '-nodecount=34', FERRET_BIN, file], { encoding: 'utf8', env })
.split('\n').filter((l) => /^\s+[\d.]+m?s/.test(l)).map((l) => l.trim().replace(/\s+/g, ' ')).slice(0, 34);
return { cum, cpuMs, sqliteMs: sqlite ? Number(sqlite[3]) * (sqlite[4] === 's' ? 1000 : 1) : null };
}
// ── one board open ──────────────────────────────────────────────────────────
async function openBoard(user, board, size) {
const browser = await pw.chromium.launch();
const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } });
const page = await context.newPage();
await page.addInitScript(() => { try { localStorage.setItem('wekan-mobile-mode', 'false'); } catch (e) { /* none */ } });
let frames = 0; let bytes = 0; let last = Date.now();
page.on('websocket', (ws) => ws.on('framereceived', (e) => { frames += 1; bytes += e.payload.length; last = Date.now(); }));
await loginWithToken(page, user.id, user.token);
await page.waitForTimeout(1500);
const insp = INSPECT ? await inspector() : null;
frames = 0; bytes = 0;
const ferret = PPROF ? ferretProfile(14) : Promise.resolve(null);
if (insp) await insp.start();
const t0 = Date.now();
await page.goto(`${BASE}/b/${board.boardId}/${board.slug}`, { waitUntil: 'commit' });
let tFirst = null;
await page.waitForSelector('.minicard', { timeout: 60000 }).then(() => { tFirst = Date.now() - t0; });
// quiet: no DDP frame for QUIET_MS
for (;;) {
if (Date.now() - last > QUIET_MS && Date.now() - t0 > (tFirst || 0)) break;
if (Date.now() - t0 > 60000) break;
await page.waitForTimeout(100);
}
const tQuiet = Date.now() - t0 - QUIET_MS;
const profile = insp ? await insp.stop() : null;
const minicards = await page.locator('.minicard').count();
const ferretResult = await ferret;
if (insp) insp.close();
await browser.close();
return { size, tFirst, tQuiet, frames, kb: Math.round(bytes / 1024), minicards, server: profile ? analyse(profile) : null, ferret: ferretResult };
}
const f = (v) => (v === null || v === undefined ? '-' : Math.round(v).toLocaleString('en-US'));
async function main() {
const user = db.seedUser();
const results = [];
// What FerretDB spends in the same 14 s with nobody in the browser: the baseline.
const idle = PPROF ? await ferretProfile(14) : null;
if (idle) console.error(`FerretDB idle for 14 s: ${f(idle.cpuMs)} ms of CPU`);
for (const size of SIZES) {
console.error(`seeding a board of ${size} cards...`);
const board = await seedBoard(user, size);
// The first open of a board is the one a person waits for; a second shows a warm server.
for (const pass of ['first open', 'second open']) {
console.error(`opening ${size} (${pass})...`);
const r = await openBoard(user, board, size);
r.pass = pass;
results.push(r);
}
}
console.log(`\n## ${LABEL}: opening a board\n`);
if (idle) console.log(`FerretDB with nobody in the browser: ${f(idle.cpuMs)} ms of CPU in the same 14 s window (subtract it).\n`);
console.log('| Cards | Open | First card (ms) | Settled (ms) | DDP frames | DDP KB | Minicards drawn | Meteor server busy (ms) | FerretDB CPU (ms) | of it SQLite (ms) |');
console.log('| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
for (const r of results) {
console.log(`| ${r.size} | ${r.pass} | ${f(r.tFirst)} | ${f(r.tQuiet)} | ${f(r.frames)} | ${f(r.kb)} | ${f(r.minicards)} | ${f(r.server && r.server.busyMs)} | ${f(r.ferret && r.ferret.cpuMs)} | ${f(r.ferret && r.ferret.sqliteMs)} |`);
}
for (const r of results.filter((x) => x.pass === 'first open' && x.server)) {
console.log(`\n### The Meteor server, ${r.size} cards, first open: ${f(r.server.busyMs)} ms of CPU\n`);
for (const [c, ms] of r.server.categories) console.log(`- ${c}: ${f(ms)} ms (${Math.round((100 * ms) / r.server.busyMs)}%)`);
if (r.ferret && r.ferret.cum) {
console.log(`\n### FerretDB, ${r.size} cards, first open: cumulative CPU by function (flat flat% sum% cum cum% name)\n`);
console.log('```');
r.ferret.cum.forEach((l) => console.log(l));
console.log('```');
}
console.log('\nTop functions:');
for (const [fn, ms] of r.server.topFunctions) console.log(`- ${f(ms)} ms ${fn}`);
}
process.exit(0);
}
if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,46 @@
'use strict';
// A Node inspector CPU profile of the Meteor server, by who spends the time. Apart from
// the browser and the database so a test can drive it with a made-up profile.
function categorise(node) {
const { functionName, url } = node.callFrame;
if (functionName === '(idle)') return 'idle';
if (functionName === '(garbage collector)') return 'garbage collector';
if (functionName === '(program)') return 'native (program)';
if (/\/mongodb\/|npm-mongo|\/bson\//.test(url)) return 'MongoDB driver (wire, decode, encode)';
if (/packages\/ddp-server|packages\/ddp-common|packages\/ddp\b/.test(url)) return 'Meteor DDP (publish, mergebox, sessions)';
if (/packages\/mongo|minimongo|diff-sequence/.test(url)) return 'Meteor mongo (observe, polling, diff)';
if (/packages\/ejson/.test(url)) return 'EJSON';
// Other Meteor packages and dependencies are not WeKan's: their paths contain /server/ too.
if (/programs\/server\/packages|node_modules/.test(url)) return 'other packages';
if (/programs\/server\/app|\/_build\/|\/models\/|\/server\/|\/imports\/|wekan/.test(url)) return 'WeKan code';
if (/^node:/.test(url) || url === '') return 'node internals';
return 'other packages';
}
function analyse(profile) {
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const cats = new Map();
const fns = new Map();
let total = 0;
profile.samples.forEach((sid, i) => {
const dt = (profile.timeDeltas[i + 1] ?? profile.timeDeltas[i] ?? 0) / 1000; // ms
const node = byId.get(sid);
const cat = categorise(node);
cats.set(cat, (cats.get(cat) || 0) + dt);
total += dt;
if (cat !== 'idle') {
const key = `${node.callFrame.functionName || '(anonymous)'} ${node.callFrame.url.split('/').slice(-2).join('/')}`;
fns.set(key, (fns.get(key) || 0) + dt);
}
});
const idle = cats.get('idle') || 0;
return {
totalMs: total, busyMs: total - idle,
categories: [...cats.entries()].filter(([c]) => c !== 'idle').sort((a, b) => b[1] - a[1]),
topFunctions: [...fns.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8),
};
}
module.exports = { categorise, analyse };
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-12.93-win64.zip](https://github.com/wekan/wekan/releases/download/v12.93/wekan-12.93-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-13.00-win64.zip](https://github.com/wekan/wekan/releases/download/v13.00/wekan-13.00-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-12.93-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-13.00-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+21 -6
View File
@@ -2300,6 +2300,22 @@ Cards.helpers({
}
},
// Adds a finished session to the total, where the total is kept: the linked
// board, the linked card, or this card. An $inc, not read-and-$set: the
// client's copy of a linked board or card can still be on its way (it is
// published after the card that links to it), and getSpentTime() then reads
// null - a Stop at that moment replaced a board's 4 logged hours with the 1
// hour of the session. The server adds to what it has, whatever the client
// has seen.
addSpentTime(hours) {
const amount = Number(hours);
if (!Number.isFinite(amount) || amount <= 0) return null;
if (this.isLinkedBoard()) {
return Boards.updateAsync({ _id: this.linkedId }, { $inc: { spentTime: amount } });
}
return Cards.updateAsync({ _id: this.getRealId() }, { $inc: { spentTime: amount } });
},
// Flowtime (#3919) session helpers. These mirror the shape of
// setSpentTime()/setIsOvertime() above, but do not follow linked
// cards/boards - a Flowtime session belongs to one person working on one
@@ -2341,8 +2357,7 @@ Cards.helpers({
if (!this.flowStartAt) return null;
const elapsedHours =
(Date.now() - new Date(this.flowStartAt).getTime()) / (1000 * 60 * 60);
const newSpentTime = (this.getSpentTime() || 0) + elapsedHours;
await this.setSpentTime(newSpentTime);
await this.addSpentTime(elapsedHours);
return Cards.updateAsync(
{ _id: this._id },
{ $set: { flowStartAt: null, flowInterruptions: 0, flowUserId: null } },
@@ -2403,13 +2418,13 @@ Cards.helpers({
},
// Called when a work interval's countdown reaches zero: adds the interval
// duration (in hours) into the existing spentTime field via setSpentTime()
// - the same method the manual time-entry popup uses - increments the
// duration (in hours) into the existing spentTime field - the one the
// manual time-entry popup edits - with addSpentTime(), increments the
// completed-interval count, and switches to a break interval.
async completePomodoroWorkInterval() {
if (!this.pomodoroStartAt || this.pomodoroPhase !== 'work') return null;
const workHours = this.getPomodoroWorkMinutes() / 60;
await this.setSpentTime((this.getSpentTime() || 0) + workHours);
await this.addSpentTime(workHours);
const newCount = this.getPomodoroCount() + 1;
return Cards.updateAsync(
{ _id: this._id },
@@ -2443,7 +2458,7 @@ Cards.helpers({
const elapsedHours =
(Date.now() - new Date(this.pomodoroStartAt).getTime()) /
(1000 * 60 * 60);
await this.setSpentTime((this.getSpentTime() || 0) + elapsedHours);
await this.addSpentTime(elapsedHours);
}
return Cards.updateAsync(
{ _id: this._id },
+7 -6
View File
@@ -569,10 +569,15 @@ if (Meteor.isServer) {
return;
}
// The same column selection applies to public and private boards.
const exportOptions = {
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
};
// If board is public, skip expensive authentication operations
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporter = new Exporter(boardId);
const exporter = new Exporter(boardId, undefined, exportOptions);
if( req.query.delimiter == "\t" ) {
// TSV file
@@ -632,11 +637,7 @@ if (Meteor.isServer) {
});
}
// #1173: the same selection every other board export honours - on a CSV it
// lands on COLUMNS, since that is what a CSV has.
const exporter = new Exporter(boardId, undefined, {
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
});
const exporter = new Exporter(boardId, undefined, exportOptions);
if (await exporter.canExport(user)) {
if (impersonateDone) {
let exportType = 'exportCSV';
+2
View File
@@ -76,6 +76,7 @@ runOnServer(function() {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -96,6 +97,7 @@ runOnServer(function() {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
+4
View File
@@ -100,6 +100,7 @@ runOnServer(function() {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -129,6 +130,7 @@ runOnServer(function() {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
@@ -199,6 +201,7 @@ runOnServer(function() {
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -218,6 +221,7 @@ runOnServer(function() {
const loginToken = req.query && req.query.authToken;
if (loginToken) {
if (loginToken.length > 10000) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
+13
View File
@@ -61,6 +61,14 @@ function sanitizeImported(value, source, invocation) {
// have their board replaced; for anyone else the import still runs and simply
// leaves that board alone. No Problems record: a member who is not an admin
// reaches this by importing from a board page, which is ordinary use.
// CreateBleed (GHSA-qcfm-2g4w-wqcc): this file is shared with the client, and
// the guard reads server-only settings, so it is required on the server only.
async function assertMayCreateBoardOnServer(userId, source) {
if (!Meteor.isServer) return;
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
await assertMayCreateBoard(userId, { source, type: 'board' });
}
async function replaceableBoardId(userId, boardId) {
if (!boardId || !userId) return undefined;
const board = await ReactiveCache.getBoard(boardId);
@@ -98,6 +106,9 @@ Meteor.methods({
}
// Admin Panel / Features / Security: master switch to disable all import.
await assertImportEnabled();
// CreateBleed: every import makes a new board - the UI's, and POST
// /api/boards/import and /api/boards/import/:source, which call this.
await assertMayCreateBoardOnServer(this.userId, 'method:importBoard');
try { validateImportSourceShape(importSource, board); }
catch (error) { throw new Meteor.Error('invalid-import-format', error.message); }
let creator;
@@ -675,6 +686,8 @@ Meteor.methods({
if (!this.userId) {
throw new Meteor.Error('error-notAuthorized');
}
// CreateBleed: a clone is a new board.
await assertMayCreateBoardOnServer(this.userId, 'method:cloneBoard');
// The board's export without its file data: the attachments' rows only.
// Their files are streamed from the source board's own storage into the
// copy as the importer reaches them, one at a time, instead of every file
+25
View File
@@ -0,0 +1,25 @@
'use strict';
// Whether this installation lets people upload attachments (#6870).
//
// The switch already exists: Admin Panel / Attachments / Transfer limits has "Attachments upload"
// and "API upload" set to Blocked (limitSettings.attachmentsUploadBlocked / apiUploadBlocked).
// The server refuses an upload on every path when it is on - the upload pipeline
// (models/attachments.server.js) and the API methods and routes (server/attachmentApi.js,
// server/routes/attachmentApi.js). What was missing is that the card still offered the "+" button
// and a drop zone for an upload that would be refused. This is the one decision the server
// method that tells the client and the tests share, so there is no second setting that could
// disagree with the one the server enforces.
//
// Only the boolean leaves the server: the rest of the limits (sizes, storage paths) are for
// administrators.
function uploadPolicy(limitSettings) {
const limits = limitSettings && typeof limitSettings === 'object' ? limitSettings : {};
return {
// anything but a real `true` keeps uploads as they were
uploadsBlocked: limits.attachmentsUploadBlocked === true,
};
}
module.exports = { uploadPolicy };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Which server streams the continuous backup (#6861, item 3).
//
// The continuous backup tails the oplog and writes archives into one target directory. With several
// WeKan servers on one database - the replicas of a Kubernetes Deployment - every one of them applied
// the setting, so the same stream was written many times into one place. It needs a leader: one
// server runs the stream, and another takes over when it stops.
//
// The leader holds a LEASE: one document, `continuousBackupLease/continuous-backup`, with the
// holder's id and an expiry. Every decision is ONE database operation, so there is no gap between
// "is it free" and "it is mine" for a second server to slip through (the pattern of
// models/lib/dailyClaim.js and dueScanClaim.js):
// - acquireLease: an insert while there is no lease at all (a duplicate key says someone has it),
// else an update whose filter matches only a lease that is unheld, already ours, or expired;
// - the holder renews it every RENEW_MS, and it lapses TTL_MS after the last renewal, so a leader
// that crashed, was rolled out or lost its database for a minute is replaced by the next server
// to look - without waiting for anybody to notice.
// No upsert on purpose: an upsert whose filter does not match has to insert, and what a database
// does then (a duplicate key, or a second document) is the very thing the lease must not depend on.
//
// `collection` is a MongoDB driver collection (insertOne, updateOne).
const LEASE_ID = 'continuous-backup';
const RENEW_MS = 15 * 1000;
const TTL_MS = 45 * 1000;
const isDuplicateKey = error => !!error && (error.code === 11000 || /E11000|duplicate key/i.test(String(error.message || '')));
// Resolves true when `holder` leads from now until the lease expires.
async function acquireLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
const expiresAt = new Date(now.getTime() + ttlMs);
try {
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
return true;
} catch (error) {
if (!isDuplicateKey(error)) throw error;
}
const result = await collection.updateOne(
{ _id: LEASE_ID, $or: [{ holder }, { holder: null }, { expiresAt: { $lte: now } }] },
{ $set: { holder, expiresAt } },
);
return (result.matchedCount || 0) > 0 || (result.modifiedCount || 0) > 0;
}
// Gives the lease up when this server is the holder (a clean stop, or the feature switched off), so
// the next server does not have to wait for it to expire.
async function releaseLease(collection, holder) {
await collection.updateOne({ _id: LEASE_ID, holder }, { $set: { holder: null, expiresAt: new Date(0) } });
}
// Takes the lease whoever holds it. For a restore, which must be the only writer: the former leader
// finds at its next renewal that it lost the lease and stops, so the caller then waits RENEW_MS
// before it writes anything (see waitMs).
async function takeoverLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
const expiresAt = new Date(now.getTime() + ttlMs);
try {
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
return { waitMs: 0 };
} catch (error) {
if (!isDuplicateKey(error)) throw error;
}
const result = await collection.updateOne({ _id: LEASE_ID }, { $set: { holder, expiresAt } });
// taking a lease this server already held needs no wait: nobody else was streaming
return { waitMs: RENEW_MS, matched: (result.matchedCount || 0) > 0 };
}
module.exports = { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease };
+35
View File
@@ -0,0 +1,35 @@
// CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: who may create a board while
// Admin Panel -> Settings -> Visibility -> "Only admins can create boards"
// (tableVisibilityMode-boardCreationAdminOnly) is on.
//
// The decision, as arithmetic: no server, no database. Every path that makes a
// new WORKING board asks it through server/lib/boardCreationGuard.js -
// createBoardWithInitialSwimlanes, a client Boards.insert, POST /api/boards,
// importBoard (the UI's and both REST import routes), cloneBoard, copyBoard
// and createBoardFromCard. Only the first of them used to read the setting.
//
// A board TEMPLATE (type 'template-board') is not a working board: a member
// keeps their Templates board, and making a working board from a template is
// copyBoard with type 'board', which this refuses. Boards WeKan makes itself -
// a user's Templates container, a board's default subtasks board - are not
// "created by the user" and never ask.
const TEMPLATE_TYPES = new Set(['template-board']);
// The type a new board will have; none means a working board, as the schema
// defaults it.
function boardTypeOf(type) {
return typeof type === 'string' && type ? type : 'board';
}
// May this user create a board of this type?
// adminOnly: the setting's booleanValue
// isAdmin: the user's isAdmin (only `true` counts)
// type: the new board's type
function boardCreationAllowed({ adminOnly, isAdmin, type } = {}) {
if (adminOnly !== true) return true;
if (isAdmin === true) return true;
return TEMPLATE_TYPES.has(boardTypeOf(type));
}
module.exports = { boardCreationAllowed, boardTypeOf, TEMPLATE_TYPES };
+72
View File
@@ -0,0 +1,72 @@
'use strict';
// GroupLinkBleed (GHSA-v544-rmr5-38gx): which client writes to a board skip a
// check that only a Meteor method makes. Used by the Boards.deny rules in
// server/permissions/boards.js. Pure: tests/groupLinkBleed.test.cjs.
// orgs, teams and domains are written only by setBoardOrgs, setBoardTeams and
// setBoardDomains (#5141 own groups only, #5850 domain validation).
const GROUP_LINK_FIELDS = ['orgs', 'teams', 'domains'];
function isGroupLinkField(field) {
if (typeof field !== 'string') return false;
return GROUP_LINK_FIELDS.some(f => field === f || field.startsWith(`${f}.`));
}
// A client board insert carrying any group link. The UI creates a board with
// none and adds them through the methods afterwards.
function groupLinksInInsert(doc) {
return GROUP_LINK_FIELDS.some(f => doc && Array.isArray(doc[f]) && doc[f].length > 0);
}
// Members may be changed from the client (role changes, removal), never added:
// adding goes through inviteUserToBoard. Allowed shapes:
// $set members.<i>.<field> for an existing index, not changing userId
// $set members.<i> an entry keeping the same userId
// $set members an array whose userIds were all on the board
// $unset, $pull, $pullAll, $pop (remove only)
// Everything else that touches members - $push, $addToSet, $setOnInsert, an
// index past the end, a positional or non-numeric index, a new userId - adds.
// So does switching a removed (inactive) member back on: removal keeps the
// entry with isActive false, and bringing it back is an invitation too.
const MEMBER_SAFE_OPS = ['$set', '$unset', '$pull', '$pullAll', '$pop'];
const isMembersPath = p => p === 'members' || p.startsWith('members.');
function addsBoardMember(doc, modifier) {
const current = (doc && Array.isArray(doc.members)) ? doc.members : [];
const ids = new Set(current.map(m => m && m.userId).filter(Boolean));
const inactive = new Set(current.filter(m => m && m.userId && m.isActive !== true).map(m => m.userId));
const reactivates = m => m && m.isActive === true && inactive.has(m.userId);
for (const [op, args] of Object.entries(modifier || {})) {
if (!args || typeof args !== 'object') continue;
if (op === '$rename') {
if (Object.keys(args).some(isMembersPath) || Object.values(args).some(v => typeof v === 'string' && isMembersPath(v))) return true;
continue;
}
const paths = Object.keys(args).filter(isMembersPath);
if (!paths.length) continue;
if (!MEMBER_SAFE_OPS.includes(op)) return true;
if (op !== '$set') continue;
for (const path of paths) {
const value = args[path];
const parts = path.split('.');
if (parts.length === 1) {
if (!Array.isArray(value) || value.some(m => !m || !ids.has(m.userId) || reactivates(m))) return true;
continue;
}
if (!/^\d+$/.test(parts[1])) return true;
const i = Number(parts[1]);
if (i >= current.length) return true;
const existing = current[i] && current[i].userId;
if (parts.length === 2) {
if (!value || typeof value !== 'object' || value.userId !== existing || reactivates(value)) return true;
continue;
}
if (parts[2] === 'userId') return true;
if (parts.length === 3 && parts[2] === 'isActive' && value === true && inactive.has(existing)) return true;
}
}
return false;
}
module.exports = { GROUP_LINK_FIELDS, isGroupLinkField, groupLinksInInsert, addsBoardMember };
+2 -2
View File
@@ -100,10 +100,10 @@ const CARD_SETTINGS_ROWS = [
{ key: 'creator', icons: ['fa-user'], label: ['creator'],
card: { toggle: 'js-field-has-creator', field: 'allowsCreator' },
minicard: { toggle: 'js-field-has-creator-on-minicard', field: 'allowsCreatorOnMinicard' } },
{ key: 'requestedBy', icons: ['fa-user', 'fa-plus'], label: ['requested-by'],
{ key: 'requestedBy', icons: ['fa-shopping-cart'], label: ['requested-by'],
card: { toggle: 'js-field-has-requested-by', field: 'allowsRequestedBy' },
minicard: { toggle: 'js-board-card-flag', field: 'allowsRequestedByOnMinicard', flag: true, default: true } },
{ key: 'assignedBy', icons: ['fa-shopping-cart'], label: ['assigned-by'],
{ key: 'assignedBy', icons: ['fa-user-plus'], label: ['assigned-by'],
card: { toggle: 'js-field-has-assigned-by', field: 'allowsAssignedBy' },
minicard: { toggle: 'js-board-card-flag', field: 'allowsAssignedByOnMinicard', flag: true, default: true } },
+13
View File
@@ -96,6 +96,19 @@ const CATALOG = {
'ssrf.webhook-forge': { category: 'ssrf', bleed: 'HookBleed', severity: 'high', cwe: 'CWE-345' },
'authz.board-owner': { category: 'authz', bleed: 'OwnerBleed', severity: 'high', cwe: 'CWE-639' },
'authz.background': { category: 'authz', bleed: 'BackgroundBleed', severity: 'high', cwe: 'CWE-639' },
// GroupLinkBleed (GHSA-v544-rmr5-38gx): a client board write naming orgs,
// teams or domains, which the UI changes only through the board methods.
'authz.group-link': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
// GroupLinkBleed, member variant: a client board update adding a member
// instead of going through inviteUserToBoard.
'authz.member-add': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
// CreateBleed (GHSA-qcfm-2g4w-wqcc): a non-admin creating a working board
// while "Only admins can create boards" is on, on a path other than the
// Add Board form - a client Boards.insert, POST /api/boards, an import, a
// clone, a copy or a card turned into a board. blocksAccount: false - a
// browser tab opened before the admin switched it on still shows the
// buttons, and that member is no attacker.
'authz.board-creation': { category: 'authz', bleed: 'CreateBleed', severity: 'low', cwe: 'CWE-863', blocksAccount: false },
'injection.prototype': { category: 'injection', bleed: 'PrototypeBleed', severity: 'high', cwe: 'CWE-1321' },
'authz.notification-tray': { category: 'authz', bleed: 'TrayBleed', severity: 'high', cwe: 'CWE-639' },
'authn.cas-state': { category: 'authn', bleed: 'CasTokenBleed', severity: 'high', cwe: 'CWE-352' },
+1 -1
View File
@@ -86,7 +86,7 @@ export function normalizeListColor(color) {
}
/**
* A list (column) in the Wekan board.
* A list (column) in the WeKan board.
*/
Lists.attachSchema(
new SimpleSchema({
+1 -1
View File
@@ -5,7 +5,7 @@ const { SimpleSchema } = require('/imports/simpleSchema');
const Org = new Mongo.Collection('org');
/**
* A Organization in Wekan. A Enterprise in Trello.
* A Organization in WeKan. A Enterprise in Trello.
*/
Org.attachSchema(
new SimpleSchema({
+1 -1
View File
@@ -5,7 +5,7 @@ const { SimpleSchema } = require('/imports/simpleSchema');
const Team = new Mongo.Collection('team');
/**
* A Team in Wekan. Organization in Trello.
* A Team in WeKan. Organization in Trello.
*/
Team.attachSchema(
new SimpleSchema({
+1 -1
View File
@@ -412,7 +412,7 @@ export class TrelloCreator {
}
/**
* Create the Wekan cards corresponding to the supplied Trello cards,
* Create the WeKan cards corresponding to the supplied Trello cards,
* as well as all linked data: activities, comments, and attachments
* @param trelloCards
* @param boardId
+1 -1
View File
@@ -6,7 +6,7 @@ const { SimpleSchema } = require('/imports/simpleSchema');
const SessionData = new Mongo.Collection('sessiondata');
/**
* A UserSessionData in Wekan. Organization in Trello.
* A UserSessionData in WeKan. Organization in Trello.
*/
SessionData.attachSchema(
new SimpleSchema({
+2 -2
View File
@@ -397,7 +397,7 @@ export class WekanCreator {
}
/**
* Create the Wekan cards corresponding to the supplied Wekan cards,
* Create the WeKan cards corresponding to the supplied WeKan cards,
* as well as all linked data: activities, comments, and attachments
* @param wekanCards
* @param boardId
@@ -660,7 +660,7 @@ export class WekanCreator {
}
/**
* Create the Wekan custom fields corresponding to the supplied Wekan
* Create the WeKan custom fields corresponding to the supplied WeKan
* custom fields.
* @param wekanCustomFields
* @param boardId
+4 -4
View File
@@ -1140,7 +1140,7 @@ def extract_route(call):
def iter_route_calls(node):
'''Recursively yield CallExpression nodes that may register a route, in
source order. Routes appear at the top level (Meteor 3 style) or wrapped in
one of the server-only guards Wekan uses — `if (Meteor.isServer) { ... }`,
one of the server-only guards WeKan uses — `if (Meteor.isServer) { ... }`,
`runOnServer(function () { ... })`, `Meteor.startup(...)`, try blocks — so we
descend through those constructs and into callback function bodies.'''
if node is None:
@@ -1295,17 +1295,17 @@ def parse_schemas(schemas_dirs):
def generate_openapi(schemas, entry_points, version):
print('''swagger: '2.0'
info:
title: Wekan REST API
title: WeKan REST API
version: {0}
description: |
The REST API allows you to control and extend Wekan with ease.
The REST API allows you to control and extend WeKan with ease.
If you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.
> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.
# Production Security Concerns
When calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
When calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
* Only call via HTTPS
* Implement a timed authorization token expiration strategy
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v12.93.0",
"version": "v13.00.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v12.93.0",
"version": "v13.00.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v12.93.0",
"version": "v13.00.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v12.93</title>
<title>WeKan REST API v13.00</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
<body>
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="WeKan REST API" class="sc-eStdmu iyAVQU">WeKan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<script type="module">
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.93","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.00","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
+7 -22
View File
@@ -1,16 +1,16 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v12.93
title: WeKan REST API
version: v13.00
description: |
The REST API allows you to control and extend Wekan with ease.
The REST API allows you to control and extend WeKan with ease.
If you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.
> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.
# Production Security Concerns
When calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
When calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
* Only call via HTTPS
* Implement a timed authorization token expiration strategy
@@ -3014,21 +3014,11 @@ paths:
description: the title value
type: string
required: true
- name: sort
in: formData
description: the sort value
type: string
required: true
- name: parentId
in: formData
description: the parentId value
type: string
required: true
- name: description
in: formData
description: the description value
type: string
required: true
- name: color
in: formData
description: the color value
@@ -3064,11 +3054,6 @@ paths:
description: the spentTime value
type: string
required: true
- name: isOverTime
in: formData
description: the isOverTime value
type: string
required: true
- name: customFields
in: formData
description: the customFields value
@@ -7675,7 +7660,7 @@ definitions:
- userId
Lists:
type: object
description: A list (column) in the Wekan board.
description: A list (column) in the WeKan board.
properties:
scrum:
type: object
@@ -7882,7 +7867,7 @@ definitions:
- soft
Org:
type: object
description: A Organization in Wekan. A Enterprise in Trello.
description: A Organization in WeKan. A Enterprise in Trello.
properties:
orgDisplayName:
description: |
@@ -8489,7 +8474,7 @@ definitions:
- type
Team:
type: object
description: A Team in Wekan. Organization in Trello.
description: A Team in WeKan. Organization in Trello.
properties:
teamDisplayName:
description: |
+23 -1
View File
@@ -17,7 +17,29 @@ log="$work/start-wekan.log"
exec ./start-wekan.sh
) > "$log" 2>&1 &
pid=$!
stop() { kill "$pid" 2>/dev/null || true; pkill -f "$bundle/ferretdb" 2>/dev/null || true; }
# Stop the whole stack, WAIT for it to be gone, and then remove its files.
# start-wekan.sh runs WeKan in the foreground, and bash runs a TERM trap only
# after the foreground command returns - so killing the script alone left
# node and FerretDB running after this test said OK. v12.96 openbsd-arm64:
# FerretDB closed its database while the VM's copyback rsync was walking the
# workspace, wekan.sqlite-wal and -shm vanished under it, and rsync's exit 24
# failed a bundle that had built and started fine. Nothing may still be
# running, or still be on disk under .tools/tmp, when this script returns.
running() { kill -0 "$pid" 2>/dev/null || pgrep -f "$bundle/(ferretdb|main\.js)" >/dev/null 2>&1; }
stop() {
kill "$pid" 2>/dev/null || true
pkill -f "$bundle/main\.js" 2>/dev/null || true
pkill -f "$bundle/ferretdb" 2>/dev/null || true
local n=0
while running && [ "$n" -lt 30 ]; do sleep 1; n=$((n + 1)); done
if running; then
kill -9 "$pid" 2>/dev/null || true
pkill -9 -f "$bundle/(ferretdb|main\.js)" 2>/dev/null || true
sleep 1
fi
wait "$pid" 2>/dev/null || true
rm -rf "$work"
}
trap stop EXIT
for i in $(seq 1 120); do
if ! kill -0 "$pid" 2>/dev/null; then
+19 -4
View File
@@ -155,14 +155,20 @@ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-bundle-bump-'));
const bumped = [];
const skipped = [];
for (const [name, minimum] of Object.entries(manifest.minimums)) {
const outdated = copiesOf(name).filter(c => compare(c.version, minimum) < 0);
for (const [key, minimum] of Object.entries(manifest.minimums)) {
// "body-parser@2" limits the minimum to copies of that major; "@babel/runtime"
// is a scoped name, not a major, so only an @ after the first character counts.
const at = key.lastIndexOf('@');
const name = at > 0 ? key.slice(0, at) : key;
const major = at > 0 ? key.slice(at + 1) : null;
const outdated = copiesOf(name).filter(c =>
(major === null || c.version.split('.')[0] === major) && compare(c.version, minimum) < 0);
if (!outdated.length) {
skipped.push(name);
skipped.push(key);
continue;
}
const prefix = path.join(tmpRoot, name.replace('/', '+'));
const prefix = path.join(tmpRoot, key.replace(/[\/@]/g, '+') + '-' + minimum);
if (!dryRun) {
fs.mkdirSync(prefix, { recursive: true });
// A prefix with no package.json is what we want: npm installs the one
@@ -180,6 +186,15 @@ for (const [name, minimum] of Object.entries(manifest.minimums)) {
fs.rmSync(copy.dir, { recursive: true, force: true });
fs.cpSync(path.join(prefix, 'node_modules', name), copy.dir, { recursive: true });
// The entry file the bundle recorded for the OLD version, written back so it
// resolves again (uuid 11 moved dist/index.js to dist/cjs/index.js).
for (const [rel, body] of Object.entries((manifest.shims || {})[name] || {})) {
const file = path.join(copy.dir, rel);
if (fs.existsSync(file)) continue;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
console.log(` + ${rel} (shim for the entry point ${name} ${copy.version} was built against)`);
}
// Whatever the new version needs that this node_modules does not have. An
// existing sibling is left as it is - it is some other package's choice.
+59 -32
View File
@@ -7,10 +7,25 @@
"be raised is the bundle, after `meteor build` has produced it.",
"",
"Every entry is a version that FIXES a published advisory in the version the",
"bundle would otherwise ship, and stays INSIDE THE SAME MAJOR. That is not",
"caution for its own sake: a Meteor bundle records the file a package resolves",
"to when it is built, so a major that moves its entry point cannot be swapped",
"in afterwards - see uuid under notFixable, which is what taught us that.",
"bundle would otherwise ship. A minimum stays inside the same major WHEN a fix",
"exists there. When the fix is only in a newer major it is still taken, but only",
"after the new major was run the way the Meteor package uses it: nodemailer 10",
"against the calls meteor/email makes, uuid 11 through a shim (see \"shims\").",
"",
"A key may be \"name@MAJOR\" (body-parser@2): the minimum then applies only to",
"copies of that major, so body-parser 1.x and 2.x are each raised inside their",
"own major and neither is dragged across it.",
"",
"A bundle records the file a package resolves to when it is built, so a major",
"that moved its entry point cannot just be swapped in: uuid 11 moved it from",
"dist/index.js to dist/cjs/index.js and the server died on boot (\"Cannot find",
"module .../uuid/dist/index.js\"). \"shims\" writes the old entry file back,",
"re-exporting the new one. nodemailer 10 moved lib/ to dist/cjs/, while",
"meteor/email, built against 8.0.3, links lib/nodemailer.js and",
"lib/well-known/index.js and Npm.requires lib/mail-composer by path: v12.95's",
"bundle failed its smoke boot on the first. nodemailer is \"type\": \"module\",",
"so its shim marks lib/ as CommonJS and forwards lib/nodemailer.js and every",
"lib/<name> directory its exports map lists to dist/cjs.",
"",
"Applied by releases/bump-bundle-npm-deps.mjs, pinned by",
"tests/bundleNpmSecurityBumps.test.cjs. A package NOT listed here is a package",
@@ -21,7 +36,8 @@
"minimums": {
"@babel/runtime": "7.29.7",
"baseline-browser-mapping": "2.11.0",
"body-parser": "1.20.6",
"body-parser": "1.20.9",
"body-parser@2": "2.4.0",
"browserslist": "4.28.7",
"colord": "2.9.4",
"compression": "1.8.2",
@@ -30,43 +46,54 @@
"lodash": "4.18.1",
"lodash.template": "4.18.1",
"nanoid": "3.3.18",
"nodemailer": "8.0.11",
"nodemailer": "10.3.1",
"on-headers": "1.1.0",
"openpgp": "5.11.3",
"picomatch": "4.0.5",
"postcss": "8.5.26",
"qs": "6.16.0",
"postcss-selector-parser": "6.1.5",
"qs": "6.16.1",
"source-map-js": "1.2.2",
"svgo": "2.8.4",
"tmp": "0.2.7",
"underscore": "1.13.8"
"underscore": "1.13.8",
"uuid": "11.1.1"
},
"notFixable": {
"nodemailer": [
"8.0.11 is the newest 8.x. CVE-2026-82659 (fixed in 9.0.1), GHSA-2x7j-588g-ccc2,",
"GHSA-cc9r-2j5m-2m83 and GHSA-wmmp-3585-3rmp (9.1.0), GHSA-8m3c-c648-2xjj (9.1.1),",
"GHSA-6vj9-mwq6-2f5v and GHSA-8vvx-rff5-p5rq (10.0.2) and GHSA-v53p-9fqp-m79j",
"(10.0.6) are fixed only in majors the Meteor email package is not written",
"against. They move when meteor/email moves."
],
"postcss-selector-parser": [
"CVE-2026-104844 in the 6.1.4 meteor/minifier-css brings (through cssnano) is",
"fixed only in 7.1.6, a new major its dependents are not written against.",
"minifier-css runs at build time; it moves when Meteor's minifier-css moves."
],
"uuid": [
"CVE-2026-41907 affects the 8.3.2 in meteor/ddp-server and",
"meteor/inter-process-messaging and the 9.0.1 ldapts brings to wekan-ldap,",
"and the lowest fixed release is 11.1.1 - a major that MOVED its entry",
"point from dist/index.js to dist/cjs/index.js. The bundle resolves the old",
"path, so putting 11.1.1 in its place stops the server before it starts:",
"",
" error on boot.js Error: Cannot find module",
" \"/node_modules/meteor/inter-process-messaging/node_modules/uuid/dist/index.js\"",
"",
"It was tried, it failed exactly there, and it is left at the version the",
"packages were built against. It moves when those packages move: Meteor's",
"for ddp-server and inter-process-messaging, ldapts' for wekan-ldap."
"6.1.5 fixes GHSA-rj75-hqrm-r3gf and is taken. CVE-2026-104844 in the copy",
"meteor/minifier-css brings (through cssnano) is fixed only in 7.1.6, a new major",
"its dependents are not written against. minifier-css runs at build time; it",
"moves when Meteor's minifier-css moves."
]
},
"shims": {
"nodemailer": {
"lib/package.json": "{ \"type\": \"commonjs\" }\n",
"lib/nodemailer.js": "module.exports = require('../dist/cjs/nodemailer.js');\n",
"lib/addressparser/index.js": "module.exports = require('../../dist/cjs/addressparser/index.js');\n",
"lib/base64/index.js": "module.exports = require('../../dist/cjs/base64/index.js');\n",
"lib/dkim/index.js": "module.exports = require('../../dist/cjs/dkim/index.js');\n",
"lib/fetch/index.js": "module.exports = require('../../dist/cjs/fetch/index.js');\n",
"lib/json-transport/index.js": "module.exports = require('../../dist/cjs/json-transport/index.js');\n",
"lib/mail-composer/index.js": "module.exports = require('../../dist/cjs/mail-composer/index.js');\n",
"lib/mailer/index.js": "module.exports = require('../../dist/cjs/mailer/index.js');\n",
"lib/mime-funcs/index.js": "module.exports = require('../../dist/cjs/mime-funcs/index.js');\n",
"lib/mime-node/index.js": "module.exports = require('../../dist/cjs/mime-node/index.js');\n",
"lib/punycode/index.js": "module.exports = require('../../dist/cjs/punycode/index.js');\n",
"lib/qp/index.js": "module.exports = require('../../dist/cjs/qp/index.js');\n",
"lib/sendmail-transport/index.js": "module.exports = require('../../dist/cjs/sendmail-transport/index.js');\n",
"lib/ses-transport/index.js": "module.exports = require('../../dist/cjs/ses-transport/index.js');\n",
"lib/shared/index.js": "module.exports = require('../../dist/cjs/shared/index.js');\n",
"lib/smtp-connection/index.js": "module.exports = require('../../dist/cjs/smtp-connection/index.js');\n",
"lib/smtp-pool/index.js": "module.exports = require('../../dist/cjs/smtp-pool/index.js');\n",
"lib/smtp-transport/index.js": "module.exports = require('../../dist/cjs/smtp-transport/index.js');\n",
"lib/stream-transport/index.js": "module.exports = require('../../dist/cjs/stream-transport/index.js');\n",
"lib/well-known/index.js": "module.exports = require('../../dist/cjs/well-known/index.js');\n",
"lib/xoauth2/index.js": "module.exports = require('../../dist/cjs/xoauth2/index.js');\n"
},
"uuid": {
"dist/index.js": "export * from './esm/index.js';\n"
}
}
}
@@ -7,7 +7,7 @@ ensure_tools git
if ! command -v awk >/dev/null 2>&1; then
case "$(_et_linux_family)" in
alpine) sudo apk add --no-cache gawk ;;
arch) sudo pacman -Sy --needed --noconfirm gawk ;;
arch) _et_pacman gawk ;;
fedora|rhel) pm=dnf; command -v dnf >/dev/null 2>&1 || pm=yum; sudo "$pm" install -y gawk ;;
*) ensure_tools awk ;;
esac
+103 -7
View File
@@ -2,7 +2,8 @@
. "$(dirname "${BASH_SOURCE[0]}")/tools-env.sh" # temporary files and downloads go to .tools/tmp
#
# Shared helper: install required CLI tools for the current platform if missing.
# Supports Debian/Ubuntu, Fedora, RHEL/Oracle Linux, Alpine, Arch, and macOS.
# Supports Debian/Ubuntu, Fedora, RHEL/Oracle Linux, Alpine, Arch (and the
# Arch-based Omarchy, EndeavourOS, CachyOS, Manjaro ...), and macOS.
# The package-manager commands are architecture independent.
#
# Usage (source it, then call ensure_tools with the tools you need):
@@ -27,7 +28,10 @@ _et_linux_family() {
. "$os_release"
case "${ID:-} ${ID_LIKE:-}" in
*alpine*) echo alpine; return ;;
*arch*) echo arch; return ;;
# Omarchy (https://omarchy.org) is Arch with Hyprland on top: pacman,
# the Arch repositories and yay for the AUR. Named explicitly so it is
# recognised whether its os-release says ID=omarchy, ID=arch or ID_LIKE=arch.
*omarchy*|*arch*|*manjaro*|*endeavouros*|*cachyos*) echo arch; return ;;
*ol*|*oracle*|*rhel*|*centos*|*rocky*|*almalinux*) echo rhel; return ;;
*fedora*) echo fedora; return ;;
*debian*|*ubuntu*) echo debian; return ;;
@@ -43,6 +47,68 @@ _et_linux_family() {
_et_have() { command -v "$1" >/dev/null 2>&1; }
# Omarchy is detected by its own tooling, for the messages that name its
# update command; everything else treats it as the Arch family it is.
_et_is_omarchy() {
_et_have omarchy-update || [ -d "${HOME:-/nonexistent}/.local/share/omarchy" ]
}
# Install Arch packages WITHOUT a partial upgrade. `pacman -Sy <pkg>` refreshes
# the package lists but upgrades nothing else, which Arch does not support: the
# new package can be linked against libraries newer than the installed ones
# (https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported).
# So: `-S --needed` against the lists the system already has. A container that
# has never synced (archlinux:latest ships with no sync databases) gets a full
# `-Syu`, which is the supported way to sync, and so does WEKAN_PACMAN_SYU=1.
_et_pacman() {
local sync_dir="${WEKAN_PACMAN_SYNC_DIR:-/var/lib/pacman/sync}"
if [ "${WEKAN_PACMAN_SYU:-0}" = 1 ] || ! ls "$sync_dir"/*.db >/dev/null 2>&1; then
sudo pacman -Syu --needed --noconfirm "$@"
return
fi
if ! sudo pacman -S --needed --noconfirm "$@"; then
echo "pacman could not install: $*" >&2
if _et_is_omarchy; then
echo " The package lists are probably older than the mirrors. Update first with omarchy-update" >&2
else
echo " The package lists are probably older than the mirrors. Update first with sudo pacman -Syu" >&2
fi
echo " (or rerun with WEKAN_PACMAN_SYU=1 to do that full upgrade here), then run this again." >&2
return 1
fi
}
# Install from the AUR with the helper that is there: Omarchy ships yay, other
# Arch installs often have paru. Neither runs as root, so no sudo here.
_et_aur() {
local helper
for helper in yay paru; do
if _et_have "$helper"; then
"$helper" -S --needed --noconfirm "$@"
return
fi
done
echo "No AUR helper (yay or paru) found; install $* from https://aur.archlinux.org manually." >&2
return 1
}
# The OpenSSH server unit is ssh.service on Debian/Ubuntu and sshd.service on
# Arch/Omarchy, Fedora, RHEL and Alpine.
_et_ssh_unit() {
if [ "$(_et_linux_family)" = debian ]; then echo ssh; else echo sshd; fi
}
# How to make en_US.UTF-8 available, per distribution, for the build notes.
_et_locale_hint() {
case "$(_et_linux_family)" in
debian) echo "run sudo dpkg-reconfigure locales." ;;
fedora|rhel) echo "install glibc-langpack-en and enable en_US.UTF-8." ;;
arch) echo "uncomment en_US.UTF-8 in /etc/locale.gen and run sudo locale-gen." ;;
alpine) echo "Alpine uses musl; install musl-locales if the build requires locale data." ;;
*) echo "generate the en_US.UTF-8 locale with your distribution's tools." ;;
esac
}
# Ensure Homebrew exists and is on PATH (Apple Silicon: /opt/homebrew, Intel: /usr/local).
_et_brew_ensure() {
if ! _et_have brew; then
@@ -129,11 +195,19 @@ ensure_tools() {
g++) package=gcc ;;
awk) package=gawk ;;
pip3|python3-pip) package=python-pip ;;
7zip) package=p7zip ;;
snapcraft) echo "Install snapd from AUR, enable snapd.socket, then install snapcraft." >&2; return 1 ;;
# Arch replaced p7zip with 7zip (7-Zip itself) in its repositories.
7zip) package=7zip ;;
snapcraft)
# snapd is in the AUR, not the Arch repositories.
_et_aur snapd || return 1
sudo systemctl enable --now snapd.socket
sudo ln -sfn /var/lib/snapd/snap /snap
sudo snap install snapcraft --classic
continue
;;
*) package="$tool" ;;
esac
sudo pacman -Sy --needed --noconfirm "$package"
_et_pacman "$package"
;;
debian)
case "$tool" in
@@ -220,7 +294,7 @@ ensure_build_toolchain() {
local pm
case "$(_et_os):$(_et_linux_family)" in
linux:alpine) sudo apk add --no-cache bash build-base python3 curl wget p7zip zip unzip ;;
linux:arch) sudo pacman -Sy --needed --noconfirm base-devel python curl wget p7zip zip unzip ;;
linux:arch) _et_pacman base-devel python curl wget 7zip zip unzip ;;
linux:fedora) sudo dnf group install -y development-tools; sudo dnf install -y gcc gcc-c++ make python3 curl wget 7zip zip unzip ;;
linux:rhel) pm=dnf; _et_have dnf || pm=yum; sudo "$pm" groupinstall -y "Development Tools"; sudo "$pm" install -y gcc gcc-c++ make python3 curl wget p7zip zip unzip ;;
linux:debian) sudo apt-get update; sudo apt-get install -y libatomic1 build-essential g++ make python3 curl wget p7zip-full zip unzip ;;
@@ -234,7 +308,7 @@ ensure_archive_tools() {
local pm
case "$(_et_os):$(_et_linux_family)" in
linux:alpine) sudo apk add --no-cache curl wget p7zip zip unzip ;;
linux:arch) sudo pacman -Sy --needed --noconfirm curl wget p7zip zip unzip ;;
linux:arch) _et_pacman curl wget 7zip zip unzip ;;
linux:fedora) sudo dnf install -y curl wget 7zip zip unzip ;;
linux:rhel) pm=dnf; _et_have dnf || pm=yum; sudo "$pm" install -y curl wget p7zip zip unzip ;;
linux:debian) sudo apt-get update; sudo apt-get install -y curl wget p7zip-full zip unzip ;;
@@ -242,3 +316,25 @@ ensure_archive_tools() {
*) echo "Unsupported platform: install curl, wget, 7zip, zip and unzip." >&2; return 1 ;;
esac
}
# The shared libraries Playwright's Chromium and Firefox load, for the
# distributions where `playwright install --with-deps` cannot install them
# (it runs apt-get). Debian/Ubuntu keep using --with-deps.
ensure_playwright_system_deps() {
local pm
case "$(_et_os):$(_et_linux_family)" in
linux:arch)
# Arch and Omarchy. A desktop Omarchy already has nearly all of these;
# --needed makes that a no-op rather than a reinstall.
_et_pacman nss at-spi2-core libcups libdrm libxkbcommon libxcomposite \
libxdamage libxrandr libxfixes mesa pango cairo alsa-lib gtk3 dbus-glib ;;
linux:fedora|linux:rhel)
pm=dnf; _et_have dnf || pm=yum
sudo "$pm" install -y nss atk at-spi2-atk cups-libs libdrm libxkbcommon \
libXcomposite libXdamage libXrandr libXfixes mesa-libgbm pango cairo \
alsa-lib gtk3 dbus-glib ;;
linux:alpine)
echo "Playwright publishes no musl browsers; use WEKAN_PLAYWRIGHT_DOCKER=1 on Alpine." >&2 ;;
*) : ;;
esac
}
+1 -6
View File
@@ -20,12 +20,7 @@ if [ ! -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
fi
echo "Note: If you use other locale than en_US.UTF-8 , you need to additionally install en_US.UTF-8"
case "$(_et_linux_family)" in
fedora|rhel) echo " install glibc-langpack-en and enable en_US.UTF-8." ;;
debian) echo " run sudo dpkg-reconfigure locales." ;;
arch) echo " enable en_US.UTF-8 in /etc/locale.gen and run locale-gen." ;;
alpine) echo " Alpine uses musl; install musl-locales if the build requires locale data." ;;
esac
[ "$(_et_os)" = linux ] && echo " $(_et_locale_hint)"
echo " You can still use any other locale as your main locale."
echo "Building Wekan."
+3 -2
View File
@@ -89,8 +89,9 @@ if [[ "$OSTYPE" == "linux-gnu" ]]; then
}
# Snapcraft is available natively on Debian, Fedora and RHEL families.
# Alpine has no snapd package; Arch requires snapd from AUR, so the shared
# helper reports the required manual prerequisite instead of running apt.
# Alpine has no snapd package. Arch and Omarchy get snapd from the AUR with
# yay or paru (Omarchy ships yay); the shared helper does that, or says what
# to install when there is no AUR helper.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/ensure-tools.sh"
ensure_tools snapcraft
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1293,
appVersion = 1300,
# Increment this for every release.
appMarketingVersion = (defaultText = "12.93~2026-11-03"),
appMarketingVersion = (defaultText = "13.00~2026-11-06"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+73 -8
View File
@@ -6,6 +6,7 @@
import { Meteor } from 'meteor/meteor';
import { check, Match } from 'meteor/check';
import { Mongo, MongoInternals } from 'meteor/mongo';
import { Random } from 'meteor/random';
import { ReactiveCache } from '/imports/reactiveCache';
import { ZipArchive } from 'archiver';
import path from 'path';
@@ -31,10 +32,18 @@ function cloudRemote(provider) {
return adapter ? adapterRemote(adapter.storage, adapter.bucketName) : null;
}
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
const { RENEW_MS, acquireLease, releaseLease, takeoverLease } = require('/models/lib/backupLease');
export const ContinuousBackupSettings = new Mongo.Collection('continuousBackupSettings');
ContinuousBackupSettings.deny({ insert: () => true, update: () => true, remove: () => true });
// #6861: with several servers on one database only one streams (models/lib/backupLease.js).
export const ContinuousBackupLease = new Mongo.Collection('continuousBackupLease');
ContinuousBackupLease.deny({ insert: () => true, update: () => true, remove: () => true });
const leaseCollection = () => ContinuousBackupLease.rawCollection();
// This server's name in the lease.
const serverId = Random.id();
const writablePath = () => path.resolve(process.env.WRITABLE_PATH || process.cwd());
const filesRoot = () => filesRootFrom(writablePath());
// The FerretDB SQLite directory, from the environment the startup scripts
@@ -78,6 +87,55 @@ async function requireSiteAdmin(userId) {
}
let restoring = false;
// The leader loop. Every server with the feature switched on runs it: the one holding the lease
// streams, the others stand by and one of them takes over within about a minute when the leader
// stops renewing (a pod restart, a rollout, a crash). A single server is always its own leader.
let leading = false;
let appliedStamp = null;
let leaseTimer = null;
const stampOf = settings => String(settings.updatedAt ? new Date(settings.updatedAt).getTime() : 0);
async function evaluateLease() {
if (restoring) return;
try {
const saved = await ContinuousBackupSettings.findOneAsync('settings');
const settings = await savedSettings();
if (!settings.enabled) {
if (backupManager().status().running) await backupManager().stop();
if (leading) await releaseLease(leaseCollection(), serverId);
leading = false; appliedStamp = null;
return;
}
leading = await acquireLease(leaseCollection(), serverId);
if (!leading) {
// someone else streams: stop ours, if it was running, so two never write one target
if (backupManager().status().running) await backupManager().stop();
appliedStamp = null;
return;
}
const stamp = stampOf(saved || {});
// start the stream, or follow a settings change another server saved
if (!backupManager().status().running || stamp !== appliedStamp) {
await backupManager().apply(settings);
appliedStamp = stamp;
}
} catch (error) { reportProblem('lease', error); }
}
function startLeaseLoop() {
if (leaseTimer) return;
leaseTimer = Meteor.setInterval(() => { evaluateLease(); }, RENEW_MS);
}
// A restore, and a fetch from the cloud, must be the only writer into the target: take the lease
// from whoever has it and wait one renewal, so the former leader has found out and stopped.
async function becomeOnlyWriter() {
const wasLeader = leading;
const { waitMs } = await takeoverLease(leaseCollection(), serverId);
leading = true;
if (!wasLeader) await new Promise(resolve => Meteor.setTimeout(resolve, waitMs));
}
Meteor.methods({
async 'continuousBackup.getSettings'() {
await requireSiteAdmin(this.userId);
@@ -104,12 +162,14 @@ Meteor.methods({
}
}
await ContinuousBackupSettings.upsertAsync('settings', { $set: { ...settings, updatedAt: new Date(), updatedBy: this.userId } });
await backupManager().apply(settings);
return backupManager().status();
// Not apply(): this server may not be the leader. It converges through the lease like every
// other server; the leader follows the change on its next tick.
await evaluateLease();
return { ...backupManager().status(), leading };
},
async 'continuousBackup.status'() {
await requireSiteAdmin(this.userId);
return { ...backupManager().status(), restoring };
return { ...backupManager().status(), restoring, leading };
},
// A lost or empty target filled again from the cloud copy, so its restore
// points can be restored. Files already here are kept.
@@ -121,13 +181,15 @@ Meteor.methods({
const remote = cloudRemote(settings.upload);
if (!remote) throw new Meteor.Error('continuous-backup-no-upload', `The ${settings.upload} storage is not configured`);
restoring = true;
await backupManager().stop();
try {
await becomeOnlyWriter();
await backupManager().stop();
fs.mkdirSync(settings.target, { recursive: true, mode: 0o700 });
return await fetchFromCloud({ target: settings.target, remote, prefix: settings.uploadPrefix });
} finally {
restoring = false;
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
appliedStamp = null;
if (settings.enabled) await evaluateLease();
}
},
async 'continuousBackup.restorePoints'() {
@@ -164,6 +226,7 @@ Meteor.methods({
// The stream pauses while the restore reads its target, and resumes
// from its saved position after it: the restore's writes are changes
// like any other, and are recorded then.
await becomeOnlyWriter();
await backupManager().stop();
try {
if (request.what === 'database') {
@@ -175,7 +238,9 @@ Meteor.methods({
.map(source => [`${source.area}\0${source.key}`, { path: source.path, file: !!source.file }]));
return await restoreFiles({ target, name: request.generation, until: request.until, roots, mode: request.mode, cipher });
} finally {
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
appliedStamp = null;
restoring = false;
if (settings.enabled) await evaluateLease();
}
} catch (error) {
if (error.code === 'continuous-backup-restore-refused' || error.code === 'continuous-backup-key') {
@@ -189,7 +254,7 @@ Meteor.methods({
Meteor.startup(async () => {
try {
const settings = await savedSettings();
if (settings.enabled) await backupManager().apply(settings);
await evaluateLease();
startLeaseLoop();
} catch (error) { reportProblem('startup', error); }
});
+2
View File
@@ -251,6 +251,8 @@ import '/server/lib/utils';
// ----------------------------------------------------------------------------
// 10. Server — methods
// ----------------------------------------------------------------------------
// #6870: the card's attachment "+" asks this whether uploads are blocked.
import '/server/methods/attachmentUploadPolicy';
import '/server/methods/backup';
// Admin Panel / Attachments / Continuous backup (docs/Backup/Continuous-Backup.md).
import '/server/continuousBackup';
+51
View File
@@ -0,0 +1,51 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
const { boardCreationAllowed, boardTypeOf } = require('/models/lib/boardCreationPolicy');
// CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: "Only admins can create boards"
// was enforced only in createBoardWithInitialSwimlanes. A non-admin still
// created boards by a client Boards.insert, POST /api/boards, both board
// import routes and importBoard, cloneBoard, copyBoard and
// createBoardFromCard. Every one of them now asks here, before it writes;
// the decision itself is models/lib/boardCreationPolicy.js.
const SETTING_ID = 'tableVisibilityMode-boardCreationAdminOnly';
async function decide(userId, type) {
const setting = await TableVisibilityModeSettings.findOneAsync(SETTING_ID);
const adminOnly = setting?.booleanValue === true;
if (!adminOnly) return true;
const user = userId ? await ReactiveCache.getUser(userId) : null;
return boardCreationAllowed({ adminOnly, isAdmin: user?.isAdmin, type });
}
function record(userId, source, type) {
// Only refused attempts reach here: the Add Board buttons, Import, Clone,
// Duplicate and "Create board from card" are hidden for a non-admin while
// the setting is on (client/lib/boardCreationAllowed.js).
try {
require('/server/lib/securityLog').record({
key: 'authz.board-creation', action: 'blocked', source, userId,
detail: `Board creation refused: only admins may create boards (type ${boardTypeOf(type)}).`,
});
} catch (e) { /* logging must never break the guard */ }
}
// For a method or a REST route: throws not-authorized when refused.
export async function assertMayCreateBoard(userId, { source, type } = {}) {
if (await decide(userId, type)) return;
record(userId, source, type);
const error = new Meteor.Error('not-authorized', 'Board creation is restricted to admins.');
// A REST route answers it as 403 (server/lib/apiResponseHelpers.js
// httpStatusForError), not as the 500 an unknown Meteor.Error becomes.
error.statusCode = 403;
throw error;
}
// For an allow/deny rule, which answers rather than throws: true when allowed.
export async function mayCreateBoard(userId, { source, type } = {}) {
if (await decide(userId, type)) return true;
record(userId, source, type);
return false;
}
+40
View File
@@ -4,6 +4,7 @@
// #5398 normalizeMoveParams - one consistent set of board-move params
// #5399 computeTopSort - sort value that lands a card on TOP of a list
// #5537 parseCardDate - parse an ISO date string into a Date that persists
// #6877 parseCardScalarFields - sort 0, isOverTime false, an empty description
/**
* #5399 Moving a card to another list via the API must put it on TOP of the
@@ -103,3 +104,42 @@ export function parseCardDate(value) {
}
return null;
}
/**
* The card PUT fields whose valid values include a falsy one: sort 0,
* isOverTime false and an empty description (#6877). A truthiness check
* skipped them, so 0 could not be written, an overtime flag could not be
* turned off and a description could not be cleared. Presence decides now,
* and the value is checked before the handler writes anything, so a bad
* value is a 400 rather than a half-applied edit.
*
* Form-encoded clients send strings, so '0' is a number and 'true'/'false'
* are booleans here, as in JSON.
*
* @param {object} body the request body
* @returns {{ error: string } | { set: object }} what to $set, or why not
*/
export function parseCardScalarFields(body) {
const has = key => !!body && Object.prototype.hasOwnProperty.call(body, key) && body[key] !== undefined;
const set = {};
if (has('sort')) {
const raw = body.sort;
const sort = typeof raw === 'number' ? raw
: (typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN);
if (!Number.isFinite(sort)) return { error: 'Invalid sort' };
set.sort = sort;
}
if (has('isOverTime')) {
const raw = body.isOverTime;
if (raw === true || raw === 'true') set.isOverTime = true;
else if (raw === false || raw === 'false') set.isOverTime = false;
else return { error: 'Invalid isOverTime' };
}
if (has('description')) {
const raw = body.description;
if (raw === null) set.description = '';
else if (typeof raw === 'string') set.description = raw;
else return { error: 'Invalid description' };
}
return { set };
}
+21
View File
@@ -0,0 +1,21 @@
import { Meteor } from 'meteor/meteor';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
const { uploadPolicy } = require('/models/lib/attachmentUploadPolicy');
// #6870: tells a signed-in client whether uploads are blocked site-wide, so a card does not offer a
// "+" button and a drop zone for an upload the server will refuse. The refusal itself is enforced
// on the server whatever the client does (models/attachments.server.js, server/attachmentApi.js);
// this only decides what is shown. Only the boolean is returned.
Meteor.methods({
async attachmentUploadPolicy() {
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
let limitSettings;
try {
limitSettings = (await AttachmentStorageSettings.findOneAsync({}))?.limitSettings;
} catch (error) {
// an unreadable setting leaves uploads as they were, as the server's own check does
limitSettings = undefined;
}
return uploadPolicy(limitSettings);
},
});
+9 -15
View File
@@ -29,6 +29,7 @@ import Lists from '/models/lists';
import Rules from '/models/rules';
import Swimlanes from '/models/swimlanes';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { assertMayCreateBoard } from '/server/lib/boardCreationGuard';
import Triggers from '/models/triggers';
import Users from '/models/users';
import { ensureIndex } from '/server/lib/mongoStartup';
@@ -179,21 +180,10 @@ Meteor.methods({
throw new Meteor.Error('not-authorized');
}
// #4475: an admin can restrict board creation to admins only. The
// setting lives beside its sibling tableVisibilityMode-allowPrivateOnly
// in Admin Panel -> Settings -> Visibility, so it is read the same way.
const boardCreationAdminOnly = await TableVisibilityModeSettings.findOneAsync(
'tableVisibilityMode-boardCreationAdminOnly',
);
if (boardCreationAdminOnly && boardCreationAdminOnly.booleanValue) {
const creator = await ReactiveCache.getUser(this.userId);
if (!creator || creator.isAdmin !== true) {
throw new Meteor.Error(
'not-authorized',
'Board creation is restricted to admins.',
);
}
}
// #4475: an admin can restrict board creation to admins only. One check,
// shared with every other path that creates a board (CreateBleed,
// server/lib/boardCreationGuard.js).
await assertMayCreateBoard(this.userId, { source: 'createBoardWithInitialSwimlanes', type });
// Apply the instance visibility policy on this server-side insert too.
// Collection allow/deny does not run for insertAsync inside a method.
@@ -1063,6 +1053,8 @@ WebApp.handlers.get('/api/boards/:boardId', async function(req, res) {
WebApp.handlers.post('/api/boards', async function(req, res) {
try {
Authentication.checkLoggedIn(req.userId);
// CreateBleed: "Only admins can create boards" holds for the API too.
await assertMayCreateBoard(req.userId, { source: 'rest:POST /api/boards', type: 'board' });
const allowPrivateOnly =
(await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly'))?.booleanValue;
const permission = allowPrivateOnly ? 'private' : (req.body.permission || 'private');
@@ -1539,6 +1531,8 @@ WebApp.handlers.post('/api/boards/:boardId/copy', async function(req, res) {
// could still copy the board, and an unknown id answered 500.
await Authentication.checkBoardAdmin(req.userId, id);
const board = await ReactiveCache.getBoard(id);
// CreateBleed: a copy is a new board of the source's type.
await assertMayCreateBoard(req.userId, { source: 'rest:POST /api/boards/:boardId/copy', type: board && board.type });
// Only a string title is taken from the request, and it is applied to a
// separate object so the cached source board is not changed.
const title = typeof req.body?.title === 'string' && req.body.title ? req.body.title : await Boards.uniqueTitle(board.title);
+23 -10
View File
@@ -11,7 +11,7 @@ import { add, now } from '/imports/lib/dateUtils';
import { Authentication } from '/server/authentication';
import { sendJsonResult } from '/server/apiMiddleware';
import { allowIsBoardMember, allowIsBoardMemberWithWriteAccess, computeSortForIndex, mergeLabelIds, canAssignCardMember, isCardDateClear } from '/server/lib/utils';
import { computeTopSort, normalizeMoveParams, parseCardDate } from '/server/lib/restCardHelpers';
import { computeTopSort, normalizeMoveParams, parseCardDate, parseCardScalarFields } from '/server/lib/restCardHelpers';
const { coerceRestArrayParam } = require('/server/lib/restArrayParam');
const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency');
const { stampCardListEntry } = require('/models/lib/cardListEntry');
@@ -162,6 +162,9 @@ Meteor.methods({
if (!sourceBoard || !allowIsBoardMemberWithWriteAccess(this.userId, sourceBoard)) {
throw new Meteor.Error('not-authorized');
}
// CreateBleed: the card becomes a new working board.
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
await assertMayCreateBoard(this.userId, { source: 'method:createBoardFromCard', type: 'board' });
const boardTitle = (title && title.trim()) || card.title || '';
if (!boardTitle) throw new Meteor.Error('invalid-title');
@@ -1787,6 +1790,13 @@ WebApp.handlers.put(
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
return;
}
// sort 0, isOverTime false and '' are values, not absences (#6877); checked
// before the first write, so a bad one leaves the card as it was.
const scalars = parseCardScalarFields(req.body);
if (scalars.error) {
sendJsonResult(res, { code: 400, data: { error: scalars.error } });
return;
}
if (req.body.title) {
const newTitle =
@@ -1800,10 +1810,10 @@ WebApp.handlers.put(
);
updated = true;
}
if (req.body.sort) {
if ('sort' in scalars.set) {
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { sort: req.body.sort } },
{ $set: { sort: scalars.set.sort } },
);
updated = true;
}
@@ -1826,10 +1836,10 @@ WebApp.handlers.put(
);
updated = true;
}
if (req.body.description) {
if ('description' in scalars.set) {
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { description: req.body.description } },
{ $set: { description: scalars.set.description } },
);
updated = true;
}
@@ -1943,10 +1953,10 @@ WebApp.handlers.put(
);
updated = true;
}
if (req.body.isOverTime) {
if ('isOverTime' in scalars.set) {
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { isOverTime: req.body.isOverTime } },
{ $set: { isOverTime: scalars.set.isOverTime } },
);
updated = true;
}
@@ -2449,7 +2459,8 @@ WebApp.handlers.post(
archived: false,
});
if (!card) {
throw new Meteor.Error(404, 'Card not found');
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
return;
}
const updatedCustomFields = (card.customFields || []).map(cf =>
cf._id === paramCustomFieldId ? { _id: cf._id, value: paramCustomFieldValue } : cf,
@@ -2483,7 +2494,8 @@ WebApp.handlers.post(
archived: false,
});
if (!card) {
throw new Meteor.Error(404, 'Card not found');
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
return;
}
await card.archive();
sendJsonResult(res, {
@@ -2508,7 +2520,8 @@ WebApp.handlers.post(
archived: true,
});
if (!card) {
throw new Meteor.Error(404, 'Card not found');
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
return;
}
await card.restore();
sendJsonResult(res, {
+20
View File
@@ -110,11 +110,29 @@ WebApp.handlers.post('/api/boards/:boardId/integrations', async function(req, re
}
});
// An integration that is not on THIS board (a wrong id, or the id of another board's
// integration) answers 404, as GET does (#6862). The write routes used to answer 200 -
// PUT with the requested id, the activities routes with `data: null` - for an update
// that changed nothing, so a client could not tell it from a success. Checked after
// board-admin authorization, so it tells nobody without that right anything.
async function integrationOr404(res, boardId, intId) {
const integration = await ReactiveCache.getIntegration(
{ _id: intId, boardId },
{ fields: { _id: 1 } },
);
if (!integration) {
sendJsonResult(res, { code: 404, data: { error: 'Integration not found' } });
return null;
}
return integration;
}
WebApp.handlers.put('/api/boards/:boardId/integrations/:intId', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
// #3695: how this webhook delivers - { webhook: { text, fields, grouping,
// schedule, ... } }, or null to inherit the board / Admin Panel default
@@ -201,6 +219,7 @@ WebApp.handlers.delete(
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
await Integrations.direct.updateAsync(
{ _id: paramIntId, boardId: paramBoardId },
@@ -228,6 +247,7 @@ WebApp.handlers.post(
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
await Integrations.direct.updateAsync(
{ _id: paramIntId, boardId: paramBoardId },
+4
View File
@@ -397,6 +397,10 @@ WebApp.handlers.put('/api/admin/orgs/:orgId/features', async function(req, res)
await Org.updateAsync(orgId, { $set });
}
const updated = await Org.findOneAsync(orgId);
if (!updated) {
sendJsonResult(res, { code: 404, data: { error: 'Organization not found' } });
return;
}
sendJsonResult(res, { code: 200, data: updated });
} catch (error) {
sendJsonResult(res, publicErrorData(error));
+4
View File
@@ -360,6 +360,10 @@ WebApp.handlers.put('/api/admin/teams/:teamId/features', async function(req, res
await Team.updateAsync(teamId, { $set });
}
const updated = await Team.findOneAsync(teamId);
if (!updated) {
sendJsonResult(res, { code: 404, data: { error: 'Team not found' } });
return;
}
sendJsonResult(res, { code: 200, data: updated });
} catch (error) {
sendJsonResult(res, publicErrorData(error));
+22 -20
View File
@@ -2714,28 +2714,30 @@ WebApp.handlers.put('/api/users/:userId', async function(req, res) {
const id = req.params.userId;
const action = req.body.action;
let data = await ReactiveCache.getUser({ _id: id });
if (data !== undefined) {
if (action === 'takeOwnership') {
const boards = await ReactiveCache.getBoards(
{ 'members.userId': id, 'members.isAdmin': true },
{ sort: { sort: 1 } },
);
data = [];
for (const board of boards) {
if (board.hasMember(req.userId)) {
await board.removeMember(req.userId);
}
board.changeOwnership(id, req.userId);
data.push({ _id: board._id, title: board.title });
if (!data) {
sendJsonResult(res, { code: 404, data: { error: 'User not found' } });
return;
}
if (action === 'takeOwnership') {
const boards = await ReactiveCache.getBoards(
{ 'members.userId': id, 'members.isAdmin': true },
{ sort: { sort: 1 } },
);
data = [];
for (const board of boards) {
if (board.hasMember(req.userId)) {
await board.removeMember(req.userId);
}
} else {
if (action === 'disableLogin' && id !== req.userId) {
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: true, 'services.resume.loginTokens': [] } });
} else if (action === 'enableLogin') {
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: false } });
}
data = withoutSecrets(await ReactiveCache.getUser(id));
board.changeOwnership(id, req.userId);
data.push({ _id: board._id, title: board.title });
}
} else {
if (action === 'disableLogin' && id !== req.userId) {
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: true, 'services.resume.loginTokens': [] } });
} else if (action === 'enableLogin') {
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: false } });
}
data = withoutSecrets(await ReactiveCache.getUser(id));
}
sendJsonResult(res, { code: 200, data });
} catch (error) {
+53
View File
@@ -3,6 +3,7 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { findWhere, where } from '/imports/lib/collectionHelpers';
import { allowIsBoardAdminOrSiteAdmin, canUpdateBoardSort, canUpdateBoardSameWidthValue } from '/server/lib/utils';
import { canWriteSubtaskDeposit, recordSubtaskDepositDenial } from '/server/lib/subtaskDepositAccess';
import { mayCreateBoard } from '/server/lib/boardCreationGuard';
const { isOpenPermission } = require('/models/lib/boardPermission');
Boards.deny({
@@ -21,6 +22,12 @@ Boards.deny({
} catch (e) { /* logging must never break the guard */ }
return true;
}
// CreateBleed (GHSA-qcfm-2g4w-wqcc): "Only admins can create boards"
// held only in createBoardWithInitialSwimlanes, and a client insert
// reached Boards.allow.insert, which approves any signed-in user. The one
// client insert WeKan makes is a board template in the Templates board,
// which the setting leaves to every member.
if (!(await mayCreateBoard(userId, { source: 'ddp:boards.insert', type: doc.type }))) return true;
if (!doc.subtasksDefaultBoardId) return false;
if (await canWriteSubtaskDeposit(userId, doc.subtasksDefaultBoardId)) return false;
recordSubtaskDepositDenial('ddp:board-insert');
@@ -108,6 +115,52 @@ Boards.allow({
fetch: ['members'],
});
// GroupLinkBleed (GHSA-v544-rmr5-38gx): orgs, teams and domains are written
// only by the methods setBoardOrgs, setBoardTeams and setBoardDomains, which
// let a board admin add only the organizations and teams they belong to
// (#5141) and validate domains (#5850). The allow rule above admits any field
// for a board admin, so a client collection write would skip those checks. The
// UI never writes these fields directly; the methods write server-side, which
// allow/deny does not see.
const { isGroupLinkField, groupLinksInInsert, addsBoardMember } = require('/models/lib/boardWriteGuards');
function recordBoardWriteDenial(key, userId, source, detail) {
try {
require('/server/lib/securityLog').record({ key, action: 'blocked', source, userId, detail });
} catch (e) { /* logging must never break the guard */ }
}
Boards.deny({
insert(userId, doc) {
if (!groupLinksInInsert(doc)) return false;
recordBoardWriteDenial('authz.group-link', userId, 'ddp:boards.insert',
'A client board insert named organizations, teams or domains.');
return true;
},
update(userId, doc, fieldNames, modifier) {
const fields = [...(fieldNames || [])];
if (modifier && modifier.$rename) fields.push(...Object.values(modifier.$rename));
if (!fields.some(isGroupLinkField)) return false;
recordBoardWriteDenial('authz.group-link', userId, 'ddp:boards.update',
'A client board update named organizations, teams or domains outside the board methods.');
return true;
},
fetch: [],
});
// GroupLinkBleed, member variant: a board admin adding a user straight into
// `members` skips inviteUserToBoard (who may invite, disabled accounts, the
// #6116 same organization/team policy). Client code only changes a member's
// role or removes one (Boards.helpers removeMember, setMemberPermission), so
// any client write that ADDS a member is refused.
Boards.deny({
update(userId, doc, fieldNames, modifier) {
if (!(fieldNames || []).includes('members') || !addsBoardMember(doc, modifier)) return false;
recordBoardWriteDenial('authz.member-add', userId, 'ddp:boards.update',
'A client board update added a member outside inviteUserToBoard.');
return true;
},
fetch: ['members'],
});
// The number of users that have starred this board is managed by trusted code
// and the user is not allowed to update it
Boards.deny({
+4
View File
@@ -1440,6 +1440,10 @@ Meteor.methods({
catch (error) { throw new Meteor.Error('invalid-copy-options', error.message); }
}
const copy = boardWithCopyProperties(board, values);
// CreateBleed: a copy is a new board - unless it is a board template, and
// a template made a working board again is a copy of type 'board'.
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
await assertMayCreateBoard(this.userId, { source: 'method:copyBoard', type: values.type || board.type });
if (selection) return copy.copy(!selection.cards, selection);
return copy.copy(!!values.withoutCards);
+5 -1
View File
@@ -23,7 +23,11 @@ Meteor.publish('notificationAttachments', async function() {
{},
true,
);
return ret;
// Attachments is an ostrio:files FilesCollection: its find() is a FilesCursor,
// which a publication cannot return ("Publish function can only return a
// Cursor"). The Mongo cursor it wraps is .cursor, as every other attachment
// publication returns.
return ret.cursor || ret;
});
// gets all cards associated with activities associated with the current user
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '12.93'
version: '13.00'
base: core24
confinement: strict
grade: stable
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-12.93-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-13.00-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v12.93/wekan-12.93-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v13.00/wekan-13.00-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v12.93/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v13.00/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+1 -1
View File
@@ -97,7 +97,7 @@ const stored = new Set();
const walk = dir => {
for (const item of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${item.name}`;
if (item.isDirectory()) { if (!['node_modules', '_build', 'tests'].includes(item.name)) walk(rel); continue; }
if (item.isDirectory()) { if (!(['node_modules', '_build', 'tests'].includes(item.name) || item.name.startsWith('_build'))) walk(rel); continue; }
if (!/\.js$/.test(item.name)) continue;
for (const m of read(rel).matchAll(/activityType: '([a-zA-Z]+)'/g)) stored.add(m[1]);
}
+163
View File
@@ -0,0 +1,163 @@
'use strict';
// build.sh and releases/*.sh on Arch Linux and Omarchy (https://omarchy.org),
// the Arch-based Hyprland desktop: pacman without partial upgrades, Arch
// package names, snapd from the AUR, the sshd unit, Playwright without the
// apt-only --with-deps. Everything runs offline: sudo, yay and pacman are
// shell functions that print what they would have run.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const TMP = path.join(ROOT, '.tools', 'tmp');
fs.mkdirSync(TMP, { recursive: true });
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
function run(body, env = {}) {
const shell = `
. releases/ensure-tools.sh
_et_os() { echo linux; }
_et_linux_family() { echo "\${TEST_FAMILY:-arch}"; }
_et_have() { [[ "|\${TEST_HAVE:-pacman}|" == *"|$1|"* ]]; }
sudo() { echo "SUDO $*"; [ -z "\${TEST_SUDO_FAIL:-}" ]; }
yay() { echo "YAY $*"; }
paru() { echo "PARU $*"; }
${body}
`;
const result = spawnSync('bash', ['-c', shell], {
cwd: ROOT, encoding: 'utf8',
env: { ...process.env, TMPDIR: TMP, ...env },
});
return result;
}
function withSyncDir(hasDb, fn) {
const dir = fs.mkdtempSync(path.join(TMP, 'pacman-sync-'));
if (hasDb) fs.writeFileSync(path.join(dir, 'core.db'), '');
try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
console.log('archOmarchyScripts:');
test('pacman installs with -S --needed, never a -Sy partial upgrade', () => {
withSyncDir(true, (dir) => {
const r = run('ensure_tools gh 7zip awk python3', { WEKAN_PACMAN_SYNC_DIR: dir });
assert.equal(r.status, 0, r.stderr);
for (const pkg of ['github-cli', '7zip', 'gawk', 'python']) {
assert.match(r.stdout, new RegExp(`SUDO pacman -S --needed --noconfirm ${pkg}\\n`), r.stdout);
}
assert.doesNotMatch(r.stdout, /pacman -Sy /);
assert.doesNotMatch(r.stdout, /p7zip/, 'Arch replaced p7zip with 7zip');
});
});
test('a never-synced pacman (fresh container) does a full -Syu, not -Sy', () => {
withSyncDir(false, (dir) => {
const r = run('_et_pacman jq', { WEKAN_PACMAN_SYNC_DIR: dir });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /SUDO pacman -Syu --needed --noconfirm jq\n/);
});
});
test('WEKAN_PACMAN_SYU=1 opts in to the full upgrade', () => {
withSyncDir(true, (dir) => {
const r = run('_et_pacman jq', { WEKAN_PACMAN_SYNC_DIR: dir, WEKAN_PACMAN_SYU: '1' });
assert.match(r.stdout, /SUDO pacman -Syu --needed --noconfirm jq\n/);
});
});
test('a failed install fails, and names omarchy-update on Omarchy', () => {
withSyncDir(true, (dir) => {
const r = run('_et_pacman jq', {
WEKAN_PACMAN_SYNC_DIR: dir, TEST_SUDO_FAIL: '1', TEST_HAVE: 'pacman|omarchy-update',
});
assert.notEqual(r.status, 0);
assert.match(r.stderr, /omarchy-update/);
assert.doesNotMatch(r.stdout, /pacman -Sy /, 'no automatic partial upgrade as a retry');
});
withSyncDir(true, (dir) => {
const r = run('_et_pacman jq', {
WEKAN_PACMAN_SYNC_DIR: dir, TEST_SUDO_FAIL: '1', HOME: path.join(TMP, 'no-such-home'),
});
assert.notEqual(r.status, 0);
assert.match(r.stderr, /sudo pacman -Syu/);
assert.doesNotMatch(r.stderr, /omarchy-update/);
});
});
test('snapcraft on Arch/Omarchy: snapd from the AUR with yay, then the snap', () => {
const r = run('ensure_tools snapcraft', { TEST_HAVE: 'pacman|yay' });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /YAY -S --needed --noconfirm snapd\n/);
assert.match(r.stdout, /SUDO systemctl enable --now snapd.socket\n/);
assert.match(r.stdout, /SUDO snap install snapcraft --classic\n/);
assert.doesNotMatch(r.stdout, /SUDO yay|SUDO paru/, 'AUR helpers must not run as root');
});
test('snapcraft without an AUR helper fails with instructions instead of guessing', () => {
const r = run('ensure_tools snapcraft');
assert.notEqual(r.status, 0);
assert.match(r.stderr, /aur\.archlinux\.org/);
assert.doesNotMatch(r.stdout, /snap install/);
});
test('the SSH server unit is sshd on Arch and ssh on Debian', () => {
assert.equal(run('_et_ssh_unit').stdout.trim(), 'sshd');
assert.equal(run('_et_ssh_unit', { TEST_FAMILY: 'debian' }).stdout.trim(), 'ssh');
});
test('Playwright system libraries come from pacman on Arch', () => {
withSyncDir(true, (dir) => {
const r = run('ensure_playwright_system_deps', { WEKAN_PACMAN_SYNC_DIR: dir });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /SUDO pacman -S --needed --noconfirm nss .*gtk3/);
});
});
test('the build toolchain and archive tools use 7zip and no partial upgrade', () => {
withSyncDir(true, (dir) => {
const r = run('ensure_build_toolchain; ensure_archive_tools', { WEKAN_PACMAN_SYNC_DIR: dir });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /pacman -S --needed --noconfirm base-devel python curl wget 7zip zip unzip\n/);
assert.doesNotMatch(r.stdout, /p7zip|pacman -Sy /);
});
});
test('build.sh: Arch install, SSH menu and Playwright are distribution-aware', () => {
const src = fs.readFileSync(path.join(ROOT, 'build.sh'), 'utf8');
assert.match(src, /_et_pacman base-devel git curl wget 7zip zip unzip file npm/);
assert.match(src, /systemctl enable \\"\\\$\(_et_ssh_unit\)\\"/);
assert.doesNotMatch(src, /systemctl (enable|start|disable|stop) ssh\b/);
// --with-deps is apt-only: it must be behind the Debian check.
const withDeps = src.indexOf('npx playwright install --with-deps');
const guard = src.lastIndexOf('"$(_et_linux_family)" != debian', withDeps);
assert.ok(withDeps > 0 && guard > 0 && withDeps - guard < 800, 'with-deps is guarded');
assert.match(src, /Arch\/Omarchy/);
});
// Negative test: the fault cannot live anywhere else. No tracked shell script
// does a `pacman -Sy <package>` partial upgrade (-Syu is the supported form),
// and none installs Arch's retired p7zip package.
test('no shell script anywhere does a pacman -Sy partial upgrade or installs p7zip on Arch', () => {
const files = spawnSync('git', ['ls-files', '*.sh'], { cwd: ROOT, encoding: 'utf8' })
.stdout.split('\n').filter(Boolean);
assert.ok(files.length > 50);
const offenders = [];
for (const file of files) {
const lines = fs.readFileSync(path.join(ROOT, file), 'utf8').split('\n');
lines.forEach((line, i) => {
if (/^\s*#/.test(line)) return;
if (/pacman\s+-Sy(?!u)\w*\s/.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
if (/pacman\b.*\bp7zip\b/.test(line) || /_et_pacman\b.*\bp7zip\b/.test(line)) {
offenders.push(`${file}:${i + 1}: ${line.trim()}`);
}
});
}
assert.deepEqual(offenders, []);
});
console.log(`\n${passed} tests passed`);
+1 -1
View File
@@ -190,7 +190,7 @@ test('negative: every file that serves attachment or document data applies the r
const walk = dir => {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) { if (!['node_modules', '_build', '.build'].includes(e.name)) walk(rel); }
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
else if (/\.js$/.test(e.name)) files.push(rel);
}
};
+1 -1
View File
@@ -28,7 +28,7 @@ const SKIP = new Set(['_build', 'node_modules', '.npm', 'tests']);
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP.has(e.name)) continue;
if (SKIP.has(e.name) || e.name.startsWith('_build')) continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) walk(full, out);
else if (e.name.endsWith('.js')) out.push(full);
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// #6870: attachments off for the whole installation. The switch exists already - Admin Panel /
// Attachments / Transfer limits, "Attachments upload" and "API upload" set to Blocked - and the
// server refuses on every path. What the card lacked was to STOP OFFERING the upload. This pins
// the client side, and the server enforcement it relies on, so the two cannot drift apart.
//
// Run: node tests/attachmentUploadPolicy.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { uploadPolicy } = require('../models/lib/attachmentUploadPolicy');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
test('only a real true blocks uploads', () => {
assert.deepEqual(uploadPolicy({ attachmentsUploadBlocked: true }), { uploadsBlocked: true });
for (const v of [undefined, null, {}, [], 'x', { attachmentsUploadBlocked: false },
{ attachmentsUploadBlocked: 'true' }, { attachmentsUploadBlocked: 1 }, { apiUploadBlocked: true }]) {
assert.deepEqual(uploadPolicy(v), { uploadsBlocked: false }, JSON.stringify(v));
}
});
test('negative: the answer carries only the boolean, nothing about limits or storage', () => {
const answer = uploadPolicy({ attachmentsUploadBlocked: true, attachmentsUploadMaxBytes: 5, apiUploadMaxBytes: 7, secret: 'x' });
assert.deepEqual(Object.keys(answer), ['uploadsBlocked']);
});
test('the method answers signed-in users only, and falls back to "not blocked" on a read error', () => {
const src = read('server/methods/attachmentUploadPolicy.js');
assert.match(src, /async attachmentUploadPolicy\(\) \{\s*if \(!this\.userId\) throw new Meteor\.Error\('error-notAuthorized'\);/);
assert.match(src, /catch \(error\) \{[^}]*limitSettings = undefined;/);
});
test('the card offers the "+" only when uploads are not blocked', () => {
const jade = read('client/components/cards/attachments.jade');
assert.match(jade, /if canAddAttachment\s*\n\s*a\.card-label\.add-label\.add-attachment\.js-add-attachment/);
assert.doesNotMatch(jade, /if canModifyCard\s*\n\s*a\.card-label\.add-label\.add-attachment/, 'the old condition is gone');
const js = read('client/components/cards/attachments.js');
assert.match(js, /canAddAttachment\(\) \{\s*return Utils\.canModifyCard\(\) && !attachmentUploadsBlocked\(\);/);
// and a drop or paste does nothing either
assert.match(js, /if \(attachmentUploadsBlocked\(\)\) return \[\];\s*\n\s*\/\/ Check if board allows attachments/);
});
test('the client asks once a minute at most and shows uploads until the server answers', () => {
const src = read('client/lib/attachmentUploadPolicy.js');
assert.match(src, /new ReactiveVar\(false\)/);
assert.match(src, /Date\.now\(\) - askedAt < 60 \* 1000/);
assert.match(src, /catch\(\(\) => \{ askedAt = 0; \}\)/);
});
test('the server refuses on every upload path whatever the client shows', () => {
const pipeline = read('models/attachments.server.js');
assert.match(pipeline, /uploadsBlocked = settings\?\.limitSettings\?\.attachmentsUploadBlocked === true/);
const methods = read('server/attachmentApi.js');
const routes = read('server/routes/attachmentApi.js');
// api.attachment.upload and api.attachment.copy, the HTTP upload, upload-background and copy
assert.equal((methods.match(/if \(apiUploadBlocked\)/g) || []).length >= 2, true, 'upload and copy methods');
assert.equal((routes.match(/if \(apiUploadBlocked\)/g) || []).length >= 3, true, 'upload, background and copy routes');
});
test('negative: there is no second "disable attachments" setting to disagree with the server', () => {
for (const f of ['models/settings.js', 'server/publications/settings.js', 'client/components/settings/settingBody.jade']) {
assert.doesNotMatch(read(f), /disableAttachments/, f);
}
});
+3
View File
@@ -126,6 +126,9 @@ const requireStub = name => {
if (name === '/models/lib/cardCopyOverrides') return { cardWithCopyOverrides: card => card };
if (name === '/server/lib/cardCopyDestination') return { requireCardCopyDestination: async () => {} };
if (name === '/models/lib/scrumCopy') return { copiedScrumMetadata: () => ({}) };
// CreateBleed's guard (tests/createBleed.test.cjs): creating is allowed here,
// so these tests see the copy methods' own checks.
if (name === '/server/lib/boardCreationGuard') return { assertMayCreateBoard: async () => {} };
throw new Error(`unexpected require ${name}`);
};
+1 -1
View File
@@ -38,7 +38,7 @@ const VARS = authConfigEnvVars();
function walk(dir, out = []) {
for (const entry of fs.readdirSync(path.join(ROOT, dir), {withFileTypes: true})) {
if (['_build', '.build', 'node_modules', '.tools', '.meteor', '.npm', 'tests'].includes(entry.name)) continue;
if ((['_build', '.build', 'node_modules', '.tools', '.meteor', '.npm', 'tests'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
const rel = `${dir}/${entry.name}`;
if (entry.isDirectory()) walk(rel, out);
else if (/\.(js|cjs|mjs)$/.test(entry.name)) out.push(rel);
+12 -5
View File
@@ -56,12 +56,19 @@ test('the server method rejects a non-admin when the setting is on', () => {
const at = server.indexOf("async createBoardWithInitialSwimlanes(payload) {");
assert.notStrictEqual(at, -1, 'the method exists');
const body = server.slice(at, server.indexOf('\n },', at));
assert.ok(/TableVisibilityModeSettings\.findOneAsync\(\s*\n?\s*'tableVisibilityMode-boardCreationAdminOnly'/.test(body),
'the guard reads the global setting');
assert.ok(/creator\.isAdmin !== true/.test(body),
'a non-admin creator is rejected while the setting is on');
assert.ok(body.indexOf('boardCreationAdminOnly') < body.indexOf('Boards.insertAsync'),
// Changed on purpose (CreateBleed, GHSA-qcfm-2g4w-wqcc): the check was
// here alone, so every other path that creates a board skipped it. It is
// now one guard, server/lib/boardCreationGuard.js, called from this method
// and from all the others (tests/createBleed.test.cjs).
assert.ok(/await assertMayCreateBoard\(this\.userId, \{ source: 'createBoardWithInitialSwimlanes', type \}\)/.test(body),
'the method asks the shared guard');
assert.ok(body.indexOf('assertMayCreateBoard') < body.indexOf('Boards.insertAsync'),
'the guard runs before the board is inserted');
const guard = read('server/lib/boardCreationGuard.js');
assert.ok(/'tableVisibilityMode-boardCreationAdminOnly'/.test(guard) && /TableVisibilityModeSettings\.findOneAsync\(SETTING_ID\)/.test(guard),
'the guard reads the global setting');
assert.ok(/isAdmin === true/.test(read('models/lib/boardCreationPolicy.js')),
'a non-admin creator is rejected while the setting is on');
});
test('client hides the "Add board" entry points the same way, but only as a UI convenience', () => {
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const exportFields = require('../models/lib/exportFields');
function fixture({ board, disabled = false, lookupError } = {}) {
let handler;
const calls = [];
class Exporter {
constructor() { calls.push('exporter'); }
async build(res) { calls.push('build'); res.end('workbook'); }
}
const context = {
runOnServer: fn => fn(),
ReactiveCache: { async getBoard(id) {
calls.push(['board', id]);
if (lookupError) throw lookupError;
return board;
} },
Meteor: { settings: { public: {} } },
require(name) {
switch (name) {
case './server/ExporterExcel': return { ExporterExcel: Exporter };
case './server/ExporterExcelBoard': return { ExporterExcelBoard: Exporter };
case '/models/lib/exportFields': return exportFields;
case 'meteor/webapp': return { WebApp: { handlers: { get(url, fn) {
assert.equal(url, '/api/boards/:boardId/exportExcel');
handler = fn;
} } } };
case '/server/apiMiddleware': return { safeRoute: fn => fn };
case '/server/authentication': return { Authentication: { async checkUserId() {
calls.push('authenticate');
throw Object.assign(new Error('Unauthorized'), { statusCode: 403 });
} } };
case '/models/lib/importExportSecurity': return { async refuseDisabledExport(res) {
if (!disabled) return false;
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Export disabled');
return true;
} };
default: throw new Error(`Unexpected module: ${name}`);
}
},
};
const source = fs.readFileSync(path.join(__dirname, '../models/exportExcel.js'), 'utf8');
vm.runInNewContext(source.replace(/^import .*;\r?\n/gm, ''), context);
assert.equal(typeof handler, 'function');
return {
calls,
async get(query = {}) {
const res = {
statusCode: 200,
headers: {},
ends: 0,
writeHead(status, headers) { this.statusCode = status; this.headers = headers; },
end(body) { this.body = body; this.ends += 1; },
};
await handler({ params: { boardId: 'board' }, query }, res);
assert.equal(res.ends, 1, 'respond exactly once');
return res;
},
};
}
for (const query of [{}, { authToken: 'token' }]) {
test(`a missing board returns plain-text 404 ${query.authToken ? 'with a token' : 'anonymously'}`, async () => {
const f = fixture();
const res = await f.get(query);
assert.equal(res.statusCode, 404);
assert.equal(res.headers['Content-Type'], 'text/plain; charset=utf-8');
assert.equal(res.body, 'Board not found');
assert.deepEqual(f.calls, [['board', 'board']]);
});
}
for (const query of [{}, { fields: 'description' }]) {
test(`an existing public board still builds ${query.fields ? 'the table' : 'the card layout'}`, async () => {
const f = fixture({ board: { isPublic: () => true } });
const res = await f.get(query);
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'workbook');
assert.deepEqual(f.calls, [['board', 'board'], 'exporter', 'build']);
});
}
test('an existing private board still requires authentication', async () => {
const f = fixture({ board: { isPublic: () => false } });
const res = await f.get();
assert.equal(res.statusCode, 403);
assert.equal(res.body, 'Unauthorized');
assert.deepEqual(f.calls, [['board', 'board'], 'authenticate']);
});
test('disabled exports are refused before looking up a board', async () => {
const f = fixture({ disabled: true });
assert.equal((await f.get()).statusCode, 403);
assert.deepEqual(f.calls, []);
});
test('a database failure is not misreported as a missing board', async () => {
const error = new Error('database unavailable');
await assert.rejects(() => fixture({ lookupError: error }).get(), error);
});
+69
View File
@@ -0,0 +1,69 @@
'use strict';
// The board-open profile (docs/Databases/FerretDB/1/SpeedTest/app): the part that can be
// checked without a browser and a database - the CPU profile of the Meteor server is
// split by who spends it, and the README states what was measured and what was not.
//
// Run: node tests/boardOpenProfile.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { categorise, analyse } = require('../docs/Databases/FerretDB/1/SpeedTest/app/profileAnalysis.cjs');
const DIR = path.join(__dirname, '..', 'docs/Databases/FerretDB/1/SpeedTest/app');
const node = (id, functionName, url = '') => ({ id, callFrame: { functionName, url } });
test('time is split by who spends it', () => {
const cases = [
['(idle)', '', 'idle'],
['(garbage collector)', '', 'garbage collector'],
['(program)', '', 'native (program)'],
['deserializeObject', 'file:///x/npm-mongo/node_modules/mongodb/lib/bson.cjs', 'MongoDB driver (wire, decode, encode)'],
['publish', 'file:///x/programs/server/packages/ddp-server.js', 'Meteor DDP (publish, mergebox, sessions)'],
['replaceTypes', 'file:///x/programs/server/packages/mongo.js', 'Meteor mongo (observe, polling, diff)'],
['stringify', 'file:///x/programs/server/packages/ejson.js', 'EJSON'],
['CollectionDocument', 'file:///x/_build/main-dev/server-rspack.cjs', 'WeKan code'],
['writeBuffer', 'node:internal/stream_base_commons', 'node internals'],
['something', 'file:///x/programs/server/packages/random.js', 'other packages'],
];
for (const [fn, url, want] of cases) assert.equal(categorise(node(1, fn, url)), want, `${fn} ${url}`);
// negative: a dependency under node_modules, and a Meteor package whose path has /server/ in it,
// are never "WeKan code", even when the path mentions wekan
assert.notEqual(categorise(node(1, 'f', 'file:///x/wekan/node_modules/lodash/lodash.js')), 'WeKan code');
assert.equal(categorise(node(1, 'f', 'file:///x/wekan/.meteor/local/build/programs/server/packages/check.js')), 'other packages');
});
test('analyse adds up the samples, leaves the idle time out of "busy", and orders by cost', () => {
const nodes = [node(1, '(idle)'), node(2, 'deserializeObject', 'file:///x/mongodb/lib/bson.cjs'),
node(3, 'CollectionDocument', 'file:///x/_build/server.cjs'), node(4, '(garbage collector)')];
// timeDeltas are in microseconds; sample i is charged the delta after it
const profile = { nodes, samples: [1, 2, 2, 3, 4, 1], timeDeltas: [0, 4000, 6000, 3000, 2000, 5000, 0] };
const a = analyse(profile);
assert.equal(Math.round(a.totalMs), 20);
assert.equal(Math.round(a.busyMs), 16, '4 of the 20 ms are idle');
assert.deepEqual(a.categories.map(([c]) => c), ['MongoDB driver (wire, decode, encode)', 'garbage collector', 'WeKan code']);
assert.deepEqual(a.categories.map(([, ms]) => Math.round(ms)), [9, 5, 2]);
assert.ok(a.categories.every(([, ms]) => ms > 0));
assert.equal(a.topFunctions[0][0].split(' ')[0], 'deserializeObject');
// negative: an empty profile is nothing, not NaN
const empty = analyse({ nodes: [], samples: [], timeDeltas: [] });
assert.equal(empty.busyMs, 0);
assert.deepEqual(empty.categories, []);
});
test('the script parses, never touches a database outside its own, and the README says what it measured', () => {
const { execFileSync } = require('node:child_process');
execFileSync(process.execPath, ['--check', path.join(DIR, 'board-open.cjs')]);
const script = fs.readFileSync(path.join(DIR, 'board-open.cjs'), 'utf8');
// it seeds a user and a board of its own, in the database named by WEKAN_MONGO_URL
assert.match(script, /db\.seedUser\(\)/);
assert.doesNotMatch(script, /dropDatabase|deleteMany\(\{\}\)|drop\(\)/);
const md = fs.readFileSync(path.join(DIR, 'README.md'), 'utf8');
for (const needle of ['development build', 'same ~210 KB', 'run 1 / run 2', '400-600 ms', 'within the noise', 'not identified', 'does not separate']) {
assert.ok(md.includes(needle), needle);
}
// the root README points to it
assert.ok(fs.readFileSync(path.join(DIR, '..', 'README.md'), 'utf8').includes('[app/](app/)'));
});
+4 -2
View File
@@ -11,13 +11,15 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
// #3249: "private boards only" refuses 'instance' boards as well as public
// ones; the server code asks isOpenPermission() for both.
const { isOpenPermission } = require('../models/lib/boardPermission.js');
const run = new AsyncFunction('payload', 'check', 'Match', 'Meteor', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', 'isOpenPermission', prefix + '\nreturn permission;');
// assertMayCreateBoard is CreateBleed's guard (tests/createBleed.test.cjs);
// here creating is allowed, so the private-only policy is seen alone.
const run = new AsyncFunction('payload', 'check', 'Match', 'Meteor', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', 'isOpenPermission', 'assertMayCreateBoard', prefix + '\nreturn permission;');
(async () => {
for (const privateOnly of [false, true]) {
for (const requested of ['private', 'public', 'instance']) {
const logs = [];
const settings = { findOneAsync: async key => ({ booleanValue: key === 'tableVisibilityMode-allowPrivateOnly' && privateOnly }) };
const result = await run.call({ userId: 'creator' }, { title: 'T', slug: 't', permission: requested }, () => {}, { ObjectIncluding: x => x, Maybe: x => x }, {}, settings, {}, () => ({ record: x => logs.push(x) }), isOpenPermission);
const result = await run.call({ userId: 'creator' }, { title: 'T', slug: 't', permission: requested }, () => {}, { ObjectIncluding: x => x, Maybe: x => x }, {}, settings, {}, () => ({ record: x => logs.push(x) }), isOpenPermission, async () => {});
assert.equal(result, privateOnly ? 'private' : requested);
assert.equal(logs.length, privateOnly && requested !== 'private' ? 1 : 0, 'only blocked public or instance requests are logged');
}
+3 -3
View File
@@ -163,7 +163,7 @@ test('negative: every server path that returns card data applies the board role,
const walk = dir => {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) { if (!['node_modules', '_build', '.build', 'tests'].includes(e.name)) walk(rel); }
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
else if (e.name.endsWith('.js')) files.push(rel);
}
};
@@ -203,7 +203,7 @@ test('activities, Chat view, the All Boards feed and position history read by th
const walk = dir => {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) { if (!['node_modules', '_build', '.build', 'tests'].includes(e.name)) walk(rel); }
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
else if (e.name.endsWith('.js') && /\$or: \[\{ assignees: [\w.]+ \}, \{ members: [\w.]+ \}\]/.test(read(rel))) offenders.push(rel);
}
};
@@ -300,7 +300,7 @@ test('custom roles: nothing sets the role flags without the custom role followin
const walk = dir => {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) { if (!['node_modules', '_build', '.build', 'tests'].includes(e.name)) walk(rel); }
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
else if (e.name.endsWith('.js') && /`members\.\$\{\w+\}\.is(NormalAssignedOnly|CommentAssignedOnly|ReadAssignedOnly|NoComments|CommentOnly|Worker|ReadOnly)`/.test(read(rel))) writers.push(rel);
}
};
+1 -1
View File
@@ -115,7 +115,7 @@ const WRITES_BOARD = /Boards\.(?:direct\.)?(?:update|updateAsync|upsert|upsertAs
function walk(dir, out = []) {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) walk(rel, out);
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
+45
View File
@@ -111,6 +111,51 @@ test('the smoke test fails when WeKan never answers, and passes when it does (ru
}
});
// v12.96 openbsd-arm64: smoke.sh said OK and returned with WeKan and FerretDB
// still running - start-wekan.sh defers its TERM trap until the foreground
// node exits - and FerretDB removed wekan.sqlite-wal/-shm while the VM's
// copyback rsync walked the workspace, failing the job with rsync exit 24.
// This launcher has start-wekan.sh's shape: FerretDB in the background, WeKan
// in the foreground, a TERM trap, a restart loop.
test('the smoke test leaves nothing running and nothing on disk when it returns (runs here)', () => {
const tmp = fs.mkdtempSync(path.join(ROOT, '.tools', 'tmp', 'bsd-smoke-'));
const bundle = path.join(tmp, 'bundle');
const tdir = path.join(tmp, 't');
const alive = () => spawnSync('pgrep', ['-f', `${bundle}/(ferretdb|main\\.js)`]).status === 0;
try {
fs.mkdirSync(bundle); fs.mkdirSync(tdir);
fs.writeFileSync(path.join(bundle, 'ferretdb'),
"const fs=require('fs'),p=require('path');const d=p.join(process.env.WRITABLE_PATH,'files','db');fs.mkdirSync(d,{recursive:true});"
+ "for(const s of ['','-wal','-shm'])fs.writeFileSync(p.join(d,'wekan.sqlite'+s),'x');"
+ "process.on('SIGTERM',()=>setTimeout(()=>{for(const s of ['-wal','-shm'])fs.rmSync(p.join(d,'wekan.sqlite'+s),{force:true});process.exit(0)},500));"
+ 'setInterval(()=>{},1000);\n');
fs.writeFileSync(path.join(bundle, 'main.js'),
"require('http').createServer((q,r)=>r.end('ok')).listen(process.env.PORT,'127.0.0.1');\n");
fs.writeFileSync(path.join(bundle, 'start-wekan.sh'), [
'#!/usr/bin/env bash',
'DIR="$(cd "$(dirname "$0")" && pwd)"',
'FERRET_PID=""',
'stop_ferret() { [ -n "$FERRET_PID" ] && kill "$FERRET_PID" 2>/dev/null || true; }',
"trap 'stop_ferret; exit 0' INT TERM",
'while true; do',
' "$NODE" "$DIR/ferretdb" &',
' FERRET_PID=$!',
' "$NODE" "$DIR/main.js" || true',
' stop_ferret; FERRET_PID=""; sleep 1',
'done', ''].join('\n'), { mode: 0o755 });
const res = spawnSync('bash', [path.join(ROOT, 'releases/bsd/smoke.sh'), bundle, '3199'],
{ encoding: 'utf8', env: { ...process.env, TMPDIR: tdir, NODE: process.execPath }, timeout: 120000 });
assert.equal(res.status, 0, res.stdout + res.stderr);
assert.match(res.stdout, /WeKan answered HTTP 200/);
assert.equal(alive(), false, 'negative: no WeKan or FerretDB left running after smoke.sh returned');
assert.deepEqual(fs.readdirSync(tdir).filter(n => n.startsWith('wekan-smoke.')), [],
'negative: no smoke-test files left for a copyback to find');
} finally {
spawnSync('pkill', ['-9', '-f', `${bundle}/(ferretdb|main\\.js)`]);
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('the resolver answers "not built" for a BSD nobody publishes yet (negative), without failing', () => {
const res = spawnSync('bash', [path.join(ROOT, 'releases/resolve-node-source.sh'), 'netbsd-x64', 'v26.11.1'], { encoding: 'utf8', timeout: 120000 });
if (res.error || /Could not resolve host|curl: \(6\)/.test(res.stderr)) return; // offline: nothing to check
+100 -18
View File
@@ -41,8 +41,9 @@ console.log('bundleNpmSecurityBumps:');
test('the manifest lists the packages the scan found, at fixed versions', () => {
const expected = {
'nodemailer': '8.0', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16', 'body-parser': '1.20',
'nodemailer': '10.3', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16.1', 'body-parser': '1.20.9', 'body-parser@2': '2.4',
'uuid': '11.1.1', 'postcss-selector-parser': '6.1.5',
'cookie': '0.7', 'on-headers': '1.1', 'tmp': '0.2', 'diff': '3.5',
'@babel/runtime': '7.', 'underscore': '1.13.8',
// Artifact Hub's Trivy report of the v12.38 image: same-major fixes.
@@ -57,25 +58,106 @@ test('the manifest lists the packages the scan found, at fixed versions', () =>
}
});
test('uuid is NOT bumped, and says why (negative)', () => {
// The one that was tried and reverted. If somebody adds it back, the bundle
// stops booting - so the reason lives where they would look.
assert.ok(!('uuid' in manifest.minimums), 'uuid must not be in minimums');
const reason = (manifest.notFixable.uuid || []).join(' ');
assert.ok(/Cannot find module/.test(reason) && /dist\/cjs\/index\.js/.test(reason),
'and notFixable carries the boot error it caused');
test('uuid is bumped to 11.1.1 and the entry point the bundle recorded is written back', () => {
// uuid 11 moved its entry from dist/index.js to dist/cjs/index.js, and a bundle
// that recorded dist/index.js died on boot ("Cannot find module
// .../uuid/dist/index.js"). The fix is a shim at the old path, not a refusal.
assert.strictEqual(manifest.minimums.uuid, '11.1.1');
const shim = (manifest.shims || {}).uuid || {};
assert.strictEqual(shim['dist/index.js'], "export * from './esm/index.js';\n",
'dist/index.js re-exports the ESM build (uuid is "type": "module"; a CJS shim would be parsed as ESM)');
assert.ok(/manifest\.shims/.test(bumper) && /writeFileSync\(file, body\)/.test(bumper),
'and the bumper writes it after every replacement');
// negative: nothing is described as unfixable that was fixed
for (const name of ['uuid', 'nodemailer']) {
assert.ok(!(name in manifest.notFixable), `${name} must not stay in notFixable`);
}
});
test('a fix that needs a new major is recorded in notFixable, never in minimums (negative)', () => {
for (const name of ['nodemailer', 'postcss-selector-parser', 'uuid']) {
const reason = (manifest.notFixable[name] || []).join(' ');
assert.ok(/major/.test(reason), `${name} says the fix is a new major`);
test('nodemailer 10 gets back the lib/ files meteor/email was built against', () => {
// v12.95's bundle failed its smoke boot: nodemailer 10 moved lib/ to dist/cjs/,
// and packages/email.js links the paths it recorded for 8.0.3 - "Cannot find
// module .../meteor/email/node_modules/nodemailer/lib/nodemailer.js" - and
// Npm.requires nodemailer/lib/mail-composer, which Meteor resolves by path.
const shim = (manifest.shims || {}).nodemailer || {};
const os = require('os');
const src = path.join(repoRoot, 'node_modules/nodemailer');
const pkg = JSON.parse(fs.readFileSync(path.join(src, 'package.json'), 'utf8'));
assert.ok(Number(pkg.version.split('.')[0]) >= 10, `node_modules/nodemailer ${pkg.version} has the 10 layout`);
// every lib/<name> directory nodemailer 10 still exports has its old file back,
// so no path a nodemailer 8 caller could have recorded is left unresolvable
const dirs = Object.entries(pkg.exports)
.filter(([key, target]) => /^\.\/lib\/[a-z0-9-]+$/.test(key)
&& target && target.require === `./dist/cjs/${key.slice(6)}/index.js`)
.map(([key]) => `lib/${key.slice(6)}/index.js`);
assert.ok(dirs.includes('lib/mail-composer/index.js') && dirs.includes('lib/well-known/index.js'));
for (const rel of ['lib/package.json', 'lib/nodemailer.js', ...dirs]) {
assert.ok(rel in shim, `nodemailer shim for ${rel}`);
}
// nodemailer stays at the newest 8.x; the 9.x/10.x fixes are named, not taken.
assert.ok(manifest.minimums.nodemailer.startsWith('8.'));
assert.ok(!('postcss-selector-parser' in manifest.minimums));
// lodash.template has had a fixed release (4.18.x) since 2026-04; it is no
// longer described as unfixable.
const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'nodemailer-shim-'));
try {
const shimmed = path.join(tmp, 'with', 'nodemailer');
const bare = path.join(tmp, 'without', 'nodemailer');
for (const [dir, files] of [[shimmed, Object.keys(shim)], [bare, ['lib/nodemailer.js']]]) {
fs.cpSync(src, dir, { recursive: true });
for (const rel of files) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), shim[rel]);
}
}
// the two paths the bundle recorded, required by file as Meteor's runtime does
assert.strictEqual(typeof require(path.join(shimmed, 'lib/nodemailer.js')).createTransport, 'function');
assert.strictEqual(typeof require(path.join(shimmed, 'lib/well-known/index.js')), 'function');
assert.strictEqual(typeof require(path.join(shimmed, 'lib/mail-composer')), 'function');
// and each forward is the module the exports map gives a require() caller
for (const rel of dirs) {
assert.strictEqual(require(path.join(shimmed, rel)),
require(path.join(shimmed, 'dist/cjs', rel.slice(4))), rel);
}
// negative: without lib/package.json the CommonJS shim is parsed as ESM
// (nodemailer is "type": "module") and does not load
assert.throws(() => require(path.join(bare, 'lib/nodemailer.js')));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('body-parser 1.x and 2.x are each raised inside their own major', () => {
assert.strictEqual(manifest.minimums['body-parser'], '1.20.9');
assert.strictEqual(manifest.minimums['body-parser@2'], '2.4.0');
const fs2 = require('fs'), os = require('os'), cp = require('child_process');
const bundle = fs2.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'bump-test-'));
const put = (dir, version) => {
const d = path.join(bundle, 'programs/server/npm/node_modules', dir);
fs2.mkdirSync(d, { recursive: true });
fs2.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version }));
};
put('meteor/a/node_modules/body-parser', '1.20.6');
put('meteor/b/node_modules/body-parser', '2.3.0');
put('meteor/c/node_modules/body-parser', '2.4.0');
put('meteor/d/node_modules/body-parser', '1.20.9');
put('meteor/e/node_modules/uuid', '8.3.2');
put('meteor/f/node_modules/uuid', '14.0.2');
put('meteor/g/node_modules/nodemailer', '8.0.11');
const out = cp.execFileSync(process.execPath,
[path.join(repoRoot, 'releases/bump-bundle-npm-deps.mjs'), bundle, '--dry-run'], { encoding: 'utf8' });
fs2.rmSync(bundle, { recursive: true, force: true });
const lines = out.split('\n').filter(l => /->/.test(l));
const has = re => lines.some(l => re.test(l));
assert.ok(has(/body-parser 1\.20\.6 -> 1\.20\.9/), '1.x below 1.20.9 is raised to 1.20.9');
assert.ok(has(/body-parser 2\.3\.0 -> 2\.4\.0/), '2.3.0 is raised to 2.4.0');
// negative: neither crosses into the other major, and fixed copies are left alone
assert.ok(!has(/body-parser 1\.\d+\.\d+ -> 2\./) && !has(/body-parser 2\.\d+\.\d+ -> 1\./));
assert.ok(!has(/body-parser 2\.4\.0 ->/) && !has(/body-parser 1\.20\.9 ->/));
assert.ok(has(/uuid 8\.3\.2 -> 11\.1\.1/) && !has(/uuid 14\./), 'uuid 14 is above the minimum');
assert.ok(has(/nodemailer 8\.0\.11 -> 10\.3\.1/));
});
test('a fix that needs a new major its dependents cannot take is recorded in notFixable (negative)', () => {
const reason = (manifest.notFixable['postcss-selector-parser'] || []).join(' ');
assert.ok(/major/.test(reason) && /CVE-2026-104844/.test(reason));
assert.ok(manifest.minimums['postcss-selector-parser'] === '6.1.5',
'but the same-major fix that exists is taken');
assert.ok(!('lodash.template' in manifest.notFixable));
for (const [name, version] of Object.entries(manifest.minimums)) {
assert.ok(/^\d+\.\d+\.\d+$/.test(version), `${name} is pinned to an exact version`);
+26
View File
@@ -116,3 +116,29 @@ assert.equal(element.style.left, '400px');
events['keydown .js-date-popup-resize']({ ...event, key: 'ArrowRight' });
assert.equal(element.style.width, '420px', 'right arrow shrinks the left-hand RTL grip');
console.log('RTL resize reverses horizontal input and keeps the right edge anchored');
// A popup that GROWS while it is dragged stays inside the window. A long menu
// (the member menu) gets its 960px column layout a frame after it is drawn
// (client/lib/popupMenuLayout.js); a drag begun in that frame clamped with the
// 380px it started at, so the grown popup ended at left 850 and mostly outside
// a 1280px window (card-title-edit-and-move-6839.e2e.js, memberMenuPopup).
context.window.getComputedStyle = () => ({ direction: 'ltr' });
element.querySelector = () => null;
element.style.width = '380px';
element.style.height = '400px';
events['pointerdown .header'](moveEvent, tpl);
assert.ok(tpl._dateMove, 'the drag begins at the narrow size');
element.style.width = '960px'; // the column layout arrives mid-drag
events['pointermove .header']({ ...moveEvent, clientX: moveEvent.clientX + 1000 }, tpl);
assert.equal(element.style.left, `${1280 - 960 - 12}px`,
'the clamp uses the size the popup has now, so all of it stays in the window');
events['pointermove .header']({ ...moveEvent, clientX: moveEvent.clientX - 10000 }, tpl);
assert.equal(element.style.left, '12px', 'and the left edge too');
events['pointerup .header, pointercancel .header, lostpointercapture .header'](moveEvent, tpl);
// Negative: a popup that does not change size moves exactly as dragged.
element.style.width = '400px';
events['pointerdown .header'](moveEvent, tpl);
events['pointermove .header']({ ...moveEvent, clientX: moveEvent.clientX - 40 }, tpl);
assert.equal(element.style.left, `${440 - 40}px`, 'an unchanged popup follows the pointer');
events['pointerup .header, pointercancel .header, lostpointercapture .header'](moveEvent, tpl);
console.log('A popup that grows during a drag is clamped by its current size');
+1 -1
View File
@@ -355,7 +355,7 @@ function trippedCanaryIds() {
const found = new Map();
const walk = dir => {
for (const entry of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
if (['node_modules', '_build', '.npm', '.build'].includes(entry.name)) continue;
if ((['node_modules', '_build', '.npm', '.build'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) walk(rel);
else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
+28
View File
@@ -0,0 +1,28 @@
'use strict';
// A phone in desktop mode: the opened card starts below the (multi-row) header bar and a
// dragged place is kept. Run: node tests/cardBelowHeaderOnPhone.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const css = fs.readFileSync(path.join(__dirname, '..', 'client/components/cards/cardDetails.css'), 'utf8');
const block = /@media screen and \(max-width: 800px\) \{\n body\.desktop-mode \.card-details:not\(\.card-details-popup\):not\(\[style\*="top"\]\),[^{]*\{([^}]*)\}/.exec(css);
test('below 801px the desktop-mode card starts at the measured header height', () => {
assert.ok(block, 'the phone rule exists');
assert.match(block[1], /top: calc\(var\(--wekan-header-height, 0px\) \+ 8px\) !important;/);
});
test('negative: a card with an inline top (dragged) never matches it, so the drag sticks', () => {
const selectors = css.slice(css.indexOf(block[0]), css.indexOf(block[1])).replace(/\/\*.*?\*\//g, '').split(',');
assert.ok(selectors.every(s => /:not\(\[style\*="top"\]\)|card-details-maximized/.test(s)));
// the variable is published by Utils, so the rule has a source
assert.match(fs.readFileSync(path.join(__dirname, '..', 'client/lib/utils.js'), 'utf8'), /--wekan-header-height/);
});
test('a card opened as a popup starts below the header too, not at the top of the window', () => {
assert.ok(block[0].includes('.card-details.card-details-popup:not([style*="top"])'));
});
+1 -1
View File
@@ -253,7 +253,7 @@ await test('every retireCardDropPreview call passes the node to hide', async ()
const files = [];
(function walk(dir) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (['node_modules', '_build', '.build', '.tools', '.git', '.meteor', 'tests'].includes(e.name)) continue;
if ((['node_modules', '_build', '.build', '.tools', '.git', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
const f = path.join(dir, e.name);
if (e.isDirectory()) walk(f);
else if (/\.js$/.test(e.name)) files.push(f);
+1 -1
View File
@@ -190,7 +190,7 @@ test('the server saves it in the site-admin-only Features method (negative: no o
const writers = [];
const walk = dir => {
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
if (['node_modules', '.meteor', '_build', '.build', '.tools'].includes(entry.name)) continue;
if ((['node_modules', '.meteor', '_build', '.build', '.tools'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) walk(rel);
// A projection - `cardFieldStates: 1` in a fields/publication list - only
+41
View File
@@ -0,0 +1,41 @@
'use strict';
// The opened card covers the header bar on an iPad. iOS WebKit (iPad Safari and Chrome)
// makes every scrolling element a stacking context, so the card's z-index is compared
// only inside #content, and the header bar (#header-quick-access, z-index 1000, a flex
// item of body) was painted over the card's top row. While a card is open the header
// drops to the level of the content. tests/playwright/specs/card-over-header-ios-stacking
// .e2e.js simulates the trap and shows it in a browser.
//
// Run: node tests/cardOverHeaderStacking.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const header = read('client/components/main/header.css');
test('while a card is open the header bar is at the level of the content', () => {
assert.match(header, /body:has\(\.card-details\) #header-quick-access \{\n z-index: 0;\n\}/);
// negative: it is conditional - the header keeps 1000 when no card is open
assert.match(header, /\n#header-quick-access \{[^}]*z-index: 1000;/);
assert.doesNotMatch(header.replace(/body:has\(\.card-details\) #header-quick-access \{\n z-index: 0;\n\}/, ''), /#header-quick-access \{[^}]*z-index: 0;/);
});
test('it names the header that exists: #header-quick-access, not the old #header', () => {
const rule = /body:has\(\.card-details\) (#[\w-]+)/.exec(header);
assert.equal(rule[1], '#header-quick-access');
assert.ok(read('client/components/main/header.jade').includes('#header-quick-access'), 'the template draws it');
// the card's own level is above the header's normal one, so a browser without the trap needs nothing
const card = read('client/components/cards/cardDetails.css');
assert.match(card, /body\.desktop-mode \.card-details:not\(\.card-details-popup\) \{[^}]*z-index: 2001;/);
});
test('the content is the later sibling that paints over the header at the same level', () => {
// #content comes after the header in the page and is positioned, so at z-index 0 against 0
// it is painted last, which is what puts the card over the header's second row as well.
const layouts = read('client/components/main/layouts.css');
assert.match(layouts, /\n#content \{\n position: relative;\n flex: 1;/);
});
+1 -1
View File
@@ -118,7 +118,7 @@ test('a member, and anybody on a public board, still get the card', async () =>
const CARD_GUARD = /isVisibleBy\(|readableCard\(|editableCard\(/;
function walk(dir, out = []) {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) walk(rel, out);
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
+13
View File
@@ -462,4 +462,17 @@ test('CLAUDE.md states these rules, so they are not folklore', () => {
assert.ok(/release summary → topic summary → commit\s+detail/.test(claude));
});
test('CLAUDE.md and AGENTS.md keep changelogs and direct commits to maintainer mode', () => {
for (const doc of ['CLAUDE.md', 'AGENTS.md']) {
const s = read(doc);
assert.ok(/Changelogs are updated only in maintainer mode/.test(s), doc);
assert.ok(/only mode that\s+updates changelogs/.test(s), doc);
assert.ok(/One feature or one fix per pull request/.test(s), doc);
assert.ok(/\*\*No changelog changes\.\*\*/.test(s), doc);
// "good practice for everyone" used to include the CHANGELOG structure, which
// told a contributor to write the entries a pull request must now leave out.
assert.ok(!/The rest \([^)]*CHANGELOG[^)]*\) is\s+good practice for everyone/.test(s), doc);
}
});
console.log(`\n${passed} tests passed`);
+1 -1
View File
@@ -35,7 +35,7 @@ test('negative: every delete path goes through the collection, so the hook sees
// removes the card's items itself in the same breath.
const walk = dir => fs.readdirSync(path.join(__dirname, '..', dir), { withFileTypes: true }).flatMap(e => {
const rel = path.join(dir, e.name);
if (e.isDirectory()) return ['node_modules', '_build', '.build', '.tools', 'tests'].includes(e.name) ? [] : walk(rel);
if (e.isDirectory()) return (['node_modules', '_build', '.build', '.tools', 'tests'].includes(e.name) || e.name.startsWith('_build')) ? [] : walk(rel);
return /\.js$/.test(e.name) ? [rel] : [];
});
for (const file of [...walk('client'), ...walk('server'), ...walk('models')]) {
+1 -1
View File
@@ -130,7 +130,7 @@ const BINDS_BOARD = /boardId(?::\s*paramBoardId|\s*[!=]==\s*paramBoardId)/;
function walk(dir, out = []) {
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) walk(rel, out);
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// #6861, item 3: with several WeKan servers on one database exactly ONE streams the continuous
// backup, and another takes over when it stops. Run: node tests/continuousBackupLease.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease } = require('../models/lib/backupLease');
// A collection with the atomic semantics the lease relies on: every call is ONE step, and an insert
// of an existing _id is a duplicate key. (Each method runs to completion before the next starts, as
// a database's single-document operations do.)
function fakeCollection() {
const docs = new Map();
const matches = (doc, filter, now) => Object.entries(filter).every(([key, want]) => {
if (key === '$or') return want.some(branch => matches(doc, branch, now));
const have = doc[key];
if (want && typeof want === 'object' && !(want instanceof Date)) {
if ('$lte' in want) return have instanceof Date && have <= want.$lte;
return false;
}
if (want === null) return have === null || have === undefined;
return have === want;
});
return {
docs,
async insertOne(doc) {
await Promise.resolve();
if (docs.has(doc._id)) throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
docs.set(doc._id, { ...doc });
return { insertedId: doc._id };
},
async updateOne(filter, update) {
await Promise.resolve();
const doc = docs.get(filter._id);
if (!doc || !matches(doc, filter)) return { matchedCount: 0, modifiedCount: 0 };
Object.assign(doc, update.$set);
return { matchedCount: 1, modifiedCount: 1 };
},
};
}
const at = ms => new Date(1700000000000 + ms);
test('24 servers acquiring at once: exactly one leads', async () => {
const c = fakeCollection();
const results = await Promise.all(Array.from({ length: 24 }, (_, i) => acquireLease(c, `server-${i}`, at(0))));
assert.equal(results.filter(Boolean).length, 1);
assert.equal(c.docs.size, 1, 'one lease document');
});
test('the leader renews, and the others stay out while the lease is alive', async () => {
const c = fakeCollection();
assert.equal(await acquireLease(c, 'a', at(0)), true);
assert.equal(await acquireLease(c, 'b', at(RENEW_MS)), false, 'b cannot take a live lease');
assert.equal(await acquireLease(c, 'a', at(RENEW_MS)), true, 'a renews');
assert.equal(c.docs.get(LEASE_ID).expiresAt.getTime(), at(RENEW_MS).getTime() + TTL_MS, 'and the expiry moves');
assert.equal(await acquireLease(c, 'b', at(2 * RENEW_MS)), false, 'still a\'s after the renewal');
});
test('failover: a leader that stops renewing is replaced once its lease has expired', async () => {
const c = fakeCollection();
await acquireLease(c, 'a', at(0));
assert.equal(await acquireLease(c, 'b', at(TTL_MS - 1)), false, 'one millisecond before it lapses');
assert.equal(await acquireLease(c, 'b', at(TTL_MS)), true, 'when it lapses b leads');
assert.equal(await acquireLease(c, 'a', at(TTL_MS + 1)), false, 'and the old leader finds it lost it');
});
test('release lets the next server lead at once; only the holder can release', async () => {
const c = fakeCollection();
await acquireLease(c, 'a', at(0));
await releaseLease(c, 'b');
assert.equal(await acquireLease(c, 'c', at(1)), false, 'b was not the holder, so nothing was released');
await releaseLease(c, 'a');
assert.equal(await acquireLease(c, 'c', at(1)), true, 'a released');
});
test('takeover: a restore becomes the only writer, and the former leader loses the lease', async () => {
const c = fakeCollection();
await acquireLease(c, 'leader', at(0));
const { waitMs } = await takeoverLease(c, 'restorer', at(5));
assert.equal(waitMs, RENEW_MS, 'it waits one renewal so the former leader has stopped');
assert.equal(await acquireLease(c, 'leader', at(RENEW_MS)), false, 'the former leader cannot renew');
assert.equal(await acquireLease(c, 'restorer', at(RENEW_MS)), true);
assert.equal((await takeoverLease(fakeCollection(), 'first', at(0))).waitMs, 0, 'with nobody to wait for there is no wait');
});
test('other database errors are not swallowed (negative)', async () => {
const broken = { async insertOne() { throw new Error('connection lost'); }, async updateOne() { return {}; } };
await assert.rejects(() => acquireLease(broken, 'a', at(0)), /connection lost/);
await assert.rejects(() => takeoverLease(broken, 'a', at(0)), /connection lost/);
});
test('negative: the lease never upserts (an unmatched upsert must insert, and what then happens is the database\'s)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'models/lib/backupLease.js'), 'utf8');
assert.doesNotMatch(src.replace(/\/\/.*$/gm, ''), /upsert/);
});
test('continuousBackup.js runs the stream through the lease, on every path', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server/continuousBackup.js'), 'utf8');
// the only direct apply() is inside the leader loop, after acquireLease said this server leads
const applies = src.split('\n').filter(l => /backupManager\(\)\.apply\(/.test(l));
assert.equal(applies.length, 1, 'one apply(), in evaluateLease');
assert.match(src, /leading = await acquireLease\(leaseCollection\(\), serverId\);\s*\n\s*if \(!leading\) \{[^}]*backupManager\(\)\.stop\(\)/);
assert.match(src, /async function evaluateLease\(\) \{\s*\n\s*if \(restoring\) return;/);
// startup and saveSettings converge through the loop; restore and fetchFromCloud take the lease first
assert.match(src, /Meteor\.startup\(async \(\) => \{\s*try \{\s*await evaluateLease\(\);\s*startLeaseLoop\(\);/);
assert.match(src, /await evaluateLease\(\);\s*\n\s*return \{ \.\.\.backupManager\(\)\.status\(\), leading \};/);
assert.equal((src.match(/await becomeOnlyWriter\(\);/g) || []).length, 2, 'restore and fetchFromCloud');
assert.match(src, /const wasLeader = leading;[\s\S]*if \(!wasLeader\) await new Promise/);
// a settings change another server saved is followed by the leader
assert.match(src, /stamp !== appliedStamp/);
});
+1 -1
View File
@@ -48,7 +48,7 @@ test('NEGATIVE: every place that copies subtasks filters them, nowhere copies th
const files = [];
const walk = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (['node_modules', '.meteor', '.tools', '_build', '.build', 'tests', '.git'].includes(entry.name)) continue;
if ((['node_modules', '.meteor', '.tools', '_build', '.build', 'tests', '.git'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.js')) files.push(full);
+229
View File
@@ -0,0 +1,229 @@
'use strict';
// CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: "Only admins can create boards"
// (Admin Panel -> Settings -> Visibility,
// tableVisibilityMode-boardCreationAdminOnly) was checked only inside
// createBoardWithInitialSwimlanes. A non-admin still created boards by a
// client Boards.insert (Boards.allow.insert approves any signed-in user),
// POST /api/boards and both board import routes - the report - and, found
// with it, importBoard, cloneBoard, copyBoard, POST /api/boards/:boardId/copy
// and createBoardFromCard.
//
// * the decision (models/lib/boardCreationPolicy.js), as arithmetic, with the
// reporter's case exactly;
// * the guard (server/lib/boardCreationGuard.js), run for real in a vm:
// refused, recorded in Admin Panel -> Problems, a 403 for REST;
// * the REAL deny rule of server/permissions/boards.js against the
// reporter's console insert;
// * negative: every path in the tree that makes a working board for a user
// asks the guard, and nothing else reads the setting.
// Run: node tests/createBleed.test.cjs
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
const { boardCreationAllowed } = require('../models/lib/boardCreationPolicy');
const { categoryFor } = require('../models/lib/securityCategories');
test('the decision: with the setting on, only an admin creates a working board', () => {
// The report: an admin turns the setting on; user u is not an admin.
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: false, type: 'board' }), false);
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: undefined }), false, 'no type is a working board');
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: 'true', type: 'board' }), false, 'only isAdmin === true counts');
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: false, type: 'template-container' }), false);
// Allowed: an admin, the setting off, and a member's own board template.
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: true, type: 'board' }), true);
assert.equal(boardCreationAllowed({ adminOnly: false, isAdmin: false, type: 'board' }), true);
assert.equal(boardCreationAllowed({ adminOnly: undefined, isAdmin: false }), true, 'no setting document is off');
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: false, type: 'template-board' }), true);
});
// server/lib/boardCreationGuard.js, its imports stubbed.
function loadGuard({ adminOnly, user }) {
const recorded = [];
const exports = {};
const src = read('server/lib/boardCreationGuard.js')
.replace(/^import \{ Meteor \} from .*$/m, '')
.replace(/^import \{ ReactiveCache \} from .*$/m, '')
.replace(/^import TableVisibilityModeSettings from .*$/m, '')
.replace(/^export async function /gm, 'exports.$fn = async function ')
.replace(/exports\.\$fn = async function (\w+)/g, 'exports.$1 = async function $1');
assert.ok(!/^import /m.test(src), 'every import of the guard is stubbed');
class MeteorError extends Error { constructor(error, reason) { super(reason); this.error = error; this.reason = reason; } }
vm.runInNewContext(src, {
exports, console,
Meteor: { Error: MeteorError },
ReactiveCache: { getUser: async id => (user && user._id === id ? user : null) },
TableVisibilityModeSettings: {
findOneAsync: async id => (id === 'tableVisibilityMode-boardCreationAdminOnly' && adminOnly !== undefined ? { booleanValue: adminOnly } : null),
},
require: spec => {
if (spec === '/server/lib/securityLog') return { record: evt => recorded.push(evt) };
if (spec === '/models/lib/boardCreationPolicy') return require('../models/lib/boardCreationPolicy');
throw new Error(`unexpected require ${spec}`);
},
});
return { ...exports, recorded };
}
test('the guard refuses a non-admin, records it, and answers REST with 403', async () => {
const g = loadGuard({ adminOnly: true, user: { _id: 'u', isAdmin: false } });
await assert.rejects(g.assertMayCreateBoard('u', { source: 'rest:POST /api/boards', type: 'board' }), error => {
assert.equal(error.error, 'not-authorized');
assert.equal(error.statusCode, 403, 'a REST route answers 403, not 500');
return true;
});
assert.equal(await g.mayCreateBoard('u', { source: 'ddp:boards.insert' }), false);
assert.equal(g.recorded.length, 2);
for (const evt of g.recorded) {
assert.equal(evt.key, 'authz.board-creation');
assert.equal(evt.action, 'blocked');
assert.equal(evt.userId, 'u');
}
assert.deepEqual(g.recorded.map(e => e.source), ['rest:POST /api/boards', 'ddp:boards.insert']);
});
test('negative: the guard lets an admin, a template and an instance without the setting through, unrecorded', async () => {
const admin = loadGuard({ adminOnly: true, user: { _id: 'a', isAdmin: true } });
await admin.assertMayCreateBoard('a', { source: 's', type: 'board' });
assert.equal(await admin.mayCreateBoard('a', { source: 's' }), true);
const member = loadGuard({ adminOnly: true, user: { _id: 'u', isAdmin: false } });
assert.equal(await member.mayCreateBoard('u', { source: 's', type: 'template-board' }), true);
const off = loadGuard({ adminOnly: false, user: { _id: 'u', isAdmin: false } });
await off.assertMayCreateBoard('u', { source: 's', type: 'board' });
const unset = loadGuard({ adminOnly: undefined, user: { _id: 'u', isAdmin: false } });
assert.equal(await unset.mayCreateBoard('u', { source: 's' }), true);
assert.equal(admin.recorded.length + member.recorded.length + off.recorded.length + unset.recorded.length, 0,
'only a refusal is an attempt; ordinary creation is not logged');
});
test('the reporter\'s console insert is refused by the real deny rule; a template insert is not', async () => {
const denies = [];
const src = read('server/permissions/boards.js')
.replace(/^import Boards from .*$/m, '')
.replace(/^import TableVisibilityModeSettings from .*$/m, 'const TableVisibilityModeSettings = { findOneAsync: async () => null };')
.replace(/^import \{ findWhere, where \} from .*$/m, 'const where = () => []; const findWhere = () => undefined;')
.replace(/^import \{[^}]*\} from '\/server\/lib\/utils';$/m,
'const allowIsBoardAdminOrSiteAdmin = () => true; const canUpdateBoardSort = () => false; const canUpdateBoardSameWidthValue = () => false;')
.replace(/^import \{[^}]*\} from '\/server\/lib\/subtaskDepositAccess';$/m,
'const canWriteSubtaskDeposit = async () => true; const recordSubtaskDepositDenial = () => {};')
.replace(/^import \{ mayCreateBoard \} from '\/server\/lib\/boardCreationGuard';$/m, '');
assert.ok(!/^import /m.test(src), 'every import of server/permissions/boards.js is stubbed');
const guard = loadGuard({ adminOnly: true, user: { _id: 'u', isAdmin: false } });
vm.runInNewContext(src, {
Boards: { deny: rules => denies.push(rules), allow: () => {} }, console,
mayCreateBoard: guard.mayCreateBoard,
require: spec => require(path.join(ROOT, spec)),
});
const refused = async doc => {
for (const rules of denies) if (rules.insert && await rules.insert('u', doc)) return true;
return false;
};
// Boards.insert({ title: 'x', permission: 'private' }) from the report.
assert.equal(await refused({ title: 'x', permission: 'private' }), true);
assert.equal(await refused({ title: 'x', permission: 'private', type: 'board' }), true);
assert.equal(guard.recorded[0].source, 'ddp:boards.insert');
// The one insert the client makes: a board template in the Templates board.
assert.equal(await refused({ title: 't', permission: 'private', type: 'template-board' }), false);
});
// Each path that makes a new working board for a user, and what it must say.
const PATHS = [
['server/models/boards.js', /async createBoardWithInitialSwimlanes\(/, /assertMayCreateBoard\(this\.userId, \{ source: 'createBoardWithInitialSwimlanes', type \}\)/],
['server/models/boards.js', /WebApp\.handlers\.post\('\/api\/boards', /, /assertMayCreateBoard\(req\.userId, \{ source: 'rest:POST \/api\/boards'/],
['server/models/boards.js', /WebApp\.handlers\.post\('\/api\/boards\/:boardId\/copy', /, /assertMayCreateBoard\(req\.userId, \{ source: 'rest:POST \/api\/boards\/:boardId\/copy'/],
['server/permissions/boards.js', / async insert\(userId, doc\) \{/, /mayCreateBoard\(userId, \{ source: 'ddp:boards\.insert', type: doc\.type \}\)/],
['models/import.js', /async importBoard\(/, /assertMayCreateBoardOnServer\(this\.userId, 'method:importBoard'\)/],
['models/import.js', /async cloneBoard\(/, /assertMayCreateBoardOnServer\(this\.userId, 'method:cloneBoard'\)/],
['server/publications/boards.js', /async copyBoard\(/, /assertMayCreateBoard\(this\.userId, \{ source: 'method:copyBoard'/],
['server/models/cards.js', /async createBoardFromCard\(/, /assertMayCreateBoard\(this\.userId, \{ source: 'method:createBoardFromCard'/],
];
// The body of the function or handler that starts at `start`: up to the next
// top-level method, handler or Boards.deny/allow block.
function bodyAt(text, start) {
const at = text.search(start);
assert.notEqual(at, -1, `${start} not found`);
const rest = text.slice(at + 1);
const next = rest.search(/\n(?: async \w+\(|WebApp\.handlers\.|Boards\.(?:deny|allow)\(|\}\);\n)/);
return text.slice(at, at + 1 + (next === -1 ? rest.length : next));
}
test('every path that makes a working board asks the guard before it writes', () => {
for (const [file, start, call] of PATHS) {
const body = bodyAt(read(file), start);
assert.match(body, call, `${file}: ${start}`);
}
// The REST import routes reach importBoard, which asks.
const boardsJs = read('server/models/boards.js');
for (const route of ["'/api/boards/import'", "'/api/boards/import/:source'"]) {
const body = bodyAt(boardsJs, new RegExp(`WebApp\\.handlers\\.post\\(${route.replace(/[/:]/g, m => `\\${m}`)}, `));
assert.match(body, /Meteor\.callAsync\('importBoard'/, `${route} imports through importBoard`);
}
// models/import.js's helper really calls the guard on the server.
assert.match(read('models/import.js'), /async function assertMayCreateBoardOnServer\(userId, source\) \{\s*if \(!Meteor\.isServer\) return;\s*const \{ assertMayCreateBoard \} = require\('\/server\/lib\/boardCreationGuard'\);\s*await assertMayCreateBoard\(userId, \{ source, type: 'board' \}\);/);
});
// Files that make boards WeKan creates by itself, or the shared copy and
// import machinery the guarded paths above call. A NEW Boards.insert
// elsewhere must either ask the guard or be added here with its reason.
const SYSTEM_CREATORS = {
'server/models/users.js': 'the Templates container a user gets at sign-up (ensureTemplatesBoardForUserId)',
'models/boards.js': 'Board.copy(), called by the guarded copyBoard, REST copy and the template-only saveBoardAsTemplate; and getDefaultSubtasksBoardAsync, a board\'s own subtasks board',
'models/csvCreator.js': 'an import creator, reached through the guarded importBoard',
'models/kanboardCreator.js': 'an import creator, reached through the guarded importBoard',
'models/jiraCreator.js': 'an import creator, reached through the guarded importBoard',
};
function sources(dir, out = []) {
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
if (['node_modules', '.tools', '.build', '.meteor', '.git', 'tests', 'packages'].includes(entry.name) || entry.name.startsWith('_build')) continue;
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) sources(rel, out);
else if (/\.js$/.test(entry.name)) out.push(rel);
}
return out;
}
test('negative: no other server code creates a board without the guard', () => {
const files = [...sources('server'), ...sources('models')];
const unguarded = [];
for (const file of files) {
const text = read(file);
if (!/Boards\.(?:direct\.)?insert(?:Async)?\(/.test(text)) continue;
if (/assertMayCreateBoard|mayCreateBoard/.test(text)) continue;
if (SYSTEM_CREATORS[file]) continue;
unguarded.push(file);
}
assert.deepEqual(unguarded, [],
'these files insert boards with no CreateBleed guard and no reason in SYSTEM_CREATORS');
});
test('negative: the setting is read in one place on the server, the guard', () => {
const readers = [...sources('server'), ...sources('models')]
// In code, not in a comment that names it.
.filter(file => /tableVisibilityMode-boardCreationAdminOnly/.test(read(file).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')))
.filter(file => file !== 'server/models/collectionBootstrap.js'); // creates the setting's document
assert.deepEqual(readers, ['server/lib/boardCreationGuard.js']);
});
test('the refusal is in Admin Panel -> Problems under CreateBleed, and blocks no account', () => {
const cat = categoryFor('authz.board-creation');
assert.equal(cat.bleed, 'CreateBleed');
assert.equal(cat.cwe, 'CWE-863');
assert.equal(cat.blocksAccount, false, 'a tab opened before the switch is no attacker');
});
test('the UI offers no new-board control to a non-admin while it is on', () => {
const helper = read('client/lib/boardCreationAllowed.js');
assert.match(helper, /Template\.registerHelper\('boardCreationAllowed', \(\) => boardCreationAllowed\(\)\)/);
assert.match(read('client/components/boards/allBoardsSidebar.jade'),
/if boardCreationAllowed\n\s+a\.sidebar-btn\.js-duplicate-selected-boards/);
assert.match(read('client/components/cards/cardDetails.jade'),
/if boardCreationAllowed\n\s+li\n\s+a\.js-create-board-from-card/);
});
+1 -1
View File
@@ -103,7 +103,7 @@ test('CustomFieldBleed: the selector logic itself refuses a field of another boa
test('CustomFieldBleed negative: no REST handler anywhere writes a custom field by _id alone', () => {
const walk = dir => fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true }).flatMap(e => {
if (['tests', 'node_modules', '_build', '.build'].includes(e.name)) return [];
if ((['tests', 'node_modules', '_build', '.build'].includes(e.name) || e.name.startsWith('_build'))) return [];
const rel = `${dir}/${e.name}`;
return e.isDirectory() ? walk(rel) : (/\.(c|m)?js$/.test(rel) ? [rel] : []);
});
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more