mirror of
https://github.com/wekan/wekan.git
synced 2026-11-11 02:54:03 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77239ee100 | ||
|
|
3913fbd507 | ||
|
|
c45359a759 | ||
|
|
d482856e03 | ||
|
|
72ee10a3ed | ||
|
|
32590ee7ab | ||
|
|
569756b66c | ||
|
|
50334816ca | ||
|
|
9821478329 | ||
|
|
ad5c35e9f3 | ||
|
|
416c69dfe2 | ||
|
|
0acc9955d0 | ||
|
|
5e9d1cb630 | ||
|
|
4f6832845d | ||
|
|
1ca199b607 | ||
|
|
63c6f42495 | ||
|
|
0fbef8936f | ||
|
|
0905de097b | ||
|
|
679ea9e63a | ||
|
|
d08f658dea | ||
|
|
dcd9fec925 | ||
|
|
8b4fe0e737 | ||
|
|
bc698806b2 | ||
|
|
0556f01030 | ||
|
|
b379a02631 | ||
|
|
c3f12b5659 | ||
|
|
ea53de4985 | ||
|
|
214b20e7ad | ||
|
|
f74ef4c40d |
No files matched your search
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
METEOR@3.6-rc.1
|
||||
METEOR@3.6
|
||||
+74
-74
@@ -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
|
||||
|
||||
+209
-432
@@ -124,6 +124,215 @@ 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
|
||||
access, that refusal now also holds for the checklist and checklist-item REST
|
||||
routes. The **Sandstorm** and **Android** bundles retry their npm install when
|
||||
github.com is briefly unavailable.
|
||||
|
||||
This release fixes the following CRITICAL SECURITY ISSUE of [ListLockBleed](https://wekan.fi/hall-of-fame/listlockbleed/):
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/214b20e7ad">A list that refuses a role write access refuses its checklist REST writes too</a>. Thanks to tsurutanmen and xet7.</summary>
|
||||
|
||||
[ListLockBleed](https://wekan.fi/hall-of-fame/listlockbleed/),
|
||||
[GHSA-4734-8vrg-8j3w](https://github.com/wekan/wekan/security/advisories/GHSA-4734-8vrg-8j3w).
|
||||
A board admin can refuse a role write access in one list (#4527). The card
|
||||
and comment REST routes and the DDP checklist writes applied that refusal,
|
||||
but the six checklist and checklist-item REST routes asked only the board. A
|
||||
member the list refused could therefore still create, rename, tick and delete
|
||||
the checklists of its cards with an API token. Those routes now ask the
|
||||
card's list and answer 403, and the refusal shows in Admin Panel / Problems.
|
||||
`tests/listPermissions.test.cjs` fails for any REST write route of the tree
|
||||
that names a card without asking its list. And
|
||||
`tests/playwright/specs/list-lock-checklists.e2e.js` replays the six writes
|
||||
against a running server.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
**Release builds** - what the release workflow does when a download fails.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/c3f12b5659">The Sandstorm and Android bundles retry their npm install through a GitHub outage</a>. Thanks to xet7.</summary>
|
||||
|
||||
v13.03's Sandstorm .spk failed with "npm error 504 Gateway Time-out" for
|
||||
github.com's `meteor/node-source-map-support` tarball: the same kind of
|
||||
outage `releases/npm-retry.sh` has retried since v10.86. That install in
|
||||
`releases/sandstorm-slim-spk.sh`, and the Android bundle's server and sharp
|
||||
installs, ran npm without it, because `tests/npmRetry.test.cjs` read only
|
||||
`release-all.yml`. All three go through the retry now. The test follows
|
||||
every `releases/` script a workflow runs and fails on a bare npm install in
|
||||
any of them.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v13.03 2026-11-09 WeKan ® release
|
||||
|
||||
**In short:** The amd64 **snaps** no longer fail the release when the Ubuntu
|
||||
@@ -8250,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.
|
||||
+29
-28
@@ -5,6 +5,35 @@ 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
|
||||
access, that refusal now also holds for the checklist and checklist-item REST
|
||||
routes. The **Sandstorm** and **Android** bundles retry their npm install when
|
||||
github.com is briefly unavailable.
|
||||
|
||||
[All changes in v13.04](CHANGELOG-big.md#v1304-2026-11-09-wekan--release)
|
||||
|
||||
# v13.03 2026-11-09 WeKan ® release
|
||||
|
||||
The amd64 **snaps** no longer fail the release when the Ubuntu
|
||||
@@ -19,31 +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)
|
||||
|
||||
# v12.99 2026-11-06 WeKan ® release
|
||||
|
||||
The **mac-arm64** release bundle builds again: its job asked for
|
||||
GitHub's retired **macos-14** runner and was cancelled in the v12.98 release
|
||||
run. The **weba** instructions now live in the weba repository itself.
|
||||
|
||||
[All changes in v12.99](CHANGELOG-big.md#v1299-2026-11-06-wekan--release)
|
||||
+2
-2
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=13.03
|
||||
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
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v13.03.0"
|
||||
appVersion: "v13.06.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -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.03-win64.zip](https://github.com/wekan/wekan/releases/download/v13.03/wekan-13.03-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.03-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
@@ -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({
|
||||
|
||||
@@ -235,6 +235,10 @@ const CATALOG = {
|
||||
// GHSA-6xqf-jh8x-4wxc: an assigned-only member archiving or converting a card
|
||||
// not assigned to them through archiveSelectedCards / createBoardFromCard.
|
||||
'authz.assigned-card-write': { category: 'authz', bleed: 'UnassignedArchiveBleed', severity: 'medium', cwe: 'CWE-863' },
|
||||
// GHSA-4734-8vrg-8j3w: a REST write in a list whose exception refuses the
|
||||
// caller's role (#4527). The web UI offers no such write, so it is the lock
|
||||
// being stepped round with a token. Medium: recorded, never disables.
|
||||
'authz.list-locked': { category: 'authz', bleed: 'ListLockBleed', severity: 'medium', cwe: 'CWE-863' },
|
||||
'authz.tenant': { category: 'authz', bleed: 'TenantBleed', severity: 'medium', cwe: 'CWE-862' },
|
||||
'authz.search-session': { category: 'authz', bleed: 'SessionBleed', severity: 'medium', cwe: 'CWE-639' },
|
||||
'authz.database': { category: 'authz', bleed: 'DatabaseBleed', severity: 'high', cwe: 'CWE-863' },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v13.03.0",
|
||||
"version": "v13.06.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v13.03.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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v13.03.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,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.
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
Package.describe({
|
||||
summary: "Google OAuth flow",
|
||||
version: '1.5.1-rc360.1_1',
|
||||
version: '1.5.1_1',
|
||||
});
|
||||
|
||||
Cordova.depends({
|
||||
|
||||
@@ -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,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v13.03</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.03","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,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: WeKan REST API
|
||||
version: v13.03
|
||||
version: v13.06
|
||||
description: |
|
||||
The REST API allows you to control and extend WeKan with ease.
|
||||
|
||||
|
||||
@@ -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" && 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" && 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"
|
||||
|
||||
@@ -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.
@@ -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"
|
||||
@@ -67,7 +67,9 @@ echo "==> [2/5] meteor build (Sandstorm profile)"
|
||||
rm -rf .meteor-spk/bundle
|
||||
mkdir -p .meteor-spk
|
||||
"$METEOR" build --directory .meteor-spk
|
||||
(cd .meteor-spk/bundle/programs/server && "$METEOR" npm install)
|
||||
# Through npm-retry.sh, as every other bundle's install: v13.03's .spk died
|
||||
# here on a 504 from github.com for meteor/node-source-map-support's tarball.
|
||||
(cd .meteor-spk/bundle/programs/server && bash "$REPO/releases/npm-retry.sh" "$METEOR" npm install)
|
||||
|
||||
echo "==> [3/5] trim the bundle to a linux/x64 sockjs grain"
|
||||
# A grain is linux/x64, always, and talks DDP over sockjs (sandstorm-pkgdef.capnp
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 1303,
|
||||
appVersion = 1306,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "13.03~2026-11-09"),
|
||||
appMarketingVersion = (defaultText = "13.06~2026-11-10"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -33,9 +33,19 @@ export async function canCommentInList(userId, board, listId) {
|
||||
* The REST API's half: a list that refuses this user's role refuses the API
|
||||
* too (403), so a restriction cannot be stepped round with a token. A list's
|
||||
* GRANT does not open the API to a role the board does not let write.
|
||||
*
|
||||
* ListLockBleed (GHSA-4734-8vrg-8j3w): the checklist and checklist-item routes
|
||||
* did not ask, so a member a list refused still wrote its cards' checklists
|
||||
* over REST. Every REST write that names a card asks now
|
||||
* (tests/listPermissions.test.cjs), and the refusal is recorded in Admin
|
||||
* Panel -> Problems: no screen offers such a write, so it is an attempt.
|
||||
*/
|
||||
export async function assertListNotRefused(userId, board, listId, capability = 'write') {
|
||||
export async function assertListNotRefused(userId, board, listId, capability = 'write', source = 'rest:list-locked') {
|
||||
if ((await listException(userId, board, listId, capability)) !== false) return;
|
||||
try {
|
||||
require('/server/lib/securityLog').record({ key: 'authz.list-locked', action: 'blocked', source, userId,
|
||||
detail: `A REST ${capability} in a list whose exception refuses the caller's role.` });
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
const { Meteor } = require('meteor/meteor');
|
||||
const error = new Meteor.Error('Forbidden', 'Forbidden');
|
||||
error.statusCode = 403;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,6 +3,7 @@ import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { Authentication } from '/server/authentication';
|
||||
import { sendJsonResult } from '/server/apiMiddleware';
|
||||
import { assertListNotRefused } from '/server/lib/listPermissionGate';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import ChecklistItems, {
|
||||
itemCreation,
|
||||
@@ -145,6 +146,8 @@ WebApp.handlers.post(
|
||||
if (checklist) {
|
||||
const card = await ReactiveCache.getCard(paramCardId);
|
||||
if (card && card.boardId === paramBoardId) {
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the list's refusal holds over REST too (#4527).
|
||||
await assertListNotRefused(req.userId, await ReactiveCache.getBoard(paramBoardId), card.listId, 'write', 'rest:checklist-item');
|
||||
// Append, like the UI does. Every item created over REST used to be given
|
||||
// `sort: 0`, so a checklist filled through the API came out in an order
|
||||
// nobody chose - and every item after the first shared the first one's
|
||||
@@ -214,6 +217,8 @@ WebApp.handlers.put(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the list's refusal holds over REST too (#4527).
|
||||
await assertListNotRefused(req.userId, await ReactiveCache.getBoard(paramBoardId), card.listId, 'write', 'rest:checklist-item');
|
||||
|
||||
const deadline = parseDueAt(req.body);
|
||||
if (deadline.error) {
|
||||
@@ -277,6 +282,8 @@ WebApp.handlers.delete(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the list's refusal holds over REST too (#4527).
|
||||
await assertListNotRefused(req.userId, await ReactiveCache.getBoard(paramBoardId), card.listId, 'write', 'rest:checklist-item');
|
||||
|
||||
await ChecklistItems.direct.removeAsync({ _id: paramItemId });
|
||||
sendJsonResult(res, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseDueAt, checklistWithItems } from '/server/lib/checklistDeadlines';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { assertListNotRefused } from '/server/lib/listPermissionGate';
|
||||
import { Authentication } from '/server/authentication';
|
||||
import { sendJsonResult } from '/server/apiMiddleware';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
@@ -266,6 +267,8 @@ WebApp.handlers.post(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the list's refusal holds over REST too (#4527).
|
||||
await assertListNotRefused(req.userId, await ReactiveCache.getBoard(paramBoardId), card.listId, 'write', 'rest:checklist');
|
||||
|
||||
const deadline = parseDueAt(req.body);
|
||||
if (deadline.error) {
|
||||
@@ -335,6 +338,8 @@ WebApp.handlers.put(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the list's refusal holds over REST too (#4527).
|
||||
await assertListNotRefused(req.userId, await ReactiveCache.getBoard(paramBoardId), card.listId, 'write', 'rest:checklist');
|
||||
|
||||
const checklist = await ReactiveCache.getChecklist({
|
||||
_id: paramChecklistId,
|
||||
@@ -390,6 +395,8 @@ WebApp.handlers.delete(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the list's refusal holds over REST too (#4527).
|
||||
await assertListNotRefused(req.userId, await ReactiveCache.getBoard(paramBoardId), card.listId, 'write', 'rest:checklist');
|
||||
|
||||
const checklist = await ReactiveCache.getChecklist({
|
||||
_id: paramChecklistId,
|
||||
|
||||
@@ -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
@@ -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.03'
|
||||
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.03-${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.03/wekan-13.03-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.03/${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}"
|
||||
|
||||
@@ -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" && 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").
|
||||
@@ -57,7 +57,9 @@ test('argon2 and bcrypt are compiled for Android and must load before the bundle
|
||||
// was found for platform=android" until the 240 s smoke test gave up.
|
||||
const build = read('releases/android/build-bundle.sh');
|
||||
const prepare = build.indexOf('"$node" "$root/releases/prepare-bundle-npm.mjs" "$root/bundle"');
|
||||
const install = build.indexOf('(cd "$server" && npm install)');
|
||||
// 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" "$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');
|
||||
@@ -103,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" && 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', () => {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -30,7 +30,9 @@ assert.equal(parseDueAt({ dueAt: '2026-09-15T14:30:00+02:00' }).dueAt.toISOStrin
|
||||
vm.runInNewContext(src.slice(start, end), {
|
||||
WebApp: { handlers: { put: (route, fn) => { handler = fn; } } }, parseDueAt,
|
||||
Authentication: { checkBoardWriteAccess: async () => { if (denied) throw Error('denied'); } },
|
||||
ReactiveCache: { getCard: async () => wrongCard ? null : card, getChecklist: async () => ({ _id: 'check' }) },
|
||||
ReactiveCache: { getCard: async () => wrongCard ? null : card, getChecklist: async () => ({ _id: 'check' }), getBoard: async () => ({ _id: 'board' }) },
|
||||
// ListLockBleed's list check: tests/listPermissions.test.cjs pins it; here the list allows.
|
||||
assertListNotRefused: async () => {},
|
||||
Checklists: { direct: { updateAsync: async (...args) => updates.push(args) } },
|
||||
sendJsonResult: (res, data) => { response = data; },
|
||||
});
|
||||
@@ -51,7 +53,8 @@ assert.equal(parseDueAt({ dueAt: '2026-09-15T14:30:00+02:00' }).dueAt.toISOStrin
|
||||
vm.runInNewContext(itemSource.slice(itemStart, itemEnd), {
|
||||
WebApp: { handlers: { put: (route, fn) => { itemHandler = fn; } } }, parseDueAt,
|
||||
Authentication: { checkBoardWriteAccess: async () => {} },
|
||||
ReactiveCache: { getChecklistItem: async () => ({ _id: 'item', cardId: 'card', checklistId: 'check' }), getCard: async () => ({ _id: 'card', boardId: 'board' }) },
|
||||
ReactiveCache: { getChecklistItem: async () => ({ _id: 'item', cardId: 'card', checklistId: 'check' }), getCard: async () => ({ _id: 'card', boardId: 'board' }), getBoard: async () => ({ _id: 'board' }) },
|
||||
assertListNotRefused: async () => {},
|
||||
ChecklistItems: { direct: { updateAsync: async (...args) => itemUpdates.push(args) } },
|
||||
sendJsonResult: (res, data) => { itemResponse = data; },
|
||||
});
|
||||
|
||||
@@ -57,7 +57,11 @@ function fixture() {
|
||||
getChecklistItems: async () => [],
|
||||
getCard: async id => cards[id] || null,
|
||||
getChecklist: async q => (checklists[q._id] && checklists[q._id].cardId === q.cardId ? checklists[q._id] : null),
|
||||
getBoard: async id => ({ _id: id }),
|
||||
},
|
||||
// ListLockBleed's list check (tests/listPermissions.test.cjs pins it); the
|
||||
// lists of this fixture refuse nobody.
|
||||
assertListNotRefused: async () => {},
|
||||
ChecklistItems: {
|
||||
insertAsync: async doc => { inserted.push(doc); return 'new'; },
|
||||
direct: {
|
||||
|
||||
@@ -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\}`;/);
|
||||
});
|
||||
|
||||
|
||||
@@ -130,6 +130,73 @@ test('the REST API honours a list\'s refusal on every per-list card route', () =
|
||||
assert.match(read('server/models/cardComments.js'), /canCommentInList\(req\.userId, board, card\.listId\)/, 'comments too');
|
||||
});
|
||||
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): the checklist and checklist-item REST
|
||||
// routes asked only the board, so a member a list refused still wrote its
|
||||
// cards' checklists with a token. Not one route at a time: EVERY REST write in
|
||||
// the tree that names a card must ask the list - or be named here, with why.
|
||||
test('negative: every REST write that names a card asks the list (ListLockBleed)', () => {
|
||||
const unrelated = {
|
||||
// A comment's edit and deletion follow the comment's own rule (its author,
|
||||
// or a board admin: assertCanMutateComment), on DDP as over REST; a list's
|
||||
// comment refusal stops NEW comments, which the POST route asks.
|
||||
'PUT /api/boards/:boardId/cards/:cardId/comments/:commentId': 'comment rule',
|
||||
'DELETE /api/boards/:boardId/cards/:cardId/comments/:commentId': 'comment rule',
|
||||
// Dependency lines have their own board-wide rule (canEditBoardDependencies),
|
||||
// the same on DDP (setBoardDependency) as over REST.
|
||||
'POST /api/boards/:boardId/cards/:cardId/dependencies': 'dependency rule',
|
||||
'PUT /api/boards/:boardId/cards/:cardId/dependencies/:targetId': 'dependency rule',
|
||||
'DELETE /api/boards/:boardId/cards/:cardId/dependencies/:targetId': 'dependency rule',
|
||||
// A bot runner's report of its own run, written to BotRuns, not the card.
|
||||
'POST /api/boards/:boardId/cards/:cardId/bot-runs/:runId': 'bot run report',
|
||||
};
|
||||
const found = [], missing = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { if (!['tests', 'node_modules'].includes(entry.name)) walk(rel); continue; }
|
||||
if (!/\.js$/.test(entry.name)) continue;
|
||||
const src = read(rel);
|
||||
for (const m of src.matchAll(/WebApp\.handlers\.(post|put|delete|patch)\(\s*['"`]([^'"`]*:cardId[^'"`]*)['"`]/g)) {
|
||||
const route = `${m[1].toUpperCase()} ${m[2]}`;
|
||||
found.push(route);
|
||||
const next = src.indexOf('WebApp.handlers.', m.index + 1);
|
||||
let body = src.slice(m.index, next < 0 ? undefined : next);
|
||||
const delegate = /await (\w+)\(req, res\b/.exec(body);
|
||||
if (delegate) {
|
||||
const start = src.indexOf(`async function ${delegate[1]}(`);
|
||||
if (start >= 0) body += src.slice(start, src.indexOf('\n}\n', start));
|
||||
}
|
||||
if (!/assertListNotRefused\(|canWriteInList\(|canCommentInList\(/.test(body) && !unrelated[route]) missing.push(`${route} (${rel})`);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk('server');
|
||||
walk('models');
|
||||
assert.ok(found.length >= 20, `the REST card write routes are found (${found.length})`);
|
||||
for (const route of ['POST /api/boards/:boardId/cards/:cardId/checklists', 'PUT /api/boards/:boardId/cards/:cardId/checklists/:checklistId',
|
||||
'DELETE /api/boards/:boardId/cards/:cardId/checklists/:checklistId', 'POST /api/boards/:boardId/cards/:cardId/checklists/:checklistId/items',
|
||||
'PUT /api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
|
||||
'DELETE /api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId']) {
|
||||
assert.ok(found.includes(route), `${route} is scanned`);
|
||||
}
|
||||
assert.deepEqual(missing, []);
|
||||
// The exceptions are real routes: a renamed one must be looked at again.
|
||||
for (const route of Object.keys(unrelated)) assert.ok(found.includes(route), `${route} still exists`);
|
||||
});
|
||||
|
||||
test('the refusal is recorded in Admin Panel -> Problems, and never breaks the guard (ListLockBleed)', () => {
|
||||
const gate = read('server/lib/listPermissionGate.js');
|
||||
const fn = gate.slice(gate.indexOf('export async function assertListNotRefused('));
|
||||
assert.match(fn, /record\(\{ key: 'authz\.list-locked', action: 'blocked'/);
|
||||
assert.match(fn, /catch \(e\) \{ \/\* logging must never break the guard \*\/ \}/);
|
||||
// Recorded only on the refusal: after the early return of an allowed write.
|
||||
assert.ok(fn.indexOf("!== false) return;") < fn.indexOf('record('), 'an allowed write records nothing');
|
||||
const { categoryFor } = require('../models/lib/securityCategories');
|
||||
const cat = categoryFor('authz.list-locked');
|
||||
assert.equal(cat.bleed, 'ListLockBleed');
|
||||
assert.equal(cat.severity, 'medium', 'recorded, never disables the account');
|
||||
});
|
||||
|
||||
test('the client offers what the list allows', () => {
|
||||
const utils = read('client/lib/utils.js');
|
||||
assert.match(utils, /canModifyCard\([\s\S]*?return Utils\.currentUserCanInList\('write', card, board\);/);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,97 @@ test('every npm install in the release goes through it', () => {
|
||||
'these npm commands can lose a release to a five-minute outage');
|
||||
});
|
||||
|
||||
test('every npm install in a script a workflow runs goes through it too', () => {
|
||||
// The test above read only release-all.yml, so a bare install one level down
|
||||
// passed it: v13.03's Sandstorm .spk died on a 504 from github.com inside
|
||||
// releases/sandstorm-slim-spk.sh, and the Android bundle had the same line.
|
||||
// So read every releases/ script any workflow names, and the scripts those
|
||||
// name in turn.
|
||||
const wfDir = path.join(repoRoot, '.github/workflows');
|
||||
const seen = new Set();
|
||||
const queue = [];
|
||||
// Only command lines name a script that runs: a commented-out `scp` of one
|
||||
// does not run it.
|
||||
const scan = text => {
|
||||
const code = text.split('\n').filter(l => !/^\s*#/.test(l)).join('\n');
|
||||
for (const m of code.matchAll(/releases\/[\w./-]+\.sh\b/g)) {
|
||||
const rel = m[0];
|
||||
if (!seen.has(rel) && fs.existsSync(path.join(repoRoot, rel))) { seen.add(rel); queue.push(rel); }
|
||||
}
|
||||
};
|
||||
fs.readdirSync(wfDir).filter(f => /\.ya?ml$/.test(f))
|
||||
.forEach(f => scan(fs.readFileSync(path.join(wfDir, f), 'utf8')));
|
||||
const bare = [];
|
||||
while (queue.length) {
|
||||
const rel = queue.shift();
|
||||
const text = fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
scan(text);
|
||||
text.split('\n').forEach((line, i) => {
|
||||
if (/^\s*#/.test(line)) return;
|
||||
if (!/\bnpm (install|ci)\b/.test(line)) return;
|
||||
if (line.includes('npm-retry.sh')) return;
|
||||
if (line.includes('|| true')) return; // best-effort, as above
|
||||
if (/\bgrep\b/.test(line)) return; // looking for the line, not running it
|
||||
if (/^\s*(echo|printf)\b|"[^"]*\bnpm (install|ci)\b[^"]*"\s*(>&2|;|\)|$)/.test(line) && !/\(cd /.test(line)) return; // a message, not a command
|
||||
bare.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
assert.ok(seen.has('releases/sandstorm-slim-spk.sh'), 'the Sandstorm pack script is among the scripts read');
|
||||
assert.deepStrictEqual(bare, [],
|
||||
'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,58 @@
|
||||
'use strict';
|
||||
// ListLockBleed (GHSA-4734-8vrg-8j3w): a list whose exception refuses a role
|
||||
// write (#4527) refuses that role's checklist and checklist-item writes over
|
||||
// REST too, and the attempt is in Admin Panel -> Problems. A list without the
|
||||
// refusal still takes the same writes.
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
|
||||
test('a list refusing a role write refuses its checklist REST writes (ListLockBleed)', async ({ request, board, user, user2 }) => {
|
||||
const original = db.getBoard(board.boardId).members;
|
||||
const [lockedListId, openListId] = board.listIds;
|
||||
const card = id => db.find('cards', { boardId: board.boardId, listId: id })[0];
|
||||
const locked = card(lockedListId), open = card(openListId);
|
||||
const api = (method, url, data) => request[method](url, {
|
||||
headers: { Authorization: `Bearer ${user2.token}`, 'Content-Type': 'application/json' }, ...(data ? { data } : {}) });
|
||||
const recorded = () => db.findOne('eventlog', { bleed: 'ListLockBleed' })?.count || 0;
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { members: [...original.filter(m => m.userId !== user2.id),
|
||||
{ userId: user2.id, isActive: true, isAdmin: false }] } });
|
||||
db.updateOne('lists', { _id: lockedListId }, { $set: { roleExceptions: { normal: { write: false } } } });
|
||||
const checklist = { _id: db.uid('locked-checklist'), cardId: locked._id, title: 'Locked', sort: 0 };
|
||||
const item = { _id: db.uid('locked-item'), cardId: locked._id, checklistId: checklist._id, title: 'Locked item', sort: 0, isFinished: false };
|
||||
db.insertOne('checklists', checklist);
|
||||
db.insertOne('checklistItems', item);
|
||||
try {
|
||||
const before = recorded();
|
||||
const base = `/api/boards/${board.boardId}/cards/${locked._id}/checklists`;
|
||||
for (const [method, url, data] of [
|
||||
['post', base, { title: 'x' }],
|
||||
['put', `${base}/${checklist._id}`, { title: 'renamed' }],
|
||||
['post', `${base}/${checklist._id}/items`, { title: 'new item' }],
|
||||
['put', `${base}/${checklist._id}/items/${item._id}`, { isFinished: 'true', title: 'changed' }],
|
||||
['delete', `${base}/${checklist._id}/items/${item._id}`],
|
||||
['delete', `${base}/${checklist._id}`],
|
||||
]) {
|
||||
const res = await api(method, url, data);
|
||||
expect(res.status(), `${method.toUpperCase()} ${url}`).toBe(403);
|
||||
}
|
||||
expect(db.find('checklists', { cardId: locked._id })).toEqual([expect.objectContaining({ _id: checklist._id, title: 'Locked' })]);
|
||||
expect(db.find('checklistItems', { cardId: locked._id })).toEqual([expect.objectContaining({ _id: item._id, title: 'Locked item', isFinished: false })]);
|
||||
expect(recorded()).toBeGreaterThan(before);
|
||||
expect(db.findOne('users', { _id: user2.id }).loginDisabled || false).toBe(false);
|
||||
|
||||
// Negative: the same member writes the checklists of a list without the refusal.
|
||||
const made = await api('post', `/api/boards/${board.boardId}/cards/${open._id}/checklists`, { title: 'Open', items: ['one'] });
|
||||
expect(made.status()).toBe(200);
|
||||
const openId = (await made.json())._id;
|
||||
expect((await api('put', `/api/boards/${board.boardId}/cards/${open._id}/checklists/${openId}`, { title: 'Open renamed' })).status()).toBe(200);
|
||||
expect((await api('delete', `/api/boards/${board.boardId}/cards/${open._id}/checklists/${openId}`)).status()).toBe(200);
|
||||
// And the board admin, whom no list exception binds, writes the locked list's.
|
||||
const admin = await request.post(base, { headers: { Authorization: `Bearer ${user.token}`, 'Content-Type': 'application/json' }, data: { title: 'Admin' } });
|
||||
expect(admin.status()).toBe(200);
|
||||
} finally {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { members: original } });
|
||||
db.updateOne('lists', { _id: lockedListId }, { $unset: { roleExceptions: '' } });
|
||||
db.deleteMany('checklists', { cardId: { $in: [locked._id, open._id] } });
|
||||
db.deleteMany('checklistItems', { cardId: { $in: [locked._id, open._id] } });
|
||||
}
|
||||
});
|
||||
@@ -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] });
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -221,6 +221,7 @@ const GUARDED = {
|
||||
importplacementbleed: ['tests/importPlacementBleed.test.cjs', 'tests/playwright/specs/security-import-placement.e2e.js'],
|
||||
samlsubjectbleed: ['tests/samlIdentityBinding.test.cjs'],
|
||||
stampbleed: ['tests/stampBleed.test.cjs', 'tests/playwright/specs/upload-stamp-bleed.e2e.js'],
|
||||
listlockbleed: ['tests/listPermissions.test.cjs', 'tests/playwright/specs/list-lock-checklists.e2e.js'],
|
||||
unassignedarchivebleed: ['tests/assignedCardMethods.test.cjs', 'tests/playwright/specs/assigned-card-methods.e2e.js'],
|
||||
unassignedattachbleed: ['tests/unassignedAttachBleed.test.cjs', 'tests/playwright/specs/unassigned-attach-bleed.e2e.js'],
|
||||
uploadbleed: ['tests/uploadBleed.test.cjs', 'tests/playwright/specs/upload-stamp-bleed.e2e.js'],
|
||||
@@ -373,7 +374,8 @@ test('the whole published list is accounted for', () => {
|
||||
// unnoticed until the companion checkout moved to .tools/w/wekan.fi and this
|
||||
// file looked there: at the old path the comparison was skipped.
|
||||
// 145 -> 146: UnassignedArchiveBleed (GHSA-6xqf-jh8x-4wxc, 2026-11-08), guarded.
|
||||
assert.strictEqual(total, 146, 'the Hall of Fame and this list disagree on how many there are');
|
||||
// 146 -> 147: ListLockBleed (GHSA-4734-8vrg-8j3w, 2026-11-09), guarded.
|
||||
assert.strictEqual(total, 147, 'the Hall of Fame and this list disagree on how many there are');
|
||||
});
|
||||
|
||||
test('the companion Hall of Fame names match the inventory when available', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user