Compare commits

..
23 Commits
Author SHA1 Message Date
github-actions[bot] 77239ee100 Bump versions for v13.06 2026-11-10 22:50:32 +00:00
Lauri Ojansivu 3913fbd507 Prepare v13.06 release 2026-11-11 00:49:29 +02:00
Lauri Ojansivu c45359a759 CHANGELOG: CodeQL alert #601 and its siblings in the tests; the oldest October release archived to stay under GitHub's limit.
Thanks to GitHub CodeQL and xet7 !
2026-11-11 00:38:03 +02:00
Lauri Ojansivu d482856e03 Tests: no path is quoted into a bash -c script, CodeQL alert #601 and its siblings.
CodeQL flagged js/shell-command-injection-from-environment (CWE-78/88) in
tests/npmRetry.test.cjs: its bash -c script set
node_bin=${JSON.stringify(prog)} and ran bash ${JSON.stringify(script)},
pasting a path under the temporary directory and one under the checkout
into the script text. JSON's quotes are not the shell's: a path with $(,
a backtick or $ would change the command. The paths now go in through
the environment ("$NODE_BIN", "$NPM_RETRY_SCRIPT"), so the script is a
constant - and NODE_BIN is not node_bin, so the test still shows the
unbound variable it exists to show.

The guard written for alerts #549 and #550 (tests/testShellPathsFromEnv
.test.cjs) only knew `source "${path}"`. It now refuses any value quoted
into a -c script - "${...}" or ${JSON.stringify(...)} - across the tests,
while a script built from the test's own snippets stays allowed; with
the fixes taken away it names every offender. It found the same fault in
two more places, fixed as arguments ("$1", "$2"):
tests/releaseDownloads.test.cjs, which quoted fetch.sh and a temporary
file into its umask tests, and tests/macosSandbox.test.cjs, which quoted
the home and temporary paths into its sandbox probe.

The finding was in tests, not in WeKan, so there is no attempt to show
in Admin Panel -> Problems.

Thanks to GitHub CodeQL and xet7 !
2026-11-11 00:37:39 +02:00
Lauri Ojansivu 72ee10a3ed CHANGELOG: the merged REST overtime and card member fixes, their follow-ups, and cleared card dates.
Thanks to AhmedLukman and xet7 !
2026-11-11 00:08:21 +02:00
Lauri Ojansivu 32590ee7ab Card members and assignees: adding one whose account is gone no longer throws.
#6889 guarded the removal halves of the cardMembers and cardAssignees
hooks, which read user.username after a lookup that finds nobody for a
deleted account. The joining halves had the same read: the REST route
checks the user before adding, but a client $addToSet, a copied or
imported card, or a rule can still add such an id, and the hook threw.
Both now record the join with the id and no username, as a removal does.

tests/cardMemberRemoval.test.cjs runs the real hooks: adding a member
and an assignee whose account is gone records the join without
throwing; and, tree-wide, no `const x = user.field;` follows a
ReactiveCache.getUser() lookup without a guard.

Thanks to AhmedLukman and xet7 !
2026-11-11 00:07:53 +02:00
Lauri Ojansivu 569756b66c REST card overtime: an isOverTime stored before #6888 becomes isOvertime.
#6888 makes the REST card PUT write its isOverTime parameter to the
schema's isOvertime field. Every overtime set through the API before it
- since b895e61237 - was stored under isOverTime, which the UI and every
reader ignore: those cards still show no overtime.

A schema upgrade step, card-overtime-field, carries that over on the
next start: isOverTime becomes isOvertime where the card has none, the
value the UI already shows wins where it has both, and the stray field
is removed. Two updateMany batches and an $unset, version-gated and
idempotent like the steps beside it.

tests/schemaUpgradeSteps.test.cjs pins the fold, the precedence and a
second pass finding nothing; as negatives, no work on cards without the
field, and - tree-wide - no server code writing isOverTime into a card.

Thanks to AhmedLukman and xet7 !
2026-11-11 00:07:26 +02:00
Lauri Ojansivu 50334816ca Card dates: clearing one no longer goes through the timing hook as a set.
The Cards.before.update timing hook read modifier.$set[field] for any
update whose first field was a date, but unsetDue/unsetStart/unsetEnd/
unsetReceived send only a $unset. Without a $set the hook threw a
TypeError; with one beside the $unset (a stamped modifiedAt) it wrote an
a-dueAt with timeValue: undefined, which counted as a due date change
(#6081), matched the "date set or changed" rule triggers (#2474), and
showed in the activity feed as an empty date.

server/lib/timingChangeActivity.js now decides it: a date set records
timeValue, a date cleared records timeOldValue alone - the same record a
rule's removeDate writes, and the one models/lib/boardTimeline.js rewinds
- and clearing a date the card does not have records nothing. The rules
skip a cleared date before matching triggers, as server/triggersDef.js
already said they should, and the feed shows only the date it had.

tests/cardTimingActivity.test.cjs runs the real hook: a set, a $unset
alone and a $unset beside a $set (both used to fail), and clearing
nothing; and, tree-wide, no modifier.$set[...] read that assumes the $set
is there. The two guards that pinned the old inline list and its
modifier.$set[action] read now pin the helper instead.

Thanks to xet7 !
2026-11-11 00:06:31 +02:00
Lauri Ojansivu 9821478329 Merge pull request #6889 from AhmedLukman/codex/card-member-removal
Fix removal of card members and assignees with missing user accounts
2026-11-10 23:49:48 +02:00
Lauri Ojansivu ad5c35e9f3 Merge pull request #6888 from AhmedLukman/codex/rest-card-overtime
Fix REST card overtime persistence
2026-11-10 23:49:23 +02:00
Ahmed Lukman 416c69dfe2 Revert "Use optional chaining for removed assignment users"
This reverts commit 0acc9955d0.

Thanks to AhmedLukman and xet7 !
2026-11-10 16:59:39 +03:00
Ahmed Lukman 0acc9955d0 Use optional chaining for removed assignment users
Read the optional username safely when removing a card member or assignee whose account is missing.

Thanks to AhmedLukman and xet7 !
2026-11-10 16:52:39 +03:00
Ahmed Lukman 5e9d1cb630 Fix removal of missing card members and assignees
Allow card assignment removal when the target user no longer exists. Keep normal membership activity and no-op behavior intact, with unit and REST/browser regression coverage.

Thanks to AhmedLukman and xet7 !
2026-11-10 16:27:40 +03:00
github-actions[bot] 4f6832845d Bump versions for v13.05 2026-11-10 09:55:03 +00:00
Lauri Ojansivu 1ca199b607 Prepare v13.05 release 2026-11-10 11:54:10 +02:00
Lauri Ojansivu 63c6f42495 CHANGELOG: updated to Meteor 3.6.
Thanks to xet7 !
2026-11-10 09:20:28 +02:00
Lauri Ojansivu 0fbef8936f Updated to Meteor 3.6.
From 3.6-rc.1. `meteor update --release METEOR@3.6` refused it: WeKan's
forks of six Meteor packages in packages/ (allow-deny, ddp-server,
dynamic-import, facebook-oauth, google-oauth, shell-server) declared
3.6-rc.1's versions with a _1 wrap number, and a local package
overrides the release's, so ~3.6.0 could not be met. Meteor 3.6 changed
those six packages only in their version line (compared on GitHub,
release/METEOR@3.6-rc.1...release/METEOR@3.6), so each fork now
declares 3.6's version with its _1 wrap number; .meteor/packages drops
the rc pins, and .meteor/versions, @meteorjs/rspack 3.0.0 and the
lockfile are what meteor update wrote. webapp is 2.4.0.

The Dockerfile's METEOR_RELEASE is METEOR@3.6. The Bun build's patch
applies unchanged to Meteor 3.6, which still has no Bun ESM loader of
its own: it is meteor-3.6.patch, checked against 3.6's commit
e730097c. tests/meteor353SecurityFixes.test.cjs is moved to 3.6 by
decision, after that check. WeKan builds with Meteor 3.6, and the node
suites pass (2162 run, 0 failed).

Thanks to xet7 !
2026-11-10 09:20:28 +02:00
Lauri Ojansivu 0905de097b CHANGELOG: the Super Productivity fix's link names its commit after the rebase.
Thanks to xet7 !
2026-11-10 08:48:52 +02:00
Lauri Ojansivu 679ea9e63a CHANGELOG: the Android bundles build again.
Thanks to xet7 !
2026-11-10 08:48:37 +02:00
Lauri Ojansivu d08f658dea Android bundles build again: npm-retry.sh is given Android's node and npm, not a shell function.
v13.04's four Android bundle jobs all died with "environment: node:
unbound variable". v13.04 sent the Android npm installs through
releases/npm-retry.sh as `npm-retry.sh npm install`, and npm there is
build-bundle.sh's function npm() { "$node" "$npm_cli" ...; }.
npm-retry.sh is a new bash with set -u: export -f carried the function
into it, but not $node or $npm_cli, so the function failed before npm
ran - and the retry wrapper rightly called it not a network error.

The two installs now give npm-retry.sh the program itself, "$node"
"$npm_cli" install, still retried on a network error as before.
tests/npmRetry.test.cjs fails when any releases/ script gives
npm-retry.sh one of its own shell functions (it fails on the old two
lines), and reproduces the failure: an exported function without its
variable fails in npm-retry.sh, the program it runs works.

Thanks to xet7 !
2026-11-10 08:48:31 +02:00
Lauri Ojansivu dcd9fec925 CHANGELOG: the Super Productivity export no longer fails on a card whose list is archived.
Opens the Upcoming section. The fix is covered by a positive and a
negative check in tests/superProductivityFormat.test.cjs, which fails
on the old formatter; it is a pure formatter behind the REST export,
so no browser test was added, and WeKan's own server was not run here.

Thanks to xet7 !
2026-11-10 08:18:49 +02:00
Lauri Ojansivu 8b4fe0e737 Super Productivity export no longer fails on a card whose list is archived.
A card whose list is archived is collected with listTitle '', and the
Super Productivity formatter tagged such a card with tagFor(''), which
is undefined - the export then threw "Cannot read properties of
undefined (reading 'taskIds')" and the whole board's export failed.
The list with no title is now "Untitled", its tag and its panel alike,
so the panel shows its cards and every task's tags exist. The test
fails with that error on the old formatter.

Found while checking Weddy's Super Productivity export against
WeKan's formatter.

Thanks to xet7 !
2026-11-10 08:18:49 +02:00
Ahmed Lukman f74ef4c40d Fix REST card overtime persistence
Keep accepting the isOverTime request parameter and write its validated value to the isOvertime schema field used by card readers and the UI.

Correct the handler regression and cover JSON/form edits, persistence, API reads, reactive display, reloads and rejected requests.
2026-11-06 19:04:30 +03:00
49 changed files with 1470 additions and 641 deletions

No files matched your search

+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
app/.tools/meteor-bun/dev_bundle
app/.tools/meteor-bun/.babel-cache
app/.tools/meteor-bun/packages/*/.npm
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.1.patch') }}
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6.patch') }}
- name: Build the release's ESM application once
working-directory: app
env:
+27 -27
View File
@@ -6,12 +6,12 @@
meteor-base@1.5.2
# Build system
ecmascript@0.19.2-rc360.1
standard-minifier-js@3.3.0-rc360.1
rspack@1.4.0-rc360.1
ecmascript@0.19.2
standard-minifier-js@3.3.0
rspack@1.4.0
# Declared explicitly because server/modernBrowsers.js imports it, to say that
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
modern-browsers@0.2.4-rc360.1
modern-browsers@0.2.4
blaze@3.0.2
@@ -19,11 +19,11 @@ blaze@3.0.2
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.5.2-rc360.1
mongo@2.6.0
# Account system
accounts-password@3.3.2-rc360.1
accounts-2fa@3.1.1-rc360.1
accounts-password@3.3.2
accounts-2fa@3.1.1
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
@@ -39,19 +39,19 @@ wekan-oidc
wekan-accounts-oidc
# Utilities
check@1.5.1-rc360.1
random@1.2.3-rc360.1
reactive-dict@1.3.3-rc360.1
session@1.2.4-rc360.1
tracker@1.3.5-rc360.1
check@1.5.1
random@1.2.3
reactive-dict@1.3.3
session@1.2.4
tracker@1.3.5
audit-argument-checks@1.0.8
# UI components
ostrio:i18n
reactive-var@1.0.13
shell-server@0.7.2-rc360.1
email@3.2.1-rc360.1
dynamic-import@0.8.1-rc360.1
shell-server@0.7.2
email@3.2.1
dynamic-import@0.8.1
wekan-markdown
quave:synced-cron
ostrio:cookies
@@ -59,21 +59,21 @@ ostrio:files
# lmieulet:meteor-coverage
meteortesting:mocha@3.0.0
matb33:collection-hooks
service-configuration@1.3.6-rc360.1
service-configuration@1.3.6
minifier-css@2.0.2
ejson@1.4.1-rc360.1
logging@1.3.7-rc360.1
ejson@1.4.1
logging@1.3.7
wekan-fullcalendar
wekan-fontawesome
accounts-google@1.4.2-rc360.1
accounts-github@1.5.2-rc360.1
accounts-facebook@1.3.5-rc360.1
accounts-twitter@1.5.3-rc360.1
accounts-meteor-developer@1.5.2-rc360.1
accounts-weibo@1.4.2-rc360.1
accounts-meetup@1.5.2-rc360.1
accounts-passwordless@3.1.2-rc360.1
instrumentation@0.0.1-rc360.1
accounts-google@1.4.2
accounts-github@1.5.2
accounts-facebook@1.3.5
accounts-twitter@1.5.3
accounts-meteor-developer@1.5.2
accounts-weibo@1.4.2
accounts-meetup@1.5.2
accounts-passwordless@3.1.2
instrumentation@0.0.1
# Prevent Meteor build/run tools from sending application package statistics.
package-stats-opt-out@1.0.8
+1 -1
View File
@@ -1 +1 @@
METEOR@3.6-rc.1
METEOR@3.6
+74 -74
View File
@@ -1,130 +1,130 @@
accounts-2fa@3.1.1-rc360.1
accounts-base@3.4.0-rc360.1
accounts-2fa@3.1.1
accounts-base@3.4.0
accounts-express@1.0.0
accounts-facebook@1.3.5-rc360.1
accounts-github@1.5.2-rc360.1
accounts-google@1.4.2-rc360.1
accounts-meetup@1.5.2-rc360.1
accounts-meteor-developer@1.5.2-rc360.1
accounts-oauth@1.4.8-rc360.1
accounts-password@3.3.2-rc360.1
accounts-passwordless@3.1.2-rc360.1
accounts-twitter@1.5.3-rc360.1
accounts-weibo@1.4.2-rc360.1
accounts-facebook@1.3.5
accounts-github@1.5.2
accounts-google@1.4.2
accounts-meetup@1.5.2
accounts-meteor-developer@1.5.2
accounts-oauth@1.4.8
accounts-password@3.3.2
accounts-passwordless@3.1.2
accounts-twitter@1.5.3
accounts-weibo@1.4.2
aldeed:collection2@4.2.2
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.2-rc360.1_1
allow-deny@2.1.2_1
audit-argument-checks@1.0.8
autoupdate@2.0.2-rc360.1
babel-compiler@7.15.2-rc360.1
autoupdate@2.0.2
babel-compiler@7.15.2
babel-runtime@1.5.2
base64@1.0.15-rc360.1
binary-heap@1.0.14-rc360.1
base64@1.0.15
binary-heap@1.0.14
blaze@3.0.3
blaze-tools@2.0.1
boilerplate-generator@2.2.0-rc360.1
boilerplate-generator@2.2.0
caching-compiler@2.0.1
caching-html-compiler@2.0.1
callback-hook@1.8.1-rc360.1
check@1.5.1-rc360.1
callback-hook@1.8.1
check@1.5.1
communitypackages:core@2.0.3
communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.3-rc360.1
ddp-client@3.4.2-rc360.1
ddp-common@1.4.6-rc360.1
ddp-rate-limiter@1.3.1-rc360.1
ddp-server@3.6.0-rc360.1_1
diff-sequence@1.2.1-rc360.1
dynamic-import@0.8.1-rc360.1_1
ecmascript@0.19.2-rc360.1
ddp@1.4.3
ddp-client@3.4.2
ddp-common@1.4.6
ddp-rate-limiter@1.3.1
ddp-server@3.6.0_1
diff-sequence@1.2.1
dynamic-import@0.8.1_1
ecmascript@0.19.2
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.4.1-rc360.1
email@3.2.1-rc360.1
ejson@1.4.1
email@3.2.1
es5-shim@4.8.1
facebook-oauth@1.12.1-rc360.1_1
facts-base@1.0.3-rc360.1
fetch@0.2.1-rc360.1
geojson-utils@1.0.13-rc360.1
github-oauth@1.4.3-rc360.1
google-oauth@1.5.1-rc360.1_1
facebook-oauth@1.12.1_1
facts-base@1.0.3
fetch@0.2.1
geojson-utils@1.0.13
github-oauth@1.4.3
google-oauth@1.5.1_1
hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.1-rc360.1
instrumentation@0.0.1-rc360.1
inter-process-messaging@0.1.4-rc360.1
id-map@1.2.1
instrumentation@0.0.1
inter-process-messaging@0.1.4
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
logging@1.3.7-rc360.1
logging@1.3.7
matb33:collection-hooks@2.1.0
meetup-oauth@1.1.4-rc360.1
meteor@2.3.2-rc360.1
meetup-oauth@1.1.4
meteor@2.3.2
meteor-base@1.5.2
meteor-developer-oauth@1.3.4-rc360.1
meteor-developer-oauth@1.3.4
meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.4.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2
minifier-js@3.3.2-rc360.1
minimongo@2.2.1-rc360.1
modern-browsers@0.2.4-rc360.1
minifier-js@3.3.2
minimongo@2.2.1
modern-browsers@0.2.4
modules@0.20.3
modules-runtime@0.13.2
mongo@2.5.2-rc360.1
mongo@2.6.0
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.10-rc360.1
npm-mongo@6.18.1-rc360.1
oauth@3.0.5-rc360.1
oauth1@1.5.4-rc360.1
mongo-id@1.0.10
npm-mongo@6.18.1
oauth@3.0.5
oauth1@1.5.4
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.1-rc360.1
ordered-dict@1.2.1
ostrio:cookies@2.9.2
ostrio:cstorage@4.0.1
ostrio:files@3.0.1
ostrio:flow-router-extra@3.15.3
ostrio:i18n@3.2.1
package-stats-opt-out@1.0.8
promise@1.0.1-rc360.1
promise@1.0.1
quave:synced-cron@2.4.0
raix:eventemitter@1.0.0
random@1.2.3-rc360.1
rate-limit@1.2.1-rc360.1
random@1.2.3
rate-limit@1.2.1
react-fast-refresh@0.3.0
reactive-dict@1.3.3-rc360.1
reactive-dict@1.3.3
reactive-var@1.0.13
reload@1.3.3-rc360.1
retry@1.1.2-rc360.1
reload@1.3.3
retry@1.1.2
reywood:publish-composite@1.9.0
routepolicy@1.1.3-rc360.1
rspack@1.4.0-rc360.1
service-configuration@1.3.6-rc360.1
session@1.2.4-rc360.1
sha@1.0.11-rc360.1
shell-server@0.7.2-rc360.1_1
socket-stream-client@0.7.2-rc360.1
routepolicy@1.1.3
rspack@1.4.0
service-configuration@1.3.6
session@1.2.4
sha@1.0.11
shell-server@0.7.2_1
socket-stream-client@0.7.2
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.3.0-rc360.1
standard-minifier-js@3.3.0
templating@1.4.5
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.4.0-rc360.1
tracker@1.3.5-rc360.1
twitter-oauth@1.3.5-rc360.1
typescript@5.11.2-rc360.1
url@1.3.7-rc360.1
webapp@2.3.1-rc360.1
webapp-hashing@1.1.3-rc360.1
weibo-oauth@1.3.4-rc360.1
tools-core@1.4.0
tracker@1.3.5
twitter-oauth@1.3.5
typescript@5.11.2
url@1.3.7
webapp@2.4.0
webapp-hashing@1.1.3
weibo-oauth@1.3.4
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
wekan-accounts-oidc@1.0.10
+162 -432
View File
@@ -124,6 +124,168 @@ its reason.
</details>
# v13.06 2026-11-11 WeKan ® release
**In short:** **CodeQL** alert #601 in a test is fixed, with two more tests
that had the same fault. A card's **overtime** set through the REST API is
saved where WeKan reads it, and cards that lost it before are repaired at
startup. Card **members and assignees** whose account was deleted can be removed
and added without an error, and clearing a card **date** no longer passes as a
date set to the rules, the due date count and the activity feed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d482856e03129d13c6e171a1503db60d81ab5166">Alert #601: no path is quoted into a bash -c script in the tests</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL flagged a shell command built from environment values (CWE-78, CWE-88)
in `tests/npmRetry.test.cjs`, whose `bash -c` script pasted a temporary path
and a checkout path into its text with `JSON.stringify` - JSON's quotes are not
the shell's, so a path with `$(`, a backtick or `$` would change the command.
The paths now go in through the environment. The guard written for alerts #549
and #550 only knew `source "${path}"`; it now refuses any value quoted into a
`-c` script across the tests, and found the same fault in
`tests/releaseDownloads.test.cjs` and `tests/macosSandbox.test.cjs`, fixed as
arguments. The finding was in tests, not in WeKan, so there is no attempt to
show in Admin Panel → Problems.
</details>
and fixes the following bugs:
**Card overtime** - what the REST API stores, and what it stored before.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f74ef4c40d85f9f6b91b8f4ecfc7372f67d32100">A REST card update saves isOverTime as the isOvertime WeKan reads</a>. Thanks to AhmedLukman.</summary>
A PUT with `{"isOverTime": true}` answered 200 and changed nothing anyone saw:
the handler wrote `isOverTime`, while the schema and the UI use `isOvertime`.
The request keeps its spelling and the validated value is stored in
`isOvertime`. Browser tests cover JSON and form updates, the stored value, a
REST read, the open card, a reload, and refused requests, and the unit test
pins the stored field name.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/569756b66c0fc3e3c41aba93fa8311994c6fa754">An isOverTime stored before that fix becomes isOvertime at startup</a>. Thanks to AhmedLukman and xet7.</summary>
Every overtime set through the API since `b895e61237` was stored under the
field nothing reads. The `card-overtime-field` schema upgrade step folds it
into `isOvertime` where the card has none, keeps the value the UI already shows
where it has both, and removes the stray field - version-gated and idempotent
like the steps beside it. Tests pin the fold, the precedence and a second pass
finding nothing; as negatives, no work on cards without the field, and no
server code writing `isOverTime` into a card.
</details>
**Card members and assignees** - people whose account is gone.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5e9d1cb6305a4a36f9cb890c7f5babb66ab4f43c">A member or assignee whose account is gone can be removed</a>. Thanks to AhmedLukman.</summary>
Removing a card member or assignee whose user account no longer exists read
`user.username` of nobody and answered HTTP 500, so the stale id could never
be removed. Both removal hooks now look the name up safely and still record
the removed id. Unit tests run the real hooks, and browser tests cover stale,
missing, repeated and normal removals with their authentication and scope
checks.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/32590ee7ab89b41ca6f764a58ec0f43d56d48ab8">Adding a member or assignee whose account is gone no longer throws either</a>. Thanks to AhmedLukman and xet7.</summary>
The joining halves of the same two hooks had the same read. The REST route
checks the user before adding, but a client update, a copied or imported card,
or a rule can still add such an id. Both now record the join with the id and
no username. Tests run the real hooks, and a tree-wide test finds no
`const x = user.field;` straight after a user lookup without a guard.
</details>
**Card dates** - what clearing a date records.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50334816caf227237124b007c53a467c8f6919f5">Clearing a card date no longer passes through the timing hook as a date set</a>. Thanks to xet7.</summary>
The hook that records `a-dueAt` and the other date activities read
`modifier.$set[field]` for every date update, but clearing a date sends only a
`$unset`: without a `$set` the hook threw, and with one beside it an `a-dueAt`
without a value was written - counted as a due date change (#6081), matched by
the "date set or changed" rule triggers (#2474) and shown as an empty date in
the activity feed. Now a date set records its value, a date cleared records
only the date it had - as a rule's removeDate does, and as the board timeline
rewinds - and clearing a date the card does not have records nothing. The rules
skip a cleared date, and the feed shows only the date it had. Tests run the real
hook with a set, a `$unset` alone and a `$unset` beside a `$set`, and a
tree-wide test finds no `modifier.$set[...]` read that assumes the `$set` is
there.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v13.05 2026-11-10 WeKan ® release
**In short:** WeKan is updated to **Meteor 3.6**. The **Android** bundles
build again, after v13.04's npm retry broke their install, and the **Super
Productivity** export no longer fails for a board with a card whose list is
archived.
This release updates the following dependencies:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0fbef8936fa2524d21048669e4c41be15e4e3f38">Updated to Meteor 3.6</a>. Thanks to xet7.</summary>
From 3.6-rc.1. `meteor update` refused it, because WeKan's forks of six Meteor
packages in `packages/` declared 3.6-rc.1's versions, and a local package
overrides the release's. Meteor 3.6 changed those six only in their version
line, so each fork now declares 3.6's version with its `_1` wrap number. The
Dockerfile builds with Meteor 3.6, and the Bun build's patch applies unchanged
to it as `meteor-3.6.patch`. WeKan builds with Meteor 3.6, and the node suites
pass.
</details>
and fixes the following bugs:
**Release builds** - what the release workflow builds.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d08f658deae3b8369b75f1c54371ad8c1cf9367d">The Android bundles build again: npm-retry.sh is given Android's node and npm</a>. Thanks to xet7.</summary>
v13.04's four Android bundle jobs all failed with "environment: node: unbound
variable". v13.04 sent the Android npm installs through
`releases/npm-retry.sh` by the name of `build-bundle.sh`'s own `npm` shell
function. `npm-retry.sh` is a new bash with `set -u`: the exported function
reached it, but the `$node` and `$npm_cli` it reads did not, so it failed before
npm ran. The two installs now give `npm-retry.sh` the program itself, still
retried on a network error. `tests/npmRetry.test.cjs` fails when any
`releases/` script gives `npm-retry.sh` one of its own shell functions, and
reproduces the failure.
</details>
**Exports** - a board in another tool's format.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8b4fe0e7376194c498219a0b496340a94020098a">The Super Productivity export no longer fails on a card whose list is archived</a>. Thanks to xet7.</summary>
A card whose list is archived is collected with an empty list title. The
Super Productivity formatter tagged that card with a tag that did not exist, so
the export threw "Cannot read properties of undefined (reading 'taskIds')" and
the whole board's export failed. That list is now "Untitled", for its tag and
its panel alike, so the panel shows its cards and every tag a task names
exists. `tests/superProductivityFormat.test.cjs` fails with that error on the
old formatter. It was found while checking Weddy's Super Productivity export
against WeKan's formatter.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v13.04 2026-11-09 WeKan ® release
**In short:** **ListLockBleed** is fixed: when a list refuses a role write
@@ -8297,435 +8459,3 @@ workflow that is code is imported disabled. Pinned by
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.41 2026-10-14 WeKan ® release
**In short:** **custom board roles**, **per-list permissions** and boards
**every signed-in user may edit**; board **keys** (`DEV-7`) and **shared
labels**; WeKan serves **HTTPS** itself; **Bun single files** and **musl
bundles and AppImages** for Alpine; on **Sandstorm**, webhooks and list sync
leave through the Powerbox and the REST API takes a grain API key. Seventeen
locale files that held **another
language**, and six full of machine-fill prefixes, are rewritten in their
own, and fourteen **GitHub CodeQL** alerts are fixed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2352f1f44f">HTML tags are removed until none are left, and no input is spliced into a pattern</a>. Thanks to GitHub CodeQL and xet7.</summary>
- **#570, #571:** an HTML-only email became card text with one pass of each
removal, so a script element spliced together by removing an inner one
survived, and its contents reached the card. Both removals now run until
nothing changes.
- **#574:** the single file's settings writer put the setting name into a
regular expression. A setting must be an environment variable name and is
compared literally, and a value must be one line. The kept-versions reader
now reads file names literally too.
- **#572, #573, #575-#580:** tests escaped only some characters, or matched
example hostnames with unescaped dots; they compare literally or escape
fully now.
`tests/tagStrippingFixedPoint.test.cjs` now also catches the `<[^>]+>`
spelling it missed, which also found the search-index text of Office
documents: never rendered, so recorded as such. `tests/emailToCard.test.cjs`
fails on the old code. Nothing here is an attempt the server refuses at run
time, so no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/167ccf9e2d">An email's HTML becomes text in one pass, with no pattern to take apart</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alerts #581 and #582 were raised on the fix above: email to card
still removed `<script>` elements and tags with regular expressions, looped
to a fixed point through a helper that CodeQL does not follow. `htmlToText`
in `models/lib/emailToCard.js` now walks the characters once. A tag is
dropped whole, a script, style or head element goes with what is in it (also
unclosed), an unfinished tag is not kept as text, and a `<` that does not
start a tag stays text - except right before another `<`, where it would
read as a tag once the next one is gone. `tests/emailToCard.test.cjs` covers
those cases, and `tests/tagStrippingFixedPoint.test.cjs` no longer accepts a
fixed-point helper and now also catches a whole element removed by pattern,
in every file. Nearly every HTML email has a `<style>`, so a removed element
is ordinary use, not an attempt: no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/73fcfb8545">No HTTP response sends the request back, tests included</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alert #583: the HTTPS test's server answered with the request's own
URL in the body, the shape of reflected cross-site scripting. It now names
the page by comparison. `tests/noReflectedRequest.test.cjs` keeps the shape
out of every file. Test code only, so no Admin Panel / Problems key is added.
</details>
and adds the following new features:
**Board roles** - who may do what on a board, and where a membership comes from.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">Custom board roles, built in the Admin Panel from what a member may do</a>. Thanks to xet7.</summary>
In Admin Panel / People / Roles a site admin builds named roles from five
capabilities: sees all cards, comments, edits, moves cards, invites. A board
admin then gives one to a member in the same popup as the built-in roles.
Board admin stays a built-in role. Every check asks the capability table,
which answers from the custom role. The member's built-in flags carry the
strongest built-in role that grants nothing more, so code reading a flag
never grants more, and a deleted role leaves exactly that. This holds for
every combination, which `tests/customBoardRoles.test.cjs` checks.
Invitations, REST reads, imports and member mapping follow it. The comment
form, reactions and chat now ask the comment capability, so a No comments
member is no longer offered reactions the server refuses. The new strings are
translated in all 234 languages, and the Roles Status table gains a Move cards
column. See [Board roles](docs/Features/Members/Roles.md#custom-roles).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">A membership that came through a team or organization leaves with it</a>. Thanks to mfnalex and xet7.</summary>
Joining a team already added its boards; leaving it now takes away the
memberships it gave, and only those. Each records where it came from
(`grantedBy`), so a direct membership is never touched and a board admin is
kept. A board admin's own add or removal is final. This covers user edits,
LDAP, OIDC, the board sidebar, and organizations that propagate their
members. `tests/groupBoardMembership.test.cjs` covers the decisions, and a
runtime test joins, leaves and rejoins a team against a real database.
</details>
**Outgoing requests** - webhooks and downloads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/78957e14cb">Outgoing webhooks go through an HTTP proxy, with the SSRF checks kept</a>. Thanks to gbarre and xet7.</summary>
`HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` work for every outgoing request
(on the snap: `http-proxy`, `https-proxy`, `no-proxy`). The destination is
still resolved and checked first, and the proxy is given that checked
address, never the name: an `http://` request goes in absolute form, an
`https://` one through a `CONNECT` tunnel, with TLS still checked against the
real name. A destination the guard refuses never reaches the proxy.
`tests/outboundProxy.test.cjs` runs a real proxy on loopback. See
[Proxy](docs/Features/Webhooks/Proxy.md).
</details>
**Who may edit where** - boards open to every signed-in user, and lists with
rules of their own.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">A board can let every signed-in user edit it, when the Admin Panel allows it</a>. Thanks to Meeques and xet7.</summary>
Admin Panel / Settings / Visibility gains "Allow boards that every signed-in
user may edit", off by default. With it on, a board admin of a public board
or one open to every signed-in user can turn it on for that board in the
visibility popup. A signed-in non-member may then comment, edit and move
cards like a Normal member - never change the board - and their first edit
makes them a Normal member, so every change stays attributed. A member keeps
their own role, a member a board admin removed stays removed, and rules,
webhooks, inbound email and list sync never act with a non-member's rights.
Board-wide settings such as list width and sticky headers stay members-only.
`tests/instanceEditing.test.cjs` and a runtime test cover it. See
[Visibility](docs/Features/Admin-Panel/Settings/Visibility.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Lists can allow or refuse editing and commenting per role</a>. Thanks to Meeques and xet7.</summary>
The list menu gains List permissions, for board admins: for each role -
built-in, custom, and on an open board everyone signed in - Edit and Comment
are as on the board, allowed, or not allowed in this list. The issue's
example works: a read-only board with one list where everybody may add cards
and comment. A board admin is never restricted, and a removed member stays
removed. The server applies it to every card, checklist, attachment and
comment write and to moves into the list, and the REST API honours a list's
refusal on every per-list card route. Only the board admin method writes the
setting; a direct write is refused and shown in Admin Panel / Problems.
`tests/listPermissions.test.cjs` and a runtime test cover it. See
[List permissions](docs/Features/Board/List-Permissions.md).
</details>
**Boards and cards** - keys, card numbers and labels shared between boards.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Every board has a key, and card numbers show as KEY-7</a>. Thanks to justinr1234 and xet7.</summary>
A key is 2-10 capital letters and digits, unique in the instance, suggested
from the board's title (Development Team: DT) and set for every existing
board at startup. A board admin can change it in the rename popup. Cards show
`DEV-7` instead of `#7`, and searching for `DEV-7` finds that card. The
per-board numbers themselves are unchanged, so nothing is renumbered.
`tests/boardKey.test.cjs` and a runtime test cover it. See
[Board key](docs/Features/Board/Boards/Board-Key.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Shared labels for every board, an organization's boards or a team's</a>. Thanks to tiFabGrinch, ejlkemper and xet7.</summary>
Admin Panel / Labels keeps labels shared by every board, or by the boards of
one organization or team. A board offers them in its labels popup beside its
own, and using one copies it to the board linked to the shared label, so
renaming it or changing its colour in the Admin Panel changes it everywhere
it is used. Deleting a shared label leaves it on those boards as their own.
Only labels a member's own organizations and teams share are sent to them.
`tests/sharedLabels.test.cjs` and a runtime test cover it. See
[Shared labels](docs/Features/Board/Shared-Labels.md).
</details>
**Servers** - running WeKan without a proxy in front of it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1bf7a62cab">WeKan serves HTTPS itself, when a certificate is configured</a>. Thanks to 68040 and xet7.</summary>
With `HTTPS_PORT`, `HTTPS_CERT_FILE` and `HTTPS_KEY_FILE` set (a path or
the PEM itself), WeKan also listens for HTTPS, on the same app, and
`HTTPS_REDIRECT=true` sends plain-HTTP requests there. Without them nothing
changes, and a proxy such as Caddy stays the recommendation for production.
The snap has `https-port`, `https-cert-file`, `https-key-file` and
`https-redirect`. `tests/httpsServer.test.cjs` makes a real TLS connection.
See [HTTPS without a proxy](docs/Platforms/Webserver/HTTPS-without-proxy.md).
</details>
**Platforms** - more ways to run WeKan.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b1e58094f4">Bun single files: one executable per Bun platform, musl included</a>. Thanks to xet7.</summary>
Each Bun bundle also comes as one executable:
`wekan-<version>-bun-<target>-single-file` for Linux x64 and arm64 (glibc
and musl, so Alpine too), macOS and Windows. It is the published bundle ZIP,
unchanged, inside that bundle's own Bun, built on the platform itself, so the
runtime is the one the bundle was tested with. The first start unpacks the
bundle once into a cache directory, without a second Bun, and every start
runs it with the executable itself as Bun. Like the Bun bundle it needs
`MONGO_URL` and `ROOT_URL`. Its ZIP reader refuses any entry or link that
would land outside the cache directory. Before it is published each file is
checked: it names itself, refuses to start without a database, unpacks once
and loads every native addon of the bundle. `tests/bunSingleFile.test.cjs`
covers the reader and its refusals. See
[Single file](docs/Platforms/Single-File.md#bun-single-files).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a1d2994c1c">musl bundles and AppImages for Alpine and the other musl systems</a>. Thanks to xet7.</summary>
Every release also has `wekan-<version>-linux-x64-musl.zip` and
`-linux-arm64-musl.zip`, with FerretDB, and the AppImages
`WeKan-<version>-x86_64-musl` and `-aarch64-musl`. Each is the release's
Linux bundle rebuilt inside Alpine on a runner of its CPU: the musl Node.js of
the same version, checked against its SHA256, and every native addon
compiled against musl. Before it is published, each addon's architecture is
checked, sharp must load, and the bundle must start and answer HTTP 200; the
AppImages are started inside Alpine too. `tests/muslBundles.test.cjs`
covers the build and the workflows. See [Alpine](docs/Platforms/FOSS/OS/Alpine.md).
</details>
**Sandstorm** - reaching out of a grain, and into it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">Outgoing webhooks leave a grain through the Powerbox</a>. Thanks to xet7.</summary>
A grain has no network but the bridge proxy, so a webhook was never sent.
Each saved webhook now offers "Allow this grain to reach this address", which
opens the Powerbox for exactly that address; once approved, the webhook goes
through the bridge with that grant. The grant belongs to the destination, not
to a person, and is approved as the address's parent path so the bridge
delivers to the exact address. Off Sandstorm nothing changes: every sender
still goes through the SSRF guard. `tests/sandstormWebhookEgress.test.cjs`
runs it against a fake bridge proxy. See
[Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#outgoing-webhooks).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">The REST API works with a Sandstorm API key</a>. Thanks to xet7.</summary>
A Sandstorm API key keeps the Authorization header for itself, so WeKan saw
no user. In a grain the bridge says who is calling, and WeKan now acts as
that user - only in a grain, only for an account that exists and is enabled,
and a key without the participate permission reads only. Off Sandstorm the
`X-Sandstorm-*` headers are never believed.
`tests/sandstormApiIdentity.test.cjs`
covers both sides.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d53b4da799">List sync from GitHub, GitLab, Gitea and Forgejo works in a grain</a>. Thanks to xet7.</summary>
A board member who may write saves a list's sync source and allows the grain
to reach that tracker's API in the Powerbox; the periodic sync then goes
through the bridge with that grant, which belongs to the tracker, so no one
needs to be signed in. GitLab's token is sent as `private_token`, because the
bridge drops `PRIVATE-TOKEN`, so private GitLab projects sync too; GitHub,
Gitea and Forgejo sync public repositories, whose token headers cannot pass
the bridge. Jira signs in only with an `Authorization` header, which the
bridge replaces, so it is refused in a grain with that reason. Off Sandstorm
nothing changes. `tests/sandstormSyncEgress.test.cjs` runs it against a fake
bridge proxy. See [Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#list-sync).
</details>
and closes the following already-done feature request:
- [Each member chooses how often email notifications come: never, periodically or instantly](https://github.com/wekan/wekan/commit/3f88fb39a5).
Thanks to saurabharch and xet7.
and fixes the following bugs:
**Lists and swimlanes** - what goes with a list, and where it is shown.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a3797dbf6">Archiving a list archives its cards, and restoring it brings them back</a>. Thanks to Raymond-Voogt and xet7.</summary>
Only template lists archived their cards, so archiving any other list left
its cards visible in the Lists, Table and Calendar views. The server now
archives the cards of every list with it, whichever way it is archived - the
list menu, the Archive sidebar, the REST API - and restoring the list
restores exactly those cards, the rule #2292 gave swimlanes. A card that was
archived on its own stays archived. Cards archived with the list get the
list's own archive time, so a browser clock that differs from the server's
cannot keep them from coming back. `tests/archiveListCards.test.cjs` and a
runtime test cover it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ebf2a019a6">Lists missing their swimlane counts only lists that lost it, and Restore never hides cards</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Problems counted every board-wide list whose history recorded
the swimlane it was created in, so lists made since the automatic
un-binding stopped (#6515, in v10.31) were counted too, and Restore would
have bound them there, hiding the cards they hold in other swimlanes. Now a
list created after v10.31 is never offered, and a list with any card in
another swimlane is never restored. A list that really lost its binding is
still restored. `tests/listSwimlaneRestore.test.cjs` and a runtime test
cover both rules.
</details>
**Board roles** - helpers that read a role.
- [Five role helpers that answered the same for everybody: Import never offered, rule controls for every member, the last admin not protected, OIDC default-board roles](https://github.com/wekan/wekan/commit/548834c4ee).
Thanks to xet7.
and fixes the following build bug:
- [The OpenBSD bundles are built on OpenBSD 7.8, where Node.js 26 now builds](https://github.com/wekan/wekan/commit/209e3b0c47).
Thanks to xet7.
and updates the following documentation:
- [Outgoing webhooks to Node-RED, and why an internal address is refused](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to yeurplc and xet7.
- [Uberspace 7: install, the update script, and email on one environment line](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to jeyca and xet7.
and updates the following translations:
**Languages that held another language** - each locale file now says what its
tag says.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f3c92273ae">Hong Kong Chinese in Traditional characters, and Flemish in Dutch</a>. Thanks to xet7.</summary>
Hong Kong Chinese had 90 values in Simplified Chinese; they are now
Traditional. Flemish held 158 values in about a dozen other languages, Venda
and Vietnamese among them, and they are now Flemish, with its 225 values
still in English filled too.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ba6d0014f7">Locale files that held another language are rewritten in their own</a>. Thanks to xet7.</summary>
Romanian held Italian, French and Spanish; Aromanian, Ladin, Romansh and
Venetian held Italian; Galician held Portuguese, Acehnese Malay, Venda Zulu,
Slovak Czech and Polish, Upper Sorbian Czech, Volapük Esperanto and Bislama
Tok Pisin, and Uzbek in Arabic script was half Latin script. Each is now in
its own language. Machine-fill prefixes and notes ("Faka-Tonga:", "Kay
willaymi:", "Wolayttatto:", "WeKan:" and others) are gone from Tongan,
Quechua, Wolaytta, Upper Sorbian, Aragonese, Friulian and Luganda. Chinese
no longer calls a board a wooden plank, and List actions no longer reads as
"list the actions" in seven languages. Values already reviewed - pinned by a
test, corrected by an audit, or quoted by another string - are kept.
`tests/wrongLanguageLocales.test.cjs` keeps the old languages and prefixes
out and checks every placeholder against English. Native speakers are asked
to review these languages.
</details>
**Igbo** - notification, integration, workflow and recovery settings.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/238354f6a54a234c9db1cf88a9436d15087c6ba0">Translate Igbo notification delivery and integration settings</a>. Thanks to xet7.</summary>
Translate 42 notification and integration strings, preserving grouping,
schedules, quiet hours, inherited webhook settings, identity omission and
literal link-rule syntax. Direct technical wording needs fluent review.
Both translation and guarded-correction suites pass with 30,943 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One notification browser case passes syntax and discovery; execution is pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/daac3ead0dceb451bdb69970cc5b19678d17ce6d">Translate Igbo workflow and permission controls</a>. Thanks to xet7.</summary>
Translate 19 workflow, directory-sync and permission messages, preserving
Wrike status behavior, environment identifiers and subtask completion choices.
Technical prose is lower confidence and needs fluent review.
Both translation and guarded-correction suites pass with 30,962 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
Three browser cases pass syntax and discovery; execution remains pending.
Igbo has 69 detected placeholders left. All-language semantic review is
unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c5391fb98d3619308aa2ba3328f47db870e5655">Translate Igbo stopped list sync recovery messages</a>. Thanks to xet7.</summary>
Translate 18 recovery messages, preserving applied changes, discarded pending
writes, replay restrictions and the oldest-50 display limit. Recovery prose
needs fluent review.
Both translation and guarded-correction suites pass with 30,980 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One browser case passes syntax and discovery; execution remains pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8c25ce78b94d65d3a7fd4540bdb065443a6c6dbd">Translate Igbo interrupted import recovery messages</a>. Thanks to xet7.</summary>
Translate 21 messages, preserving permanent deletion, retained partial boards,
source-file limitations, untouched foreign boards and recovery limits.
Direct recovery prose needs fluent review.
Both translation and guarded-correction suites pass with 31,001 corrections.
All 21 human-preference checks pass; existing Chinese values are unchanged
(38,430 checked). One browser case passes syntax and discovery; execution is
pending. All-language translation and semantic verification remain unfinished.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
+20 -20
View File
@@ -5,6 +5,26 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v13.06 2026-11-11 WeKan ® release
**CodeQL** alert #601 in a test is fixed, with two more tests
that had the same fault. A card's **overtime** set through the REST API is
saved where WeKan reads it, and cards that lost it before are repaired at
startup. Card **members and assignees** whose account was deleted can be removed
and added without an error, and clearing a card **date** no longer passes as a
date set to the rules, the due date count and the activity feed.
[All changes in v13.06](CHANGELOG-big.md#v1306-2026-11-11-wekan--release)
# v13.05 2026-11-10 WeKan ® release
WeKan is updated to **Meteor 3.6**. The **Android** bundles
build again, after v13.04's npm retry broke their install, and the **Super
Productivity** export no longer fails for a board with a card whose list is
archived.
[All changes in v13.05](CHANGELOG-big.md#v1305-2026-11-10-wekan--release)
# v13.04 2026-11-09 WeKan ® release
**ListLockBleed** is fixed: when a list refuses a role write
@@ -28,23 +48,3 @@ archive is briefly unreachable: the build waits for it and retries once.
to them, through every card method.
[All changes in v13.02](CHANGELOG-big.md#v1302-2026-11-09-wekan--release)
# v13.01 2026-11-08 WeKan ® release
Translations: the strings still in English are written in **33
more languages**, **Sardinian** is Sardinian instead of Italian and **Moroccan
Arabic** is Darija instead of Persian. Dependabot's updates of **mailparser**,
**isomorphic-git**, **pdfjs-dist** and the S3 libraries are checked and kept,
with the S3 client brought to the version the upload library needs.
[All changes in v13.01](CHANGELOG-big.md#v1301-2026-11-08-wekan--release)
# v13.00 2026-11-07 WeKan ® release
**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 v13.00](CHANGELOG-big.md#v1300-2026-11-07-wekan--release)
+2 -2
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=13.04
ARG VERSION=13.06
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
@@ -149,7 +149,7 @@ ENV \
DEBUG=false \
DDP_TRANSPORT=sockjs \
NODE_VERSION=v26.13.0 \
METEOR_RELEASE=METEOR@3.6-rc.1 \
METEOR_RELEASE=METEOR@3.6 \
USE_EDGE=false \
NPM_VERSION=12.2.2 \
SRC_PATH=./ \
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v13.04.0"
appVersion: "v13.06.0"
files:
userUploads:
- README.md
+4 -1
View File
@@ -195,7 +195,10 @@ template(name="activity")
if(currentData.timeKey)
| {{_ activity.activityType }}
= ' '
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
//- A date cleared has no new value (server/lib/timingChangeActivity.js):
only the one it had, below, rather than an empty date.
if currentData.timeValue
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
if (currentData.timeOldValue)
= ' '
| {{{activityMessage "previous_as" }}}
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-13.04-win64.zip](https://github.com/wekan/wekan/releases/download/v13.04/wekan-13.04-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-13.06-win64.zip](https://github.com/wekan/wekan/releases/download/v13.06/wekan-13.06-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-13.04-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-13.06-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+4 -4
View File
@@ -3912,7 +3912,7 @@ async function cardMembers(userId, doc, fieldNames, modifier) {
if (modifier.$addToSet && modifier.$addToSet.members) {
memberId = modifier.$addToSet.members;
const user = await ReactiveCache.getUser(memberId);
const username = user.username;
const username = user && user.username;
if (!(doc.members || []).includes(memberId)) {
await Activities.insertAsync({
userId,
@@ -3931,7 +3931,7 @@ async function cardMembers(userId, doc, fieldNames, modifier) {
if (modifier.$pull && modifier.$pull.members) {
memberId = modifier.$pull.members;
const user = await ReactiveCache.getUser(memberId);
const username = user.username;
const username = user && user.username;
// Check that the former member is member of the card
if ((doc.members || []).includes(memberId)) {
await Activities.insertAsync({
@@ -3955,7 +3955,7 @@ async function cardAssignees(userId, doc, fieldNames, modifier) {
if (modifier.$addToSet && modifier.$addToSet.assignees) {
assigneeId = modifier.$addToSet.assignees;
const user = await ReactiveCache.getUser(assigneeId);
const username = user.username;
const username = user && user.username;
if (!(doc.assignees || []).includes(assigneeId)) {
await Activities.insertAsync({
userId,
@@ -3973,7 +3973,7 @@ async function cardAssignees(userId, doc, fieldNames, modifier) {
if (modifier.$pull && modifier.$pull.assignees) {
assigneeId = modifier.$pull.assignees;
const user = await ReactiveCache.getUser(assigneeId);
const username = user.username;
const username = user && user.username;
// Check that the former assignee is assignee of the card
if ((doc.assignees || []).includes(assigneeId)) {
await Activities.insertAsync({
+7 -3
View File
@@ -352,6 +352,10 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
}
return tagIdByTitle.get(name);
};
// A list's own tag. A list with no title - a card whose list is archived
// is collected with listTitle '' - is "Untitled", as its panel is named,
// so the panel shows its cards and every task's tags exist.
const listTag = title => tagFor(String(title || '').trim() || 'Untitled');
const inProgressTag = () => {
if (!tagState.entities[SP_IN_PROGRESS_TAG_ID]) {
tagState.ids.push(SP_IN_PROGRESS_TAG_ID);
@@ -398,7 +402,7 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
const isDone = kind === 'done' || ended !== undefined;
const tagIds = (Array.isArray(item.labels) ? item.labels : []).map(tagFor).filter(Boolean);
if (kind === 'progress' && !isDone) tagIds.push(inProgressTag());
if (kind === 'other') tagIds.push(tagFor(item.listTitle));
if (kind === 'other') tagIds.push(listTag(item.listTitle));
const notes = [String(item.description || '').trim(), checklistMarkdown(item.checklists)].filter(Boolean).join('\n\n');
const created = msOf(item.createdAt) || now;
const spent = Number(item.spentTime);
@@ -449,7 +453,7 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
});
// One board, one panel per list, filtering the way the list decides.
const otherTags = listTitles.filter(title => listKind(title) === 'other').map(tagFor);
const otherTags = listTitles.filter(title => listKind(title) === 'other').map(listTag);
const panel = (title, index, filter) => ({
id: `wekan-panel-${index + 1}`, title: title || 'Untitled', taskIds: panelTasks.get(title) || [],
includedTagIds: [], excludedTagIds: [], projectIds: [''],
@@ -464,7 +468,7 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
excludedTagIds: [...(tagState.entities[SP_IN_PROGRESS_TAG_ID] ? [SP_IN_PROGRESS_TAG_ID] : []), ...otherTags],
backlogState: backlogUsed ? 2 : 1,
});
default: return panel(title, index, { includedTagIds: [tagFor(title)] });
default: return panel(title, index, { includedTagIds: [listTag(title)] });
}
});
+433 -1
View File
@@ -14,7 +14,439 @@ Releases per day:
| 11 | 6 |
| 12 | 5 |
| 13 | 1 |
| 14 | 1 |
| 14 | 2 |
# v12.41 2026-10-14 WeKan ® release
**In short:** **custom board roles**, **per-list permissions** and boards
**every signed-in user may edit**; board **keys** (`DEV-7`) and **shared
labels**; WeKan serves **HTTPS** itself; **Bun single files** and **musl
bundles and AppImages** for Alpine; on **Sandstorm**, webhooks and list sync
leave through the Powerbox and the REST API takes a grain API key. Seventeen
locale files that held **another
language**, and six full of machine-fill prefixes, are rewritten in their
own, and fourteen **GitHub CodeQL** alerts are fixed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2352f1f44f">HTML tags are removed until none are left, and no input is spliced into a pattern</a>. Thanks to GitHub CodeQL and xet7.</summary>
- **#570, #571:** an HTML-only email became card text with one pass of each
removal, so a script element spliced together by removing an inner one
survived, and its contents reached the card. Both removals now run until
nothing changes.
- **#574:** the single file's settings writer put the setting name into a
regular expression. A setting must be an environment variable name and is
compared literally, and a value must be one line. The kept-versions reader
now reads file names literally too.
- **#572, #573, #575-#580:** tests escaped only some characters, or matched
example hostnames with unescaped dots; they compare literally or escape
fully now.
`tests/tagStrippingFixedPoint.test.cjs` now also catches the `<[^>]+>`
spelling it missed, which also found the search-index text of Office
documents: never rendered, so recorded as such. `tests/emailToCard.test.cjs`
fails on the old code. Nothing here is an attempt the server refuses at run
time, so no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/167ccf9e2d">An email's HTML becomes text in one pass, with no pattern to take apart</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alerts #581 and #582 were raised on the fix above: email to card
still removed `<script>` elements and tags with regular expressions, looped
to a fixed point through a helper that CodeQL does not follow. `htmlToText`
in `models/lib/emailToCard.js` now walks the characters once. A tag is
dropped whole, a script, style or head element goes with what is in it (also
unclosed), an unfinished tag is not kept as text, and a `<` that does not
start a tag stays text - except right before another `<`, where it would
read as a tag once the next one is gone. `tests/emailToCard.test.cjs` covers
those cases, and `tests/tagStrippingFixedPoint.test.cjs` no longer accepts a
fixed-point helper and now also catches a whole element removed by pattern,
in every file. Nearly every HTML email has a `<style>`, so a removed element
is ordinary use, not an attempt: no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/73fcfb8545">No HTTP response sends the request back, tests included</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alert #583: the HTTPS test's server answered with the request's own
URL in the body, the shape of reflected cross-site scripting. It now names
the page by comparison. `tests/noReflectedRequest.test.cjs` keeps the shape
out of every file. Test code only, so no Admin Panel / Problems key is added.
</details>
and adds the following new features:
**Board roles** - who may do what on a board, and where a membership comes from.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">Custom board roles, built in the Admin Panel from what a member may do</a>. Thanks to xet7.</summary>
In Admin Panel / People / Roles a site admin builds named roles from five
capabilities: sees all cards, comments, edits, moves cards, invites. A board
admin then gives one to a member in the same popup as the built-in roles.
Board admin stays a built-in role. Every check asks the capability table,
which answers from the custom role. The member's built-in flags carry the
strongest built-in role that grants nothing more, so code reading a flag
never grants more, and a deleted role leaves exactly that. This holds for
every combination, which `tests/customBoardRoles.test.cjs` checks.
Invitations, REST reads, imports and member mapping follow it. The comment
form, reactions and chat now ask the comment capability, so a No comments
member is no longer offered reactions the server refuses. The new strings are
translated in all 234 languages, and the Roles Status table gains a Move cards
column. See [Board roles](docs/Features/Members/Roles.md#custom-roles).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">A membership that came through a team or organization leaves with it</a>. Thanks to mfnalex and xet7.</summary>
Joining a team already added its boards; leaving it now takes away the
memberships it gave, and only those. Each records where it came from
(`grantedBy`), so a direct membership is never touched and a board admin is
kept. A board admin's own add or removal is final. This covers user edits,
LDAP, OIDC, the board sidebar, and organizations that propagate their
members. `tests/groupBoardMembership.test.cjs` covers the decisions, and a
runtime test joins, leaves and rejoins a team against a real database.
</details>
**Outgoing requests** - webhooks and downloads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/78957e14cb">Outgoing webhooks go through an HTTP proxy, with the SSRF checks kept</a>. Thanks to gbarre and xet7.</summary>
`HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` work for every outgoing request
(on the snap: `http-proxy`, `https-proxy`, `no-proxy`). The destination is
still resolved and checked first, and the proxy is given that checked
address, never the name: an `http://` request goes in absolute form, an
`https://` one through a `CONNECT` tunnel, with TLS still checked against the
real name. A destination the guard refuses never reaches the proxy.
`tests/outboundProxy.test.cjs` runs a real proxy on loopback. See
[Proxy](docs/Features/Webhooks/Proxy.md).
</details>
**Who may edit where** - boards open to every signed-in user, and lists with
rules of their own.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">A board can let every signed-in user edit it, when the Admin Panel allows it</a>. Thanks to Meeques and xet7.</summary>
Admin Panel / Settings / Visibility gains "Allow boards that every signed-in
user may edit", off by default. With it on, a board admin of a public board
or one open to every signed-in user can turn it on for that board in the
visibility popup. A signed-in non-member may then comment, edit and move
cards like a Normal member - never change the board - and their first edit
makes them a Normal member, so every change stays attributed. A member keeps
their own role, a member a board admin removed stays removed, and rules,
webhooks, inbound email and list sync never act with a non-member's rights.
Board-wide settings such as list width and sticky headers stay members-only.
`tests/instanceEditing.test.cjs` and a runtime test cover it. See
[Visibility](docs/Features/Admin-Panel/Settings/Visibility.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Lists can allow or refuse editing and commenting per role</a>. Thanks to Meeques and xet7.</summary>
The list menu gains List permissions, for board admins: for each role -
built-in, custom, and on an open board everyone signed in - Edit and Comment
are as on the board, allowed, or not allowed in this list. The issue's
example works: a read-only board with one list where everybody may add cards
and comment. A board admin is never restricted, and a removed member stays
removed. The server applies it to every card, checklist, attachment and
comment write and to moves into the list, and the REST API honours a list's
refusal on every per-list card route. Only the board admin method writes the
setting; a direct write is refused and shown in Admin Panel / Problems.
`tests/listPermissions.test.cjs` and a runtime test cover it. See
[List permissions](docs/Features/Board/List-Permissions.md).
</details>
**Boards and cards** - keys, card numbers and labels shared between boards.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Every board has a key, and card numbers show as KEY-7</a>. Thanks to justinr1234 and xet7.</summary>
A key is 2-10 capital letters and digits, unique in the instance, suggested
from the board's title (Development Team: DT) and set for every existing
board at startup. A board admin can change it in the rename popup. Cards show
`DEV-7` instead of `#7`, and searching for `DEV-7` finds that card. The
per-board numbers themselves are unchanged, so nothing is renumbered.
`tests/boardKey.test.cjs` and a runtime test cover it. See
[Board key](docs/Features/Board/Boards/Board-Key.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Shared labels for every board, an organization's boards or a team's</a>. Thanks to tiFabGrinch, ejlkemper and xet7.</summary>
Admin Panel / Labels keeps labels shared by every board, or by the boards of
one organization or team. A board offers them in its labels popup beside its
own, and using one copies it to the board linked to the shared label, so
renaming it or changing its colour in the Admin Panel changes it everywhere
it is used. Deleting a shared label leaves it on those boards as their own.
Only labels a member's own organizations and teams share are sent to them.
`tests/sharedLabels.test.cjs` and a runtime test cover it. See
[Shared labels](docs/Features/Board/Shared-Labels.md).
</details>
**Servers** - running WeKan without a proxy in front of it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1bf7a62cab">WeKan serves HTTPS itself, when a certificate is configured</a>. Thanks to 68040 and xet7.</summary>
With `HTTPS_PORT`, `HTTPS_CERT_FILE` and `HTTPS_KEY_FILE` set (a path or
the PEM itself), WeKan also listens for HTTPS, on the same app, and
`HTTPS_REDIRECT=true` sends plain-HTTP requests there. Without them nothing
changes, and a proxy such as Caddy stays the recommendation for production.
The snap has `https-port`, `https-cert-file`, `https-key-file` and
`https-redirect`. `tests/httpsServer.test.cjs` makes a real TLS connection.
See [HTTPS without a proxy](docs/Platforms/Webserver/HTTPS-without-proxy.md).
</details>
**Platforms** - more ways to run WeKan.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b1e58094f4">Bun single files: one executable per Bun platform, musl included</a>. Thanks to xet7.</summary>
Each Bun bundle also comes as one executable:
`wekan-<version>-bun-<target>-single-file` for Linux x64 and arm64 (glibc
and musl, so Alpine too), macOS and Windows. It is the published bundle ZIP,
unchanged, inside that bundle's own Bun, built on the platform itself, so the
runtime is the one the bundle was tested with. The first start unpacks the
bundle once into a cache directory, without a second Bun, and every start
runs it with the executable itself as Bun. Like the Bun bundle it needs
`MONGO_URL` and `ROOT_URL`. Its ZIP reader refuses any entry or link that
would land outside the cache directory. Before it is published each file is
checked: it names itself, refuses to start without a database, unpacks once
and loads every native addon of the bundle. `tests/bunSingleFile.test.cjs`
covers the reader and its refusals. See
[Single file](docs/Platforms/Single-File.md#bun-single-files).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a1d2994c1c">musl bundles and AppImages for Alpine and the other musl systems</a>. Thanks to xet7.</summary>
Every release also has `wekan-<version>-linux-x64-musl.zip` and
`-linux-arm64-musl.zip`, with FerretDB, and the AppImages
`WeKan-<version>-x86_64-musl` and `-aarch64-musl`. Each is the release's
Linux bundle rebuilt inside Alpine on a runner of its CPU: the musl Node.js of
the same version, checked against its SHA256, and every native addon
compiled against musl. Before it is published, each addon's architecture is
checked, sharp must load, and the bundle must start and answer HTTP 200; the
AppImages are started inside Alpine too. `tests/muslBundles.test.cjs`
covers the build and the workflows. See [Alpine](docs/Platforms/FOSS/OS/Alpine.md).
</details>
**Sandstorm** - reaching out of a grain, and into it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">Outgoing webhooks leave a grain through the Powerbox</a>. Thanks to xet7.</summary>
A grain has no network but the bridge proxy, so a webhook was never sent.
Each saved webhook now offers "Allow this grain to reach this address", which
opens the Powerbox for exactly that address; once approved, the webhook goes
through the bridge with that grant. The grant belongs to the destination, not
to a person, and is approved as the address's parent path so the bridge
delivers to the exact address. Off Sandstorm nothing changes: every sender
still goes through the SSRF guard. `tests/sandstormWebhookEgress.test.cjs`
runs it against a fake bridge proxy. See
[Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#outgoing-webhooks).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">The REST API works with a Sandstorm API key</a>. Thanks to xet7.</summary>
A Sandstorm API key keeps the Authorization header for itself, so WeKan saw
no user. In a grain the bridge says who is calling, and WeKan now acts as
that user - only in a grain, only for an account that exists and is enabled,
and a key without the participate permission reads only. Off Sandstorm the
`X-Sandstorm-*` headers are never believed.
`tests/sandstormApiIdentity.test.cjs`
covers both sides.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d53b4da799">List sync from GitHub, GitLab, Gitea and Forgejo works in a grain</a>. Thanks to xet7.</summary>
A board member who may write saves a list's sync source and allows the grain
to reach that tracker's API in the Powerbox; the periodic sync then goes
through the bridge with that grant, which belongs to the tracker, so no one
needs to be signed in. GitLab's token is sent as `private_token`, because the
bridge drops `PRIVATE-TOKEN`, so private GitLab projects sync too; GitHub,
Gitea and Forgejo sync public repositories, whose token headers cannot pass
the bridge. Jira signs in only with an `Authorization` header, which the
bridge replaces, so it is refused in a grain with that reason. Off Sandstorm
nothing changes. `tests/sandstormSyncEgress.test.cjs` runs it against a fake
bridge proxy. See [Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#list-sync).
</details>
and closes the following already-done feature request:
- [Each member chooses how often email notifications come: never, periodically or instantly](https://github.com/wekan/wekan/commit/3f88fb39a5).
Thanks to saurabharch and xet7.
and fixes the following bugs:
**Lists and swimlanes** - what goes with a list, and where it is shown.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a3797dbf6">Archiving a list archives its cards, and restoring it brings them back</a>. Thanks to Raymond-Voogt and xet7.</summary>
Only template lists archived their cards, so archiving any other list left
its cards visible in the Lists, Table and Calendar views. The server now
archives the cards of every list with it, whichever way it is archived - the
list menu, the Archive sidebar, the REST API - and restoring the list
restores exactly those cards, the rule #2292 gave swimlanes. A card that was
archived on its own stays archived. Cards archived with the list get the
list's own archive time, so a browser clock that differs from the server's
cannot keep them from coming back. `tests/archiveListCards.test.cjs` and a
runtime test cover it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ebf2a019a6">Lists missing their swimlane counts only lists that lost it, and Restore never hides cards</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Problems counted every board-wide list whose history recorded
the swimlane it was created in, so lists made since the automatic
un-binding stopped (#6515, in v10.31) were counted too, and Restore would
have bound them there, hiding the cards they hold in other swimlanes. Now a
list created after v10.31 is never offered, and a list with any card in
another swimlane is never restored. A list that really lost its binding is
still restored. `tests/listSwimlaneRestore.test.cjs` and a runtime test
cover both rules.
</details>
**Board roles** - helpers that read a role.
- [Five role helpers that answered the same for everybody: Import never offered, rule controls for every member, the last admin not protected, OIDC default-board roles](https://github.com/wekan/wekan/commit/548834c4ee).
Thanks to xet7.
and fixes the following build bug:
- [The OpenBSD bundles are built on OpenBSD 7.8, where Node.js 26 now builds](https://github.com/wekan/wekan/commit/209e3b0c47).
Thanks to xet7.
and updates the following documentation:
- [Outgoing webhooks to Node-RED, and why an internal address is refused](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to yeurplc and xet7.
- [Uberspace 7: install, the update script, and email on one environment line](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to jeyca and xet7.
and updates the following translations:
**Languages that held another language** - each locale file now says what its
tag says.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f3c92273ae">Hong Kong Chinese in Traditional characters, and Flemish in Dutch</a>. Thanks to xet7.</summary>
Hong Kong Chinese had 90 values in Simplified Chinese; they are now
Traditional. Flemish held 158 values in about a dozen other languages, Venda
and Vietnamese among them, and they are now Flemish, with its 225 values
still in English filled too.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ba6d0014f7">Locale files that held another language are rewritten in their own</a>. Thanks to xet7.</summary>
Romanian held Italian, French and Spanish; Aromanian, Ladin, Romansh and
Venetian held Italian; Galician held Portuguese, Acehnese Malay, Venda Zulu,
Slovak Czech and Polish, Upper Sorbian Czech, Volapük Esperanto and Bislama
Tok Pisin, and Uzbek in Arabic script was half Latin script. Each is now in
its own language. Machine-fill prefixes and notes ("Faka-Tonga:", "Kay
willaymi:", "Wolayttatto:", "WeKan:" and others) are gone from Tongan,
Quechua, Wolaytta, Upper Sorbian, Aragonese, Friulian and Luganda. Chinese
no longer calls a board a wooden plank, and List actions no longer reads as
"list the actions" in seven languages. Values already reviewed - pinned by a
test, corrected by an audit, or quoted by another string - are kept.
`tests/wrongLanguageLocales.test.cjs` keeps the old languages and prefixes
out and checks every placeholder against English. Native speakers are asked
to review these languages.
</details>
**Igbo** - notification, integration, workflow and recovery settings.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/238354f6a54a234c9db1cf88a9436d15087c6ba0">Translate Igbo notification delivery and integration settings</a>. Thanks to xet7.</summary>
Translate 42 notification and integration strings, preserving grouping,
schedules, quiet hours, inherited webhook settings, identity omission and
literal link-rule syntax. Direct technical wording needs fluent review.
Both translation and guarded-correction suites pass with 30,943 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One notification browser case passes syntax and discovery; execution is pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/daac3ead0dceb451bdb69970cc5b19678d17ce6d">Translate Igbo workflow and permission controls</a>. Thanks to xet7.</summary>
Translate 19 workflow, directory-sync and permission messages, preserving
Wrike status behavior, environment identifiers and subtask completion choices.
Technical prose is lower confidence and needs fluent review.
Both translation and guarded-correction suites pass with 30,962 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
Three browser cases pass syntax and discovery; execution remains pending.
Igbo has 69 detected placeholders left. All-language semantic review is
unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c5391fb98d3619308aa2ba3328f47db870e5655">Translate Igbo stopped list sync recovery messages</a>. Thanks to xet7.</summary>
Translate 18 recovery messages, preserving applied changes, discarded pending
writes, replay restrictions and the oldest-50 display limit. Recovery prose
needs fluent review.
Both translation and guarded-correction suites pass with 30,980 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One browser case passes syntax and discovery; execution remains pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8c25ce78b94d65d3a7fd4540bdb065443a6c6dbd">Translate Igbo interrupted import recovery messages</a>. Thanks to xet7.</summary>
Translate 21 messages, preserving permanent deletion, retained partial boards,
source-file limitations, untouched foreign boards and recovery limits.
Direct recovery prose needs fluent review.
Both translation and guarded-correction suites pass with 31,001 corrections.
All 21 human-preference checks pass; existing Chinese values are unchanged
(38,430 checked). One browser case passes syntax and discovery; execution is
pending. All-language translation and semantic verification remain unfinished.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.40 2026-10-14 WeKan ® release
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v13.04.0",
"version": "v13.06.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v13.04.0",
"version": "v13.06.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -80,7 +80,7 @@
"yjs": "13.6.33"
},
"devDependencies": {
"@meteorjs/rspack": "^3.0.0-rc.2",
"@meteorjs/rspack": "^3.0.0",
"@rsdoctor/rspack-plugin": "^1.6.4",
"@rspack/cli": "^2.2.9",
"@rspack/core": "^2.2.9",
@@ -2973,9 +2973,9 @@
}
},
"node_modules/@meteorjs/rspack": {
"version": "3.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@meteorjs/rspack/-/rspack-3.0.0-rc.2.tgz",
"integrity": "sha512-0YwVxT5X7dBSrBOoQTXWdiWStnAHg7IYIs229TBxVifCfEHulxsGkuV+OIgz1arZWUQACOyZxQIUdS3aR4fK8w==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@meteorjs/rspack/-/rspack-3.0.0.tgz",
"integrity": "sha512-INccdjjsdxUzwgeColyJY3pNBO2h3auag7XikHmuNKDiNqKbt5pIP2Ky1+10NoiiLZIQo8EL17zsGoCinvM2TQ==",
"dev": true,
"license": "ISC",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v13.04.0",
"version": "v13.06.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -38,7 +38,7 @@
"postinstall": "node scripts/patch-yargs-dirname.cjs && node scripts/patch-pdfkit-entry.cjs && node scripts/patch-meteor-node-stubs-pbkdf2.cjs"
},
"devDependencies": {
"@meteorjs/rspack": "^3.0.0-rc.2",
"@meteorjs/rspack": "^3.0.0",
"@rsdoctor/rspack-plugin": "^1.6.4",
"@rspack/cli": "^2.2.9",
"@rspack/core": "^2.2.9",
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
name: 'allow-deny',
version: '2.1.2-rc360.1_1',
version: '2.1.2_1',
// Brief, one-line summary of the package.
summary: 'Implements functionality for allow/deny and client-side db operations',
// URL to the Git repository containing the source code for this package.
+2 -2
View File
@@ -1,10 +1,10 @@
Package.describe({
summary: "Meteor's latency-compensated distributed data server",
version: '3.6.0-rc360.1_1',
version: '3.6.0_1',
documentation: null,
});
// WeKan fork of Meteor 3.6-rc.1's ddp-server, for the Admin Panel / Problems
// WeKan fork of Meteor 3.6's ddp-server, for the Admin Panel / Problems
// report of a refused DDP message. uWebSockets.js is left out, as Meteor's
// published 3.4.1-rc360.0 isopack had no copy of it either: it is only loaded
// for the opt-in "uws" transport, and it would be fetched from a git URL at
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
name: "dynamic-import",
version: '0.8.1-rc360.1_1',
version: '0.8.1_1',
summary: "Runtime support for Meteor 1.5 dynamic import(...) syntax",
documentation: "README.md"
});
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
summary: "Facebook OAuth flow",
version: '1.12.1-rc360.1_1',
version: '1.12.1_1',
});
Package.onUse(api => {
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
summary: "Google OAuth flow",
version: '1.5.1-rc360.1_1',
version: '1.5.1_1',
});
Cordova.depends({
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
name: "shell-server",
version: '0.7.2-rc360.1_1',
version: '0.7.2_1',
summary: "Server-side component of the `meteor shell` command.",
documentation: "README.md",
devOnly: true,
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v13.04</title>
<title>WeKan REST API v13.06</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":"v13.04","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.06","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'));
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: WeKan REST API
version: v13.04
version: v13.06
description: |
The REST API allows you to control and extend WeKan with ease.
+6 -2
View File
@@ -61,6 +61,10 @@ npm_cli="$(dirname "$(readlink -f "$(command -v npm)")")/npm-cli.js"
[ -f "$npm_cli" ] || npm_cli="$PREFIX/lib/node_modules/npm/bin/npm-cli.js"
npm() { "$node" "$npm_cli" "$@"; }
export -f npm 2>/dev/null || true
# npm-retry.sh is a new bash: it is given the command itself, "$node"
# "$npm_cli", never the npm function above. The function reaches it through
# export -f, but $node and $npm_cli do not, and under its set -u v13.04's
# Android bundles all died there with "environment: node: unbound variable".
server="$root/bundle/programs/server"
appmods="$server/npm/node_modules"
@@ -76,7 +80,7 @@ fi
# have no Android prebuild, were never compiled, and WeKan did not start. It
# is idempotent, so a bundle already prepared is left as it is.
"$node" "$root/releases/prepare-bundle-npm.mjs" "$root/bundle"
(cd "$server" && bash "$root/releases/npm-retry.sh" npm install)
(cd "$server" && bash "$root/releases/npm-retry.sh" "$node" "$npm_cli" install)
# accounts-password requires both at startup with no fallback. Load them here
# with Android's Node.js, so a missing addon stops the build with its name -
# check-addon-arch below passes Linux prebuilds, which Android does not load.
@@ -86,7 +90,7 @@ for (const name of ["argon2", "bcrypt"]) {
catch (error) { console.error(`::error::${name} has no Android addon: ${error.message.split("\n")[0]}`); process.exit(1); }
}')
# sharp has no Android prebuilt: built from source against Termux's libvips.
if ! (cd "$appmods/sharp" && bash "$root/releases/npm-retry.sh" npm install --no-save --omit=dev node-addon-api node-gyp && "$node" install/build.js); then
if ! (cd "$appmods/sharp" && bash "$root/releases/npm-retry.sh" "$node" "$npm_cli" install --no-save --omit=dev node-addon-api node-gyp && "$node" install/build.js); then
echo "::warning::sharp could not be built against Termux's libvips here; it runs as WebAssembly (@img/sharp-wasm32)."
fi
"$node" "$root/releases/prune-build-only-modules.mjs" "$root/bundle"
+3 -2
View File
@@ -55,10 +55,11 @@ against its assets: new or removed OS/CPU targets fail planning visibly.
Meteor's Bun/ESM support is still proposed in
[meteor/meteor#14751](https://github.com/meteor/meteor/pull/14751).
The workflow clones the app's exact Meteor release into `.tools/meteor-bun`.
For Meteor 3.6-rc.1 it applies `meteor-3.6-rc.1.patch`, ported from
For Meteor 3.6 it applies `meteor-3.6.patch`, ported from
`dupontbertrand/meteor` commit `c0ab9318822f36f902da6124c1356ede58b71a4e` onto
Meteor 3.6-rc.0 (`cc8cf7d21d24da5f4a4fd456527225bc5ce0e721`); it applies
unchanged to 3.6-rc.1 (`311f5cd2d26f74ce187dc2e639bd57aab81929e4`).
unchanged to 3.6-rc.1 (`311f5cd2d26f74ce187dc2e639bd57aab81929e4`) and to 3.6
(`e730097ce42a0dbc4c134aeb1706147b02112844`).
The port preserves the 3.6 transport documentation and Rspack's await of the
server bundle's exported promise. It does not downgrade package pins, change
File renamed without changes.
+4 -4
View File
@@ -12,9 +12,9 @@ meteor_dir="$root/.tools/meteor-bun"
git clone --depth 1 --branch "release/METEOR@$release" https://github.com/meteor/meteor.git "$meteor_dir"
if [ ! -f "$meteor_dir/tools/static-assets/server/esm-loader.mjs" ]; then
# Do not silently downgrade core packages to the PR's older 3.5 base.
[ "$release" = 3.6-rc.1 ] || { echo "Port the Bun patch to Meteor $release before building"; exit 1; }
[ "$(git -C "$meteor_dir" rev-parse HEAD)" = 311f5cd2d26f74ce187dc2e639bd57aab81929e4 ] || exit 1
git -C "$meteor_dir" apply --check "$root/releases/bun/meteor-3.6-rc.1.patch"
git -C "$meteor_dir" apply "$root/releases/bun/meteor-3.6-rc.1.patch"
[ "$release" = 3.6 ] || { echo "Port the Bun patch to Meteor $release before building"; exit 1; }
[ "$(git -C "$meteor_dir" rev-parse HEAD)" = e730097ce42a0dbc4c134aeb1706147b02112844 ] || exit 1
git -C "$meteor_dir" apply --check "$root/releases/bun/meteor-3.6.patch"
git -C "$meteor_dir" apply "$root/releases/bun/meteor-3.6.patch"
fi
printf '%s\n' "$meteor_dir" >> "$GITHUB_PATH"
+2 -2
View File
@@ -38,9 +38,9 @@ check() {
echo "=== MongoDB 7.0.14 (amd64 and arm64 native; amd64 also used via QEMU for armhf/s390x/ppc64el) ==="
check "MongoDB 7.0.14 amd64 (x86_64, ubuntu2204)" \
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.45.tgz"
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.48.tgz"
check "MongoDB 7.0.14 arm64 (aarch64, ubuntu2204)" \
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.45.tgz"
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.48.tgz"
echo " NOTE MongoDB Community has no prebuilt builds for s390x, ppc64le, or arm/v7."
echo " The amd64 binary above is also used on those arches via qemu-x86_64-static."
echo ""
+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 = 1304,
appVersion = 1306,
# Increment this for every release.
appMarketingVersion = (defaultText = "13.04~2026-11-09"),
appMarketingVersion = (defaultText = "13.06~2026-11-10"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+28
View File
@@ -59,6 +59,10 @@
// rejected by FerretDB ("infinity values are not
// allowed"), aborting MongoDB->FerretDB migration ->
// reset to a finite 0
// card-overtime-field #6888: REST PUT stored isOverTime, which nothing
// reads; the schema and UI field is isOvertime ->
// folded into isOvertime where that is unset, and
// the stray field removed
// card-children-boardid #6793: comments/reactions/text notes/attachments
// still carrying the board their card was moved
// AWAY from -> the card's current board id
@@ -863,6 +867,30 @@ const steps = [
},
},
{
// card-overtime-field (#6888): the REST card PUT stored its isOverTime
// parameter under that spelling (b895e61237 until #6888), while the
// schema, the UI and every reader use isOvertime - so an overtime set
// through the API answered 200 and was seen nowhere. #6888 writes the
// right field from now on; this carries over what was written before.
// Where a card has both, isOvertime - the one shown - is kept. Two
// updateMany batches and an $unset; idempotent, nothing left after.
name: 'card-overtime-field',
async check(db) {
return !!(await db.collection('cards').findOne({ isOverTime: { $exists: true } }, { projection: { _id: 1 } }));
},
async run(db) {
const cards = db.collection('cards');
let fixed = 0;
for (const value of [true, false]) {
const r = await cards.updateMany({ isOverTime: value, isOvertime: { $exists: false } }, { $set: { isOvertime: value } });
fixed += (r && r.modifiedCount) || 0;
}
await cards.updateMany({ isOverTime: { $exists: true } }, { $unset: { isOverTime: '' } });
return { fixed, unresolved: 0 };
},
},
{
// checklist-minicard-unset: `showChecklistAtMinicard: false` written by the
// old schema default, which now means something it did not mean then.
+40
View File
@@ -0,0 +1,40 @@
// The card date a Cards.before.update records as its a-<field> activity:
// the update's first changed field, when it is one of the four dates.
//
// A date SET carries timeValue; a date CLEARED (unsetDue/unsetStart/unsetEnd/
// unsetReceived send only a $unset, or a null value) carries timeOldValue
// alone - the same record a rule's removeDate writes
// (server/lib/syncRuleCardCommand.js), and the one models/lib/boardTimeline.js
// rewinds a card's dates through. The hook used to read modifier.$set[field]
// whatever the update was, so a $unset alone threw a TypeError inside it and,
// with a $set beside it, wrote timeValue: undefined.
//
// A cleared date is not "a card's date is set or changed" (#2474,
// server/triggersDef.js): isTimingClear() lets the rules skip it.
export const TIMING_FIELDS = ['receivedAt', 'dueAt', 'startAt', 'endAt'];
const has = (o, k) => !!o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
// { field, set: true, value } for a date set, { field, set: false } for a
// date cleared, or null when the update is not a change of a date.
export function timingChange(fieldNames, modifier) {
const fields = (Array.isArray(fieldNames) ? fieldNames : []).filter(name => name !== 'dateLastActivity');
const field = fields[0];
if (!TIMING_FIELDS.includes(field) || !modifier) return null;
const set = modifier.$set;
if (has(set, field)) {
const value = set[field];
if (value === null || value === undefined || value === '') return { field, set: false };
return { field, set: true, value };
}
if (has(modifier.$unset, field)) return { field, set: false };
return null;
}
// Is this activity a card date cleared - an a-<date> without a timeValue?
export function isTimingClear(activity) {
if (!activity || typeof activity.activityType !== 'string') return false;
const field = activity.activityType.replace(/^a-/, '');
return activity.activityType.startsWith('a-') && TIMING_FIELDS.includes(field) &&
(activity.timeValue === undefined || activity.timeValue === null || activity.timeValue === '');
}
+11 -7
View File
@@ -17,6 +17,7 @@ const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency'
const { stampCardListEntry } = require('/models/lib/cardListEntry');
import { titleChanged } from '/server/lib/titleChangeActivity';
import { descriptionChanged } from '/server/lib/descriptionChangeActivity';
import { timingChange } from '/server/lib/timingChangeActivity';
const { collectionWriteSucceeded } = require('/server/lib/collectionWriteOutcome');
const { deferSyncRecording, deferSyncLabelActivities } = require('/server/lib/syncRecordingScope');
const { movedScrumMetadata } = require('/models/lib/scrumCopy');
@@ -1264,13 +1265,15 @@ Cards.after.update(async function(userId, doc, fieldNames) {
});
Cards.before.update(async (userId, doc, fieldNames, modifier) => {
const fields = fieldNames.filter(name => name !== 'dateLastActivity');
const timingaction = ['receivedAt', 'dueAt', 'startAt', 'endAt'];
const action = fields[0];
if (fields.length > 0 && timingaction.includes(action)) {
// A date set carries timeValue, a date cleared ($unset) timeOldValue alone;
// neither reads a $set that is not there (server/lib/timingChangeActivity.js).
const change = timingChange(fieldNames, modifier);
if (change) {
// A durable rule date action writes this activity from its saved plan.
if (deferSyncRecording('timing', doc)) return;
const value = modifier.$set[action];
const action = change.field;
// Clearing a date the card does not have changes nothing.
if (!change.set && !doc[action]) return;
const oldvalue = doc[action] || '';
const activityType = `a-${action}`;
const card = await ReactiveCache.getCard(doc._id);
@@ -1296,7 +1299,7 @@ Cards.before.update(async (userId, doc, fieldNames, modifier) => {
cardId: doc._id,
cardTitle: doc.title,
timeKey: action,
timeValue: value,
...(change.set ? { timeValue: change.value } : {}),
timeOldValue: oldvalue,
listId: card.listId,
swimlaneId: card.swimlaneId,
@@ -1985,7 +1988,8 @@ WebApp.handlers.put(
if ('isOverTime' in scalars.set) {
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { isOverTime: scalars.set.isOverTime } },
// Keep the REST parameter spelling; the schema and UI use isOvertime.
{ $set: { isOvertime: scalars.set.isOverTime } },
);
updated = true;
}
+3
View File
@@ -27,6 +27,7 @@ import { relativeDateOffset } from '/models/lib/relativeDateOffset';
import { resolveRuleSwimlaneId, resolveRuleListId } from '/models/lib/ruleActionResolve';
import { cardTitleMatchList, cardTitleFilterMatches } from '/models/lib/ruleCardTitleFilter';
import { triggerMatchesWithVars } from '/models/lib/ruleTriggerVars';
import { isTimingClear } from '/server/lib/timingChangeActivity';
import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
import { tripCanary } from '/server/lib/canary';
import { substituteVars, recipientVars } from '/models/lib/ruleVarsSubstitute';
@@ -424,6 +425,8 @@ export const RulesHelper = {
return { activity: { ...activity, boardId: card.boardId }, followedFrom: activity.boardId };
},
async findMatchingRules(activity) {
// A card date cleared is not "a card's date is set or changed" (#2474).
if (isTimingClear(activity)) return [];
const activityType = activity.activityType;
const matchingRules = [];
if (TriggersDef[activityType] !== undefined) {
+6 -6
View File
@@ -7,7 +7,7 @@
# FOUR channels — stable, candidate, beta and edge. Every snap job does exactly
# that (snap-native, snap-launchpad and snap-variants all publish
# `stable,candidate,beta,edge`), for the default snap and for the wekan-ondra and
# wekan-gantt-gpl variants alike. It ships the newest WeKan (Meteor 3.6-rc.1,
# wekan-gantt-gpl variants alike. It ships the newest WeKan (Meteor 3.6,
# Node.js 26, FerretDB v1, MongoDB 7, Caddy 2).
#
# snapcraft-core26.yaml is the SAME WeKan on `base: core26`, kept for testing the
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '13.04'
version: '13.06'
base: core24
confinement: strict
grade: stable
@@ -258,7 +258,7 @@ parts:
# mongodb-control disables the mongodb service when database=ferretdb.
*) echo "No MongoDB server for ${CRAFT_ARCH_BUILD_FOR}; FerretDB-only arch, skipping mongod."; exit 0 ;;
esac
MONGO_URL="https://fastdl.mongodb.org/linux/mongodb-linux-${MONGO_ARCH}-ubuntu2204-7.0.45.tgz"
MONGO_URL="https://fastdl.mongodb.org/linux/mongodb-linux-${MONGO_ARCH}-ubuntu2204-7.0.48.tgz"
bash "${CRAFT_PROJECT_DIR:-${SNAPCRAFT_PROJECT_DIR:-$PWD}}/releases/fetch.sh" -o mongodb.tgz "$MONGO_URL"
tar -xzf mongodb.tgz --strip-components=1
mkdir -p "$SNAPCRAFT_PART_INSTALL/bin"
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-13.04-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-13.06-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v13.04/wekan-13.04-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v13.06/wekan-13.06-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/v13.04/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v13.06/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+3 -3
View File
@@ -37,7 +37,7 @@ test('the bundle is rebuilt by the Android Node.js itself, checked and started',
// happened in programs/server/node_modules, where sharp is not.
assert.match(build, /appmods="\$server\/npm\/node_modules"\nrm -rf "\$appmods\/@img\/sharp-linux-"\*/);
assert.doesNotMatch(build, /rm -rf "\$server\/node_modules\/@img/, 'not the wrong directory');
assert.match(build, /cd "\$appmods\/sharp" && bash "\$root\/releases\/npm-retry\.sh" npm install --no-save --omit=dev node-addon-api node-gyp && "\$node" install\/build\.js/);
assert.match(build, /cd "\$appmods\/sharp" && bash "\$root\/releases\/npm-retry\.sh" "\$node" "\$npm_cli" install --no-save --omit=dev node-addon-api node-gyp && "\$node" install\/build\.js/);
assert.match(build, /::warning::sharp could not be built[^\n]*WebAssembly/);
// Termux's user does not own the bind mount: chmod only what is not
// already executable (v12.43-v12.44 stopped at "Operation not permitted").
@@ -59,7 +59,7 @@ test('argon2 and bcrypt are compiled for Android and must load before the bundle
const prepare = build.indexOf('"$node" "$root/releases/prepare-bundle-npm.mjs" "$root/bundle"');
// Through npm-retry.sh since v13.03's Sandstorm .spk was lost to a 504 from
// github.com (tests/npmRetry.test.cjs): the same install, retried on outage.
const install = build.indexOf('(cd "$server" && bash "$root/releases/npm-retry.sh" npm install)');
const install = build.indexOf('(cd "$server" && bash "$root/releases/npm-retry.sh" "$node" "$npm_cli" install)');
const loads = build.indexOf('for (const name of ["argon2", "bcrypt"])');
assert.ok(prepare > 0 && prepare < install, 'the bundle declares its installers before npm installs');
assert.ok(loads > install && loads < build.indexOf('check-addon-arch.mjs'), 'loaded with Android Node.js right after the install');
@@ -105,7 +105,7 @@ test('termux-exec is preloaded before npm, so #!/usr/bin/env scripts run', () =>
assert(preload > 0, 'LD_PRELOAD is exported');
assert.match(build, /"\$PREFIX\/lib\/libtermux-exec-ld-preload\.so" "\$PREFIX\/lib\/libtermux-exec\.so"/, 'both termux-exec generations, newest first, as login does');
assert.match(build, /LD_PRELOAD="\$lib" sh -c true/, 'kept only if a program still starts with it');
assert(preload < build.indexOf('(cd "$server" && bash "$root/releases/npm-retry.sh" npm install)'), 'before npm runs any install script');
assert(preload < build.indexOf('(cd "$server" && bash "$root/releases/npm-retry.sh" "$node" "$npm_cli" install)'), 'before npm runs any install script');
});
test('negative: an LD_PRELOAD already set is not replaced', () => {
+107
View File
@@ -0,0 +1,107 @@
'use strict';
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 source = fs.readFileSync(path.join(__dirname, '../models/cards.js'), 'utf8');
const start = source.indexOf('async function cardMembers(');
const end = source.indexOf('async function cardLabels(', start);
assert.ok(start >= 0 && end > start, 'membership activity hooks are present');
function hooks(user) {
const activities = [];
const context = {
ReactiveCache: { getUser: async () => user },
Activities: { insertAsync: async activity => activities.push(activity) },
};
vm.runInNewContext(source.slice(start, end), context);
return { ...context, activities };
}
for (const [field, hook, idKey, joined, removed] of [
['members', 'cardMembers', 'memberId', 'joinMember', 'unjoinMember'],
['assignees', 'cardAssignees', 'assigneeId', 'joinAssignee', 'unjoinAssignee'],
]) {
const card = { _id: 'card', boardId: 'board', listId: 'list', swimlaneId: 'lane' };
test(`${field}: removing an absent user is a no-op without activity`, async () => {
const h = hooks(undefined);
for (const membership of [undefined, [], ['other']]) {
await h[hook]('actor', { ...card, [field]: membership }, [field], { $pull: { [field]: 'missing' } });
}
assert.equal(h.activities.length, 0);
});
test(`${field}: a deleted user's assignment can be removed and recorded`, async () => {
const h = hooks(undefined);
await h[hook]('actor', { ...card, [field]: ['deleted'] }, [field], { $pull: { [field]: 'deleted' } });
assert.equal(h.activities.length, 1);
assert.deepEqual({ ...h.activities[0] }, {
userId: 'actor', username: undefined, activityType: removed,
boardId: card.boardId, cardId: card._id, [idKey]: 'deleted',
listId: card.listId, swimlaneId: card.swimlaneId,
});
});
test(`${field}: normal joins and removals retain the username and activity scope`, async () => {
const h = hooks({ username: 'member' });
await h[hook]('actor', { ...card, [field]: [] }, [field], { $addToSet: { [field]: 'member' } });
await h[hook]('actor', { ...card, [field]: ['member'] }, [field], { $pull: { [field]: 'member' } });
assert.deepEqual(h.activities.map(activity => activity.activityType), [joined, removed]);
for (const activity of h.activities) {
assert.equal(activity.username, 'member');
assert.equal(activity[idKey], 'member');
assert.equal(activity.userId, 'actor');
assert.equal(activity.cardId, card._id);
}
await h[hook]('actor', { ...card, [field]: [] }, [field], { $pull: { [field]: 'member' } });
await h[hook]('actor', { ...card, [field]: ['member'] }, ['title'], { $pull: { [field]: 'member' } });
assert.equal(h.activities.length, 2);
});
}
// The join halves had the same unguarded user.username (#6889 follow-up):
// REST checks the user before adding, but a client $addToSet, a copied or
// imported card, or a rule can add an id whose account is gone.
for (const [field, hook, idKey, joined] of [
['members', 'cardMembers', 'memberId', 'joinMember'],
['assignees', 'cardAssignees', 'assigneeId', 'joinAssignee'],
]) {
test(`${field}: adding a user whose account is gone does not throw, and records the id`, async () => {
const h = hooks(undefined);
const card = { _id: 'card', boardId: 'board', listId: 'list', swimlaneId: 'lane' };
await assert.doesNotReject(() => h[hook]('actor', { ...card, [field]: [] }, [field], { $addToSet: { [field]: 'gone' } }));
assert.equal(h.activities.length, 1);
assert.equal(h.activities[0].activityType, joined);
assert.equal(h.activities[0][idKey], 'gone');
assert.equal(h.activities[0].username, undefined);
});
}
// Negative, tree-wide: a user looked up is never dereferenced before it is
// known to exist - the shape both halves of #6889 had.
test('nowhere: `const x = user.field;` straight after getUser() without a guard', () => {
const root = path.join(__dirname, '..');
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (['node_modules', '.meteor', 'tests', 'packages', 'public'].includes(e.name) || e.name.startsWith('_build') || e.name.startsWith('.')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (p.endsWith('.js')) {
const lines = fs.readFileSync(p, 'utf8').split('\n');
lines.forEach((line, i) => {
const m = /const (\w+) = await ReactiveCache\.getUser\(/.exec(line);
if (!m) return;
const next = (lines.slice(i + 1).find(l => l.trim()) || '').trim();
if (new RegExp(`^const \\w+ = ${m[1]}\\.\\w+;`).test(next)) offenders.push(`${path.relative(root, p)}:${i + 2}`);
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(path.join(root, dir));
assert.deepEqual(offenders, []);
});
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// A card date cleared ($unset - unsetDue/unsetStart/unsetEnd/unsetReceived)
// went through the Cards.before.update timing hook, which read
// modifier.$set[field] whatever the update was: a $unset alone threw a
// TypeError inside the hook, and with a $set beside it an a-dueAt with
// timeValue: undefined was written - counted as a due date change and
// matched by the "date set or changed" rule triggers. Now a date set
// carries timeValue, a date cleared timeOldValue alone (as a rule's
// removeDate records it, and models/lib/boardTimeline.js rewinds), and the
// rules skip a clear.
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 { deferSyncRecording } = require('../server/lib/syncRecordingScope');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const helper = () => import('../server/lib/timingChangeActivity.js');
test('timingChange: a date set, a date cleared, or no date change', async () => {
const { timingChange } = await helper();
const at = new Date('2099-12-31T17:00:00Z');
assert.deepEqual(timingChange(['dueAt'], { $set: { dueAt: at } }), { field: 'dueAt', set: true, value: at });
assert.deepEqual(timingChange(['dateLastActivity', 'startAt'], { $set: { dateLastActivity: at, startAt: at } }),
{ field: 'startAt', set: true, value: at });
// The bug: a $unset alone, and a $unset beside a stamped $set.
assert.doesNotThrow(() => timingChange(['dueAt'], { $unset: { dueAt: '' } }));
assert.deepEqual(timingChange(['dueAt'], { $unset: { dueAt: '' } }), { field: 'dueAt', set: false });
assert.deepEqual(timingChange(['endAt', 'modifiedAt'], { $unset: { endAt: '' }, $set: { modifiedAt: at } }), { field: 'endAt', set: false });
assert.deepEqual(timingChange(['receivedAt'], { $set: { receivedAt: null } }), { field: 'receivedAt', set: false });
// Negative: not a date change.
for (const [fields, modifier] of [[['title'], { $set: { title: 'T' } }], [['title', 'dueAt'], { $set: { title: 'T', dueAt: at } }],
[[], {}], [['dueAt'], null], [['dueAt'], { $inc: { dueAt: 1 } }]]) {
assert.equal(timingChange(fields, modifier), null, JSON.stringify([fields, modifier]));
}
});
test('isTimingClear: an a-<date> without timeValue, and nothing else', async () => {
const { isTimingClear } = await helper();
assert.equal(isTimingClear({ activityType: 'a-dueAt', timeOldValue: new Date(1) }), true);
assert.equal(isTimingClear({ activityType: 'a-startAt', timeValue: null }), true);
assert.equal(isTimingClear({ activityType: 'a-dueAt', timeValue: new Date(1) }), false);
for (const activityType of ['a-changedTitle', 'addComment', 'a-isOvertime', undefined]) {
assert.equal(isTimingClear({ activityType }), false, String(activityType));
}
});
async function timingHook(card) {
const { timingChange } = await helper();
const src = read('server/models/cards.js');
const start = src.indexOf('Cards.before.update(async (userId, doc, fieldNames, modifier) => {\n // A date set carries timeValue');
const end = src.indexOf('// Issue #3619', start);
assert.ok(start > 0 && end > start, 'the timing hook is where this test looks for it');
const hooks = [], activities = [];
const context = {
Cards: { before: { update: fn => hooks.push(fn) } }, timingChange, deferSyncRecording, console,
ReactiveCache: { getCard: async () => card, getUser: async () => ({ username: 'writer' }) },
Lists: { direct: { updateAsync: async () => 1 } }, add: () => ({ toISOString: () => 'x' }), now: () => 0,
Activities: { insertAsync: async doc => activities.push(doc) },
};
vm.runInNewContext(src.slice(start, end), context);
return { activities, run: (doc, fieldNames, modifier) => hooks[0]('user', doc, fieldNames, modifier) };
}
test('the timing hook records a set, records a clear without throwing, and nothing for clearing nothing', async () => {
const card = { _id: 'card', listId: 'list', swimlaneId: 'lane', list: async () => ({ _id: 'list', boardId: 'board' }) };
const h = await timingHook(card);
const old = new Date('2026-01-01T00:00:00Z'), at = new Date('2099-12-31T17:00:00Z');
const doc = { _id: 'card', boardId: 'board', title: 'T', dueAt: old };
await h.run(doc, ['dueAt'], { $set: { dueAt: at } });
assert.equal(h.activities[0].activityType, 'a-dueAt');
assert.equal(h.activities[0].timeValue, at);
assert.equal(h.activities[0].timeOldValue, old);
// The bug: this threw "Cannot read properties of undefined".
await assert.doesNotReject(() => h.run(doc, ['dueAt'], { $unset: { dueAt: '' } }));
const cleared = h.activities[1];
assert.equal(cleared.activityType, 'a-dueAt');
assert.ok(!Object.hasOwn(cleared, 'timeValue'), 'a clear carries no new date');
assert.equal(cleared.timeOldValue, old);
// A stamped $set beside the $unset: still a clear, never timeValue: undefined.
await h.run(doc, ['dueAt', 'modifiedAt'], { $unset: { dueAt: '' }, $set: { modifiedAt: at } });
assert.ok(!Object.hasOwn(h.activities[2], 'timeValue'));
// Negative: clearing a date the card does not have records nothing.
await h.run({ ...doc, dueAt: undefined }, ['dueAt'], { $unset: { dueAt: '' } });
assert.equal(h.activities.length, 3);
});
test('a clear is recorded as a rule\'s removeDate records it', () => {
// server/lib/syncRuleCardCommand.js leaves timeValue out when the date is gone.
const rule = read('server/lib/syncRuleCardCommand.js');
assert.match(rule, /Object\.hasOwn\(after, field\) \? \{ timeValue: after\[field\] \} : \{\}/);
assert.match(read('server/models/cards.js'), /\.\.\.\(change\.set \? \{ timeValue: change\.value \} : \{\}\)/);
});
test('the rules skip a cleared date before matching any trigger', () => {
const src = read('server/rulesHelper.js');
const body = src.slice(src.indexOf('async findMatchingRules(activity) {'));
assert.ok(body.indexOf('if (isTimingClear(activity)) return [];') > 0);
assert.ok(body.indexOf('if (isTimingClear(activity)) return [];') < body.indexOf('TriggersDef[activityType]'),
'skipped before the triggers are matched');
});
test('the activity feed shows no empty date for a clear', () => {
const jade = read('client/components/activities/activities.jade');
assert.match(jade, /if currentData\.timeValue\n\s+i\(title=currentData\.timeValue\)/);
});
// Negative, tree-wide: no hook reads a computed key from a modifier's $set
// without knowing the $set is there - the shape of this bug.
test('nowhere: a modifier.$set[...] read that assumes the $set exists', () => {
const skip = new Set(['node_modules', '.meteor', '.build', '_build', '.tools', 'tests', 'packages', 'public']);
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (skip.has(e.name) || e.name.startsWith('_build')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (p.endsWith('.js')) {
read(path.relative(root, p)).split('\n').forEach((line, i) => {
// A read (right of =, or an argument), not the assignment of a modifier we built.
if (/[=(,]\s*modifier\.\$set\[/.test(line) && !/modifier\.\$set\s*&&|\?\.\[/.test(line)) {
offenders.push(`${path.relative(root, p)}:${i + 1}`);
}
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(path.join(root, dir));
assert.deepEqual(offenders, []);
});
+5 -1
View File
@@ -52,7 +52,11 @@ test('#4912: description changes log activityType a-changedDescription', () => {
});
test('#4912: due/start/end/received date changes log activityType a-${action}', () => {
assert.match(cardsSrc, /const timingaction = \[\s*'receivedAt',\s*'dueAt',\s*'startAt',\s*'endAt',?\s*\];/);
// The four dates moved from an inline list into server/lib/timingChangeActivity.js
// when the hook stopped reading a $set a cleared date does not have.
const timingSrc = fs.readFileSync(path.join(__dirname, '../server/lib/timingChangeActivity.js'), 'utf8');
assert.match(timingSrc, /export const TIMING_FIELDS = \[\s*'receivedAt',\s*'dueAt',\s*'startAt',\s*'endAt',?\s*\];/);
assert.match(cardsSrc, /const change = timingChange\(fieldNames, modifier\);/);
assert.match(cardsSrc, /const activityType = `a-\$\{action\}`;/);
});
+3 -2
View File
@@ -51,9 +51,10 @@ test('on macOS: the profile really refuses the home directory and allows the che
const profile = path.join(tmp, 'test.sb');
fs.writeFileSync(profile, `(version 1)\n(allow default)\n(deny file-read* file-write* (subpath "/Users") (subpath "/Volumes"))\n`
+ `(allow file-read* file-write* (subpath ${JSON.stringify(root)}))\n`);
// The paths are arguments, not script text (CodeQL #601's shape).
const r = spawnSync('sandbox-exec', ['-f', profile, '/bin/bash', '-c',
`ls ${JSON.stringify(home + '/Desktop')} >/dev/null 2>&1 && echo home=read || echo home=refused;`
+ `touch ${JSON.stringify(tmp + '/ok')} && echo checkout=written`], { encoding: 'utf8' });
'ls "$1" >/dev/null 2>&1 && echo home=read || echo home=refused; touch "$2" && echo checkout=written',
'_', home + '/Desktop', tmp + '/ok'], { encoding: 'utf8' });
assert.match(r.stdout, /home=refused/);
assert.match(r.stdout, /checkout=written/);
} finally {
+6 -5
View File
@@ -3,7 +3,8 @@
// Meteor 3.5.3's six security fixes, in WeKan's forks of Meteor's own
// packages in packages/ (a local package overrides the release's). The forks
// were made because Meteor 3.6-rc.0 lacked the fixes; 3.6-rc.1 carries them,
// and the forks are rebased onto it for what is WeKan's own: the Admin Panel /
// and the forks are rebased onto it - and onto Meteor 3.6, whose six packages
// differ from 3.6-rc.1's only in their version line - for what is WeKan's own: the Admin Panel /
// Problems reports and the crypto-RNG keys (tests/meteorForkRandomKeys.test.cjs).
// This suite keeps checking that the fixes are there, whichever side has them.
// Run: node tests/meteor353SecurityFixes.test.cjs
@@ -25,9 +26,9 @@ const { METEOR_SECURITY_KEYS, meteorSecurityEvent } = require('../models/lib/met
const { CATALOG } = require('../models/lib/securityCategories.js');
const { METEOR_PACKAGE_FORKS: FORKS } = require('./lib/meteorPackageForks.cjs');
const BASE_RELEASE = 'METEOR@3.6-rc.1';
const BASE_RELEASE = 'METEOR@3.6';
// The wrap number a fork adds to the release's own version of the package.
const FORK_VERSION = /-rc360\.1_1$/;
const FORK_VERSION = /^\d+\.\d+\.\d+_1$/;
let passed = 0;
let failed = 0;
@@ -70,7 +71,7 @@ const SOURCES = ['packages', 'server', 'models', 'imports', 'client']
test(`the forks are for ${BASE_RELEASE}, and none outlives it`, () => {
// A local package overrides the release's own SILENTLY. Moved to another
// Meteor release, these would keep 3.6-rc.1's code under the new release's
// Meteor release, these would keep 3.6's code under the new release's
// name. So this fails the moment .meteor/release changes: check whether the
// new release has the 3.5.3 fixes (the GitHub release notes list them) and
// delete packages/<name> for each that does, or rebase the fork onto it.
@@ -83,7 +84,7 @@ test('every fork is marked as one, and .meteor/versions resolves to it', () => {
const versions = read('.meteor/versions');
for (const name of FORKS) {
const version = read(`packages/${name}/package.js`).match(/version:\s*'([^']+)'/)[1];
assert.match(version, FORK_VERSION, `${name}'s fork keeps 3.6-rc.1's version with a _1 wrap number: ${version}`);
assert.match(version, FORK_VERSION, `${name}'s fork keeps 3.6's version with a _1 wrap number: ${version}`);
assert.ok(versions.split('\n').includes(`${name}@${version}`),
`.meteor/versions must resolve ${name} to the fork ${version}`);
}
+51
View File
@@ -181,6 +181,57 @@ test('every npm install in a script a workflow runs goes through it too', () =>
'these npm commands, run by a workflow, can lose a release to a five-minute outage');
});
test('npm-retry.sh is given a program, never a shell function of the caller', () => {
// npm-retry.sh is a new bash with set -u. A function of the script that
// calls it reaches it only through export -f, and the variables that
// function reads do not: v13.04's Android bundles all died with
// "environment: node: unbound variable" when build-bundle.sh handed it its
// npm() { "$node" "$npm_cli" ...; }. So, for every releases/ script, no
// function it defines is the command it gives npm-retry.sh.
const dir = path.join(repoRoot, 'releases');
const scripts = [];
const walk = d => fs.readdirSync(d, { withFileTypes: true }).forEach(e => {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p); else if (/\.sh$/.test(e.name)) scripts.push(p);
});
walk(dir);
const bad = [];
for (const file of scripts) {
const text = fs.readFileSync(file, 'utf8');
const functions = [...text.matchAll(/^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\)\s*\{/gm)].map(m => m[1]);
text.split('\n').forEach((line, i) => {
if (/^\s*#/.test(line)) return;
const m = /npm-retry\.sh"?\s+"?([^\s"]+)/.exec(line);
if (m && functions.includes(m[1])) bad.push(`${path.relative(repoRoot, file)}:${i + 1}: ${line.trim()}`);
});
}
assert.deepStrictEqual(bad, [], 'npm-retry.sh runs these as a program in a new bash, where the function has no variables');
// The Android installs still go through it, as the program they are.
const android = fs.readFileSync(path.join(dir, 'android/build-bundle.sh'), 'utf8');
assert.strictEqual((android.match(/npm-retry\.sh" "\$node" "\$npm_cli" install/g) || []).length, 2,
'both Android npm installs are retried, given Android\'s node and npm-cli.js');
});
test('a caller\'s function in npm-retry.sh fails as it did, and its program works', () => {
// The failure itself, so the rule above has a reason a test shows: a
// function exported with export -f reaches npm-retry.sh, its variable does not.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npmretry-'));
const prog = path.join(dir, 'fake-npm');
fs.writeFileSync(prog, '#!/bin/sh\necho "installed $*"\n', { mode: 0o755 });
// The paths go in through the environment, never into the script text
// (CodeQL alert #601, js/shell-command-injection-from-environment): a
// temporary directory with a quote, `$(` or a backtick would change it.
// NODE_BIN is not node_bin, so npm-retry.sh's bash still lacks the variable.
const run = body => spawnSync('bash', ['-c', `set -u; node_bin="$NODE_BIN"; npm() { "$node_bin" "$@"; }; export -f npm; ${body}`],
{ encoding: 'utf8', env: { ...process.env, NPM_RETRY_SLEEPS: '0', NODE_BIN: prog, NPM_RETRY_SCRIPT: script } });
const viaFunction = run('bash "$NPM_RETRY_SCRIPT" npm install');
assert.notStrictEqual(viaFunction.status, 0, 'the function, without its variable, fails');
assert.match(viaFunction.stdout + viaFunction.stderr, /node_bin: unbound variable/);
const viaProgram = run('bash "$NPM_RETRY_SCRIPT" "$node_bin" install');
assert.strictEqual(viaProgram.status, 0, viaProgram.stdout + viaProgram.stderr);
assert.match(viaProgram.stdout, /installed install/);
});
test('the containers can reach the script that retries for them', () => {
// Two of these installs run inside `docker run`, where the repository is not
// present unless it is mounted. A call to /releases/npm-retry.sh in a
@@ -0,0 +1,92 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
const CardPage = require('../pages/CardPage');
const headers = token => ({ Authorization: `Bearer ${token}` });
const cardPath = card => `/api/boards/${card.boardId}/lists/${card.listId}/cards/${card._id}`;
for (const [field, idKey, join, unjoin, avatar] of [
['members', 'memberId', 'joinMember', 'unjoinMember', '.card-details-item-members .js-member'],
['assignees', 'assigneeId', 'joinAssignee', 'unjoinAssignee', '.card-details-item-assignees .js-assignee'],
]) {
test(`${field}: removing an unassigned or nonexistent user succeeds without membership activity`, async ({ request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
const activities = { cardId: card._id, activityType: { $in: [join, unjoin] } };
for (const target of [user.id, db.uid('missing')]) {
for (let attempt = 0; attempt < 2; attempt++) {
const response = await request.delete(`${cardPath(card)}/${field}/${target}`, { headers: headers(user.token) });
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ _id: card._id, members: [], assignees: [] });
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([]);
expect(db.countDocuments('activities', activities)).toBe(0);
}
}
});
test(`${field}: removing a deleted user's stale assignment persists and records its ID`, async ({ request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
const deleted = db.uid('deleted');
expect(db.findOne('users', { _id: deleted })).toBeNull();
// Old cards may retain assignments after their user account is gone.
db.updateOne('cards', { _id: card._id }, { $set: { [field]: [deleted, user.id] } });
const response = await request.delete(`${cardPath(card)}/${field}/${deleted}`, { headers: headers(user.token) });
expect(response.status()).toBe(200);
expect((await response.json())[field]).toEqual([user.id]);
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([user.id]);
const read = await request.get(cardPath(card), { headers: headers(user.token) });
expect(read.status()).toBe(200);
expect((await read.json())[field]).toEqual([user.id]);
const activity = { cardId: card._id, activityType: unjoin, [idKey]: deleted };
expect(db.findOne('activities', activity)).toMatchObject({ boardId: card.boardId, listId: card.listId });
const repeat = await request.delete(`${cardPath(card)}/${field}/${deleted}`, { headers: headers(user.token) });
expect(repeat.status()).toBe(200);
expect(db.countDocuments('activities', activity)).toBe(1);
});
test(`${field}: normal assignment and removal update card details and retain activity usernames`, async ({ boardPage: page, request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
await new BoardPage(page).clickCard(card.listId, card.title);
const details = new CardPage(page);
await details.waitForOpen();
const avatars = details.root.locator(avatar);
await expect(avatars).toHaveCount(0);
const endpoint = `${cardPath(card)}/${field}/${user.id}`;
const add = await request.post(endpoint, { headers: headers(user.token) });
expect(add.status()).toBe(200);
await expect(avatars).toHaveCount(1);
await expect(avatars).toHaveAttribute('title', new RegExp(user.username));
const remove = await request.delete(endpoint, { headers: headers(user.token) });
expect(remove.status()).toBe(200);
expect((await remove.json())[field]).toEqual([]);
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([]);
await expect(avatars).toHaveCount(0);
for (const activityType of [join, unjoin]) {
expect(db.findOne('activities', { cardId: card._id, activityType, [idKey]: user.id }))
.toMatchObject({ username: user.username, boardId: card.boardId });
}
});
}
test('member removal retains authentication, board access, card scope and assignment guards', async ({ request, user, user2, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('cards', { _id: card._id }, { $set: { members: [user.id], assignees: [user.id] } });
for (const field of ['members', 'assignees']) {
for (const auth of [{}, headers(user2.token)]) {
const response = await request.delete(`${cardPath(card)}/${field}/${user.id}`, { headers: auth });
expect([401, 403]).toContain(response.status());
}
for (const target of [{ ...card, _id: db.uid('missing') }, { ...card, listId: board.listIds[1] }]) {
const response = await request.delete(`${cardPath(target)}/${field}/${user.id}`, { headers: headers(user.token) });
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({ error: 'Card not found' });
}
const add = await request.post(`${cardPath(card)}/${field}/${db.uid('missing')}`, { headers: headers(user.token) });
expect(add.status()).toBe(400);
expect(await add.json()).toEqual({ error: 'userId is not an active member of this board' });
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([user.id]);
}
expect(db.countDocuments('activities', { cardId: card._id, activityType: { $in: ['joinMember', 'unjoinMember', 'joinAssignee', 'unjoinAssignee'] } })).toBe(0);
});
@@ -0,0 +1,88 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
const BoardPage = require('../pages/BoardPage');
const CardPage = require('../pages/CardPage');
const headers = token => ({ Authorization: `Bearer ${token}` });
const cardPath = card => `/api/boards/${card.boardId}/lists/${card.listId}/cards/${card._id}`;
test('REST overtime persists and updates the open card for JSON and form edits', async ({ boardPage: page, request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsSpentTime: true } });
db.updateOne('cards', { _id: card._id }, { $set: { spentTime: 2, isOvertime: false } });
const bp = new BoardPage(page);
const cp = new CardPage(page);
await bp.clickCard(card.listId, card.title);
await cp.waitForOpen();
const heading = cp.root.locator('.card-details-item-spent .card-details-item-title');
await expect(heading).toContainText('Spent time');
for (const format of ['json', 'form']) {
for (const isOverTime of [true, false]) {
const response = await request.put(cardPath(card), {
headers: headers(user.token),
...(format === 'json' ? { data: { isOverTime } } : { form: { isOverTime: String(isOverTime) } }),
});
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ _id: card._id });
const stored = db.findOne('cards', { _id: card._id });
expect(stored.isOvertime).toBe(isOverTime);
expect(stored).not.toHaveProperty('isOverTime');
expect(stored.spentTime).toBe(2);
const read = await request.get(cardPath(card), { headers: headers(user.token) });
expect(read.status()).toBe(200);
expect((await read.json()).isOvertime).toBe(isOverTime);
await expect(heading).toContainText(isOverTime ? 'Overtime' : 'Spent time');
}
}
// Omitting the flag during another edit must preserve the saved value.
const enable = await request.put(cardPath(card), { headers: headers(user.token), data: { isOverTime: true } });
expect(enable.status()).toBe(200);
const edit = await request.put(cardPath(card), { headers: headers(user.token), data: { description: 'Keep overtime' } });
expect(edit.status()).toBe(200);
expect(db.findOne('cards', { _id: card._id }).isOvertime).toBe(true);
await page.reload();
// Token-based fixture sessions may be memory-only. Authenticate the new
// page before reopening the private board to check the persisted display.
await loginWithToken(page, user.id, user.token);
await openBoard(page, board.boardId, board.slug);
await bp.clickCard(card.listId, card.title);
await cp.waitForOpen();
await expect(heading).toContainText('Overtime');
});
test('REST overtime rejects invalid, unauthenticated, unauthorized and incorrectly scoped edits without writes', async ({ request, user, user2, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('cards', { _id: card._id }, { $set: { isOvertime: true } });
const before = db.findOne('cards', { _id: card._id });
for (const isOverTime of [null, 0, 'yes', {}, []]) {
const response = await request.put(cardPath(card), { headers: headers(user.token), data: { isOverTime, title: 'Renamed' } });
expect(response.status()).toBe(400);
expect(await response.json()).toEqual({ error: 'Invalid isOverTime' });
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
}
for (const auth of [{}, headers(user2.token)]) {
const response = await request.put(cardPath(card), { headers: auth, data: { isOverTime: false } });
expect([401, 403]).toContain(response.status());
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
}
const otherBoard = db.seedBoard({ ownerId: user.id });
try {
for (const target of [
{ ...card, _id: db.uid('missing') },
{ ...card, listId: board.listIds[1] },
{ ...card, boardId: otherBoard.boardId },
]) {
const response = await request.put(cardPath(target), { headers: headers(user.token), data: { isOverTime: false } });
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({ error: 'Card not found' });
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
}
} finally {
db.cleanup({ boardIds: [otherBoard.boardId] });
}
});
+3 -2
View File
@@ -92,7 +92,8 @@ test('a download is readable by everyone, as an AppImage needs (v12.69)', () =>
// after `chmod +x`, and appimagetool refused to pack them.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-'));
const out = path.join(dir, 'ferretdb');
const r = spawnSync('bash', ['-c', `umask 022; bash "${fetchSh}" -o "${out}" "${base}/ok" && chmod +x "${out}"`],
// Arguments, not script text: the paths cannot change the command (CodeQL #601's shape).
const r = spawnSync('bash', ['-c', 'umask 022; bash "$1" -o "$2" "$3" && chmod +x "$2"', '_', fetchSh, out, `${base}/ok`],
{ encoding: 'utf8', env: Object.assign({}, process.env, { FETCH_SLEEPS: '0' }) });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual((fs.statSync(out).mode & 0o777).toString(8), '755');
@@ -101,7 +102,7 @@ test('a download is readable by everyone, as an AppImage needs (v12.69)', () =>
test('a download still follows a stricter umask (negative)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-'));
const out = path.join(dir, 'private');
const r = spawnSync('bash', ['-c', `umask 077; bash "${fetchSh}" -o "${out}" "${base}/ok"`],
const r = spawnSync('bash', ['-c', 'umask 077; bash "$1" -o "$2" "$3"', '_', fetchSh, out, `${base}/ok`],
{ encoding: 'utf8', env: Object.assign({}, process.env, { FETCH_SLEEPS: '0' }) });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual((fs.statSync(out).mode & 0o777).toString(8), '600');
+8 -3
View File
@@ -77,17 +77,22 @@ for (const sort of [null, '', 'abc', true, {}, [], 'Infinity', Number.NaN]) {
}
for (const [isOverTime, written] of [[false, false], ['false', false], [true, true], ['true', true]]) {
test(`card PUT writes isOverTime ${JSON.stringify(isOverTime)}`, async () => {
test(`card PUT stores isOverTime ${JSON.stringify(isOverTime)} in the schema's isOvertime field`, async () => {
const f = await fixture();
assert.equal((await f.put({ isOverTime })).code, 200);
assert.deepEqual(f.writes.map(w => w.modifier), [{ $set: { isOverTime: written } }]);
// The API spelling differs from the schema and UI. Checking only the
// request spelling allowed successful responses with no persisted change.
assert.deepEqual(f.writes, [{
selector: { _id: 'card', listId: 'list', boardId: 'board', archived: false },
modifier: { $set: { isOvertime: written } },
}]);
});
}
for (const isOverTime of [null, 0, 'yes', {}]) {
test(`card PUT refuses isOverTime ${JSON.stringify(isOverTime)} with 400`, async () => {
const f = await fixture();
assert.deepEqual(await f.put({ isOverTime }), { code: 400, data: { error: 'Invalid isOverTime' } });
assert.deepEqual(await f.put({ isOverTime, title: 'Renamed' }), { code: 400, data: { error: 'Invalid isOverTime' } });
assert.deepEqual(f.writes, []);
});
}
+9 -6
View File
@@ -57,15 +57,18 @@ test('negative: the 4 date triggers are distinct activityType keys, none shared'
// reuses actually exists and is not something this change invents.
test('the Cards.before.update timing-field hook logs a-receivedAt/a-startAt/a-dueAt/a-endAt', () => {
const block = serverCardsSrc.slice(
serverCardsSrc.indexOf("const timingaction = ['receivedAt', 'dueAt', 'startAt', 'endAt'];"),
);
const block = serverCardsSrc.slice(serverCardsSrc.indexOf('const change = timingChange(fieldNames, modifier);'));
const body = block.slice(0, block.indexOf('\n});') + 4);
assert.ok(/activityType = `a-\$\{action\}`;/.test(body));
assert.ok(/await Activities\.insertAsync\(/.test(body));
// Reads modifier.$set - only fires for a real SET, not for an $unset clear
// (unsetDue/unsetStart/unsetEnd/unsetReceived), matching "set or changed".
assert.ok(/modifier\.\$set\[action\]/.test(body));
// It read modifier.$set[action] to "only fire for a real SET", but a cleared
// date ($unset) went through it too - throwing, or writing timeValue:
// undefined. Now a set carries timeValue and a clear only timeOldValue
// (tests/cardTimingActivity.test.cjs), and "set or changed" still means a
// real value: the rules skip a clear before matching any trigger.
assert.ok(/\.\.\.\(change\.set \? \{ timeValue: change\.value \} : \{\}\)/.test(body));
assert.ok(!/modifier\.\$set\[action\]/.test(body), 'negative: no read of a $set that may not be there');
assert.ok(/if \(isTimingClear\(activity\)\) return \[\];/.test(fs.readFileSync(path.join(__dirname, '../server/rulesHelper.js'), 'utf8')));
});
// --- server/rulesHelper.js: the matcher works generically off TriggersDef --
+47
View File
@@ -956,6 +956,53 @@ await test('nonfinite-sort-repair: repairs every sorted collection (lists/swimla
assert.strictEqual(db._dump('checklistItems')[1].sort, 2);
});
// ── card-overtime-field (#6888) ──────────────────────────────────────────────
const overtimeStep = steps.find(s => s.name === 'card-overtime-field');
await test('card-overtime-field: a REST isOverTime becomes isOvertime, the shown one wins, the stray field goes', async () => {
const db = liveSortDb({ cards: [
{ _id: 'on', isOverTime: true },
{ _id: 'off', isOverTime: false },
{ _id: 'both', isOverTime: true, isOvertime: false },
{ _id: 'ui', isOvertime: true },
{ _id: 'plain' },
] });
assert.strictEqual(await overtimeStep.check(db), true);
const r = await overtimeStep.run(db);
assert.strictEqual(r.fixed, 2);
const byId = Object.fromEntries(db._dump('cards').map(c => [c._id, c]));
assert.deepStrictEqual(byId.on, { _id: 'on', isOvertime: true });
assert.deepStrictEqual(byId.off, { _id: 'off', isOvertime: false });
assert.deepStrictEqual(byId.both, { _id: 'both', isOvertime: false }, 'the value the UI showed is kept');
assert.deepStrictEqual(byId.ui, { _id: 'ui', isOvertime: true });
assert.deepStrictEqual(byId.plain, { _id: 'plain' });
assert.strictEqual(await overtimeStep.check(db), false, 'idempotent: nothing left after one pass');
});
await test('negative: card-overtime-field does not run on cards without the stray field', async () => {
const db = liveSortDb({ cards: [{ _id: 'ui', isOvertime: true }, { _id: 'plain' }] });
assert.strictEqual(await overtimeStep.check(db), false);
});
// Negative, tree-wide: nothing writes the request spelling into a card again.
await test('negative: no server code writes isOverTime into a card document', () => {
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
if (['node_modules', '.meteor', 'tests', 'packages'].includes(e.name) || e.name.startsWith('_build') || e.name.startsWith('.')) continue;
const rel = path.join(dir, e.name);
if (e.isDirectory()) walk(rel);
else if (rel.endsWith('.js')) {
read(rel).split('\n').forEach((line, i) => {
if (/\$(set|setOnInsert)\s*:\s*\{[^}]*\bisOverTime\s*:/.test(line)) offenders.push(`${rel}:${i + 1}`);
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(dir);
assert.deepStrictEqual(offenders, []);
});
await test('negative: nonfinite-sort-repair does not run on healthy finite sorts', async () => {
const db = liveSortDb({ cards: [{ _id: 'c1', sort: 0 }, { _id: 'c2', sort: 1e300 }, { _id: 'c3', sort: -1e300 }] });
assert.strictEqual(await sortStep.check(db), false);
+23
View File
@@ -297,6 +297,29 @@ async function main() {
assert.equal(empty.data.project.entities['wekan-project-1'].title, 'Empty');
});
// A card whose list is archived is collected with listTitle '': the export
// threw "Cannot read properties of undefined (reading 'taskIds')" on its
// missing tag. It is the "Untitled" list now, its tag and its panel alike.
test('a card with no list title exports into the Untitled list', () => {
const backup = JSON.parse(formatSuperProductivity({ board: { title: 'B' }, lists: [{ title: 'Doing' }], swimlanes: [],
items: [{ cardId: 'a', title: 'Orphan', listTitle: '', labels: [] }, { cardId: 'b', title: 'Kept', listTitle: 'Doing', labels: [] }] }, { now: 1 }));
assertSuperProductivityAccepts(backup);
const orphan = backup.data.task.entities.a;
const untitled = Object.values(backup.data.tag.entities).find(t => t.title === 'Untitled');
assert.ok(untitled, 'the Untitled list has its tag');
assert.deepEqual(orphan.tagIds, [untitled.id]);
assert.deepEqual(untitled.taskIds, ['a']);
const panel = backup.data.boards.boardCfgs[0].panels.find(p => p.title === 'Untitled');
assert.deepEqual(panel.includedTagIds, [untitled.id], 'its panel filters by that tag');
// Negative: no task names a tag that does not exist, and no panel a null tag.
for (const task of Object.values(backup.data.task.entities)) {
for (const id of task.tagIds) assert.ok(backup.data.tag.entities[id], `${task.id} names ${id}`);
}
for (const p of backup.data.boards.boardCfgs[0].panels) {
assert.ok(!p.includedTagIds.includes(null) && !p.excludedTagIds.includes(null), `${p.title} names no null tag`);
}
});
test('the format is wired into import, export, the import page and the export menu', () => {
assert.equal(EXTERNAL_PARSERS.superproductivity, parseSuperProductivity);
assert.equal(typeof formatters.superproductivity, 'function');
+31 -6
View File
@@ -1,9 +1,11 @@
'use strict';
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550): tests
// ran `bash -c` with a script that interpolated an absolute path built from
// __dirname, e.g. `source "${helper}"`. A checkout path with a quote, `$(` or a
// backtick would change the command. The path goes in through the environment
// instead (`source "$HELPER"`), so the script text is constant.
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550, #601):
// tests ran `bash -c` with a script that interpolated an absolute path built
// from __dirname or the temporary directory, e.g. `source "${helper}"`,
// `node_bin=${JSON.stringify(prog)}` or `bash "${fetchSh}" -o "${out}"`. A path
// with a quote, `$(` or a backtick would change the command - JSON's quotes are
// not the shell's. The path goes in through the environment (`"$HELPER"`) or
// as an argument (`"$1"`), so the script text is constant.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
@@ -26,4 +28,27 @@ assert.ok(SHAPE.test('`\n source "${configPath}"\n`'));
assert.ok(!SHAPE.test('[\'-c\', \'source "$SOCKET_DENIED_HELPER"; socket_denied\']'));
assert.ok(!SHAPE.test('`${name}. ${detail}`'));
console.log('testShellPathsFromEnv: no test sources an interpolated path');
// #601: any value quoted into a `-c` script - "${...}" or ${JSON.stringify(...)}
// - is data pasted into code. A script built from the test's own snippets
// (${functions}, ${body}) is code, and stays allowed.
const DASH_C = /['"]-c['"],\s*`([^`]*)`/g;
const QUOTED_VALUE = /"\$\{|\$\{JSON\.stringify\(/;
function quotedValues(src) {
const found = [];
for (const m of src.matchAll(DASH_C)) if (QUOTED_VALUE.test(m[1])) found.push(m[1].slice(0, 80));
return found;
}
const quoted = fs.readdirSync(path.join(root, 'tests'))
.filter(name => /\.(c?js|mjs)$/.test(name) && name !== path.basename(__filename))
.flatMap(name => quotedValues(fs.readFileSync(path.join(root, 'tests', name), 'utf8')).map(s => `${name}: ${s}`))
.sort();
assert.deepEqual(quoted, [], 'pass the value as an argument ("$1") or in the environment ("$NAME")');
// Negative: the guard catches #601's shapes...
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `set -u; node_bin=${JSON.stringify(prog)}; npm`])").length, 1);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `umask 022; bash \"${fetchSh}\" -o \"${out}\"`])").length, 1);
// ...and not the fixed forms, nor a script built from the test's own code.
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', 'bash \"$1\" -o \"$2\"', '_', fetchSh, out])").length, 0);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `set -u; node_bin=\"$NODE_BIN\"; ${body}`])").length, 0);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `${functions}\ntool_repo_dir ${args}`])").length, 0);
console.log('testShellPathsFromEnv: no test sources an interpolated path, nor quotes a value into a -c script');