Compare commits

..
47 Commits
Author SHA1 Message Date
NotAlexNoyleandClaude Opus 5.5 860a385496 Record TrueOG's WeKan deployment in trueog/
Environment (secrets replaced by placeholders), OpenRC and nginx files, the
Staff-Auth sign-in setup and Admin Panel settings, the trueog-wekan-authwatch
service with its installer and tests, and the step-by-step upgrade helper with
its patches, as deployed on 2026-11-11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MpagfNzrTjLo49dfEEBxnF
2026-11-11 07:42:47 -05:00
NotAlexNoyleandClaude Opus 5.5 1c70df29a2 OIDC sign-ups need no invitation code when registration is disabled
With registration disabled, Users.after.insert demanded an invitation code for
every new OAuth2 account, so a first sign-in through the OIDC provider created
the account and then failed with error-invitation-code-not-exist. Accounts
created by the OIDC service skip that check; the identity provider decides who
gets in. Password registration stays closed. Live on 13.06 through
wekan-patch-13.06.py (oidcSignup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MpagfNzrTjLo49dfEEBxnF
2026-11-11 05:15:18 -05:00
NotAlexNoyleandClaude Opus 5.5 8bdf86eaef Merge upstream WeKan v13.06
Keeps the TrueOG changes on top of upstream v13.06:
- .gitattributes and .gitea/CODEOWNERS (one staff approval)
- minicardOverrides.css hides the minicard completion checkbox (import moved after the last card stylesheet)
- setBoardTeams adds every member of an assigned active team on the server, since a board admin who is not a
  site admin cannot see other users' teams in the browser
- app.env and the AUR wekan.env example are not tracked, and .gitignore excludes .env files

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Thanks to xet7 !
2026-11-10 08:18:49 +02:00
github-actions[bot] bc698806b2 Bump versions for v13.04 2026-11-09 18:28:55 +00:00
Lauri Ojansivu 0556f01030 Prepare v13.04 release 2026-11-09 20:24:26 +02:00
Lauri Ojansivu b379a02631 CHANGELOG: the Sandstorm and Android bundles retry their npm install through a GitHub outage.
Thanks to xet7 !
2026-11-09 20:02:11 +02:00
Lauri Ojansivu c3f12b5659 Sandstorm and Android bundles retry their npm install through a GitHub outage.
v13.03's build-sandstorm died in releases/sandstorm-slim-spk.sh with
"npm error 504 Gateway Time-out - GET https://github.com/meteor/
node-source-map-support/tarball/81bce1f9..." - the same github.com outage
releases/npm-retry.sh was written for in v10.86. That install, and the
Android bundle's programs/server and sharp installs, ran npm bare, because
tests/npmRetry.test.cjs read only release-all.yml and never the scripts it
runs. All three now go through npm-retry.sh: a transient 5xx or socket
error is retried with backoff, a real npm error still fails at once.

tests/npmRetry.test.cjs now follows every releases/ script a workflow runs,
and the scripts those run, and fails on a bare npm install or ci in any of
them; it fails on the old lines. tests/androidBundles.test.cjs pins the same
installs in the same order, now through the retry.

Thanks to xet7 !
2026-11-09 20:01:57 +02:00
Lauri Ojansivu ea53de4985 CHANGELOG: ListLockBleed; the security regression inventory guards it.
The Upcoming release fixes the CRITICAL SECURITY ISSUE of ListLockBleed
(GHSA-4734-8vrg-8j3w): a list that refuses a role write access refuses that
role's checklist and checklist-item REST writes too.
tests/securityRegressionCoverage.test.cjs names its guards
(tests/listPermissions.test.cjs and
tests/playwright/specs/list-lock-checklists.e2e.js) and counts 147.

Thanks to tsurutanmen and xet7 !
2026-11-09 19:44:17 +02:00
Lauri Ojansivu 214b20e7ad ListLockBleed: a list's write refusal holds for the checklist REST routes too.
GHSA-4734-8vrg-8j3w. A board admin can refuse a role write access in one
list (#4527, list.roleExceptions). The card and comment REST routes and the
DDP checklist writes applied that refusal; the six checklist and
checklist-item REST routes asked only the board, so a member the list
refused still created, renamed, ticked and deleted its cards' checklists
and items with an API token.

They now call assertListNotRefused with the card's list, as the card routes
do, and answer 403. The refusal is recorded in Admin Panel -> Problems as
ListLockBleed (authz.list-locked, medium: recorded, never disables the
account): no screen offers such a write, so it is the lock being stepped
round. The card routes' refusals are recorded through the same gate.

tests/listPermissions.test.cjs scans every REST write route of the tree that
names a card and fails on one that does not ask the list - not only the
per-list card routes it checked before - naming the three that follow their
own rule (a comment's edit and deletion, dependency lines, a bot run's
report), and checks the refusal is recorded. It fails on the old routes.
tests/playwright/specs/list-lock-checklists.e2e.js refuses all six writes in
a locked list with 403, leaves the checklist and item untouched, records the
attempt, and still lets the same member write in an open list and the board
admin in the locked one.

Thanks to tsurutanmen and xet7 !
2026-11-09 19:41:49 +02:00
github-actions[bot] 0f89ad07b8 Bump versions for v13.03 2026-11-09 07:49:50 +00:00
Lauri Ojansivu 3fb9e4d1be Prepare v13.03 release 2026-11-09 09:47:39 +02:00
Lauri Ojansivu 5ca5b00a98 CHANGELOG: amd64 snaps wait out an archive outage; UnassignedArchiveBleed inventory.
Thanks to xet7 !
2026-11-09 09:08:09 +02:00
Lauri Ojansivu 6cc84a07cd Security regression inventory: UnassignedArchiveBleed is guarded.
The Hall of Fame published UnassignedArchiveBleed (GHSA-6xqf-jh8x-4wxc), so
tests/securityRegressionCoverage.test.cjs failed: the companion list had a
name the inventory did not. It is in GUARDED now, with the two suites that
pin the fix, and those suites name it.

Thanks to tsurutanmen and xet7 !
2026-11-09 09:07:51 +02:00
Lauri Ojansivu a7ace18af8 Snap: wait out an Ubuntu archive outage instead of failing the amd64 snaps.
v13.02's snap-native amd64 and both amd64 snap-variants failed within eight
minutes of each other with "Failed to refresh package list: failed to run
apt update" while snapcraft installed build-packages, and the arm64 snaps,
served by ports.ubuntu.com, built. releases/snap-refresh-managed-apt.py
recovered only a stale .deb 404 and refused this, so the one guarded retry
never ran.

It now also recognises a failed apt update in the managed project instance,
retries apt-get update there with a 30/60/120/240 s backoff until the archive
answers, and only then lets the Snapcraft retry run; an outage that outlasts
the backoff stays fatal. It prints apt's own E:/W:/Err: lines from the
Snapcraft log first, so a failing job says why apt failed. Anything else is
still refused.

Thanks to xet7 !
2026-11-09 09:07:51 +02:00
github-actions[bot] 7e0fd84c3c Bump versions for v13.02 2026-11-09 02:14:45 +00:00
Lauri Ojansivu 4023654d84 Prepare v13.02 release 2026-11-09 04:13:46 +02:00
Lauri Ojansivu d213e7b674 Merge remote-tracking branch 'origin/main' 2026-11-09 04:08:15 +02:00
Lauri Ojansivu 423ac8a145 German: "Due next week" filter says "Nächste Woche fällig", not "Diese Woche fällig".
The filter sidebar's due date options "Due this week" and "Due next week"
had the same German label, so two options returning different cards could
not be told apart. Fixed in de, de-AT, de_DE and de-CH, and the same copied
label in lb, sv and ts. In ak, "Due today" and "Due next week" both said
"search this"; both now say what they filter (low confidence for ak, lb
and ts).

tests/dueFilterDistinctLabels.test.cjs pins the German labels and fails
when any language gives two due date options the same label.

Thanks to d0g2342 and xet7 !

Fixes #6887.
2026-11-08 21:16:56 +02:00
Lauri Ojansivu 913acde0ef CHANGELOG: UnassignedArchiveBleed, GHSA-6xqf-jh8x-4wxc.
Thanks to tsurutanmen and xet7 !
2026-11-08 14:52:11 +02:00
Lauri Ojansivu 1513b2e98c UnassignedArchiveBleed: an assigned-only member archives and converts only the cards assigned to them.
GHSA-6xqf-jh8x-4wxc. The DDP methods archiveSelectedCards and
createBoardFromCard asked only for the board's write capability. The
Normal, assigned only role has it, but sees and changes only its own
cards, so such a member who knew a card's id archived any card of the
board - and its subtasks - and turned any card into a linked-board
card. Both methods now also ask assignedOnlyAllowsCard, as copyCard
does; archiveSelectedCards checks every card of the selection before it
archives the first, so a refused selection changes nothing.

A refusal is recorded in Admin Panel -> Problems as
UnassignedArchiveBleed (authz.assigned-card-write, medium, never
disabling the account). tests/assignedCardMethods.test.cjs drives the
reported case and fails for any server method that takes a card id and
decides by the board's write access alone;
tests/playwright/specs/assigned-card-methods.e2e.js replays the
reporter's calls against a running server.

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

Correct the handler regression and cover JSON/form edits, persistence, API reads, reactive display, reloads and rejected requests.
2026-11-06 19:04:30 +03:00
NotAlexNoyle 6e4c0539af Exclude CODEOWNERS from language statistics 2026-10-31 16:52:22 -04:00
NotAlexNoyle b8fcef2fd4 Add CODEOWNERS (one staff approval) 2026-10-31 16:52:21 -04:00
NotAlexNoyle 4e2e6adc73 Fix card details import 2026-06-13 11:54:24 -04:00
NotAlexNoyle 091541a960 Hide minicard completion checkbox 2026-06-13 11:52:03 -04:00
98 changed files with 4061 additions and 713 deletions

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
.gitea/CODEOWNERS linguist-detectable=false
+3
View File
@@ -0,0 +1,3 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
app/.tools/meteor-bun/dev_bundle
app/.tools/meteor-bun/.babel-cache
app/.tools/meteor-bun/packages/*/.npm
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.1.patch') }}
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6.patch') }}
- name: Build the release's ESM application once
working-directory: app
env:
+2 -2
View File
@@ -4714,7 +4714,7 @@ jobs:
with:
snapcraft-args: pack
- name: Refresh stale managed APT indexes after a package 404
- name: Refresh managed APT after a package 404 or a failed apt update
if: steps.build-first.outcome == 'failure'
run: sudo -u "$(id -un)" -E python3 "$GITHUB_WORKSPACE/releases/snap-refresh-managed-apt.py"
@@ -5610,7 +5610,7 @@ jobs:
snapcraft-args: pack
path: variant
- name: Refresh stale managed APT indexes after a package 404
- name: Refresh managed APT after a package 404 or a failed apt update
if: steps.build-first.outcome == 'failure'
run: sudo -u "$(id -un)" -E python3 "$GITHUB_WORKSPACE/wekan/releases/snap-refresh-managed-apt.py"
+5
View File
@@ -126,3 +126,8 @@ mongo-tools-patches/
# Generated on the Windows single-EXE runner (releases/append-windows-payload.mjs --manifest)
wekan-real-files.h
# TrueOG: never track .env files
.env
*.env
.env.*
+27 -27
View File
@@ -6,12 +6,12 @@
meteor-base@1.5.2
# Build system
ecmascript@0.19.2-rc360.1
standard-minifier-js@3.3.0-rc360.1
rspack@1.4.0-rc360.1
ecmascript@0.19.2
standard-minifier-js@3.3.0
rspack@1.4.0
# Declared explicitly because server/modernBrowsers.js imports it, to say that
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
modern-browsers@0.2.4-rc360.1
modern-browsers@0.2.4
blaze@3.0.2
@@ -19,11 +19,11 @@ blaze@3.0.2
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.5.2-rc360.1
mongo@2.6.0
# Account system
accounts-password@3.3.2-rc360.1
accounts-2fa@3.1.1-rc360.1
accounts-password@3.3.2
accounts-2fa@3.1.1
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
@@ -39,19 +39,19 @@ wekan-oidc
wekan-accounts-oidc
# Utilities
check@1.5.1-rc360.1
random@1.2.3-rc360.1
reactive-dict@1.3.3-rc360.1
session@1.2.4-rc360.1
tracker@1.3.5-rc360.1
check@1.5.1
random@1.2.3
reactive-dict@1.3.3
session@1.2.4
tracker@1.3.5
audit-argument-checks@1.0.8
# UI components
ostrio:i18n
reactive-var@1.0.13
shell-server@0.7.2-rc360.1
email@3.2.1-rc360.1
dynamic-import@0.8.1-rc360.1
shell-server@0.7.2
email@3.2.1
dynamic-import@0.8.1
wekan-markdown
quave:synced-cron
ostrio:cookies
@@ -59,21 +59,21 @@ ostrio:files
# lmieulet:meteor-coverage
meteortesting:mocha@3.0.0
matb33:collection-hooks
service-configuration@1.3.6-rc360.1
service-configuration@1.3.6
minifier-css@2.0.2
ejson@1.4.1-rc360.1
logging@1.3.7-rc360.1
ejson@1.4.1
logging@1.3.7
wekan-fullcalendar
wekan-fontawesome
accounts-google@1.4.2-rc360.1
accounts-github@1.5.2-rc360.1
accounts-facebook@1.3.5-rc360.1
accounts-twitter@1.5.3-rc360.1
accounts-meteor-developer@1.5.2-rc360.1
accounts-weibo@1.4.2-rc360.1
accounts-meetup@1.5.2-rc360.1
accounts-passwordless@3.1.2-rc360.1
instrumentation@0.0.1-rc360.1
accounts-google@1.4.2
accounts-github@1.5.2
accounts-facebook@1.3.5
accounts-twitter@1.5.3
accounts-meteor-developer@1.5.2
accounts-weibo@1.4.2
accounts-meetup@1.5.2
accounts-passwordless@3.1.2
instrumentation@0.0.1
# Prevent Meteor build/run tools from sending application package statistics.
package-stats-opt-out@1.0.8
+1 -1
View File
@@ -1 +1 @@
METEOR@3.6-rc.1
METEOR@3.6
+74 -74
View File
@@ -1,130 +1,130 @@
accounts-2fa@3.1.1-rc360.1
accounts-base@3.4.0-rc360.1
accounts-2fa@3.1.1
accounts-base@3.4.0
accounts-express@1.0.0
accounts-facebook@1.3.5-rc360.1
accounts-github@1.5.2-rc360.1
accounts-google@1.4.2-rc360.1
accounts-meetup@1.5.2-rc360.1
accounts-meteor-developer@1.5.2-rc360.1
accounts-oauth@1.4.8-rc360.1
accounts-password@3.3.2-rc360.1
accounts-passwordless@3.1.2-rc360.1
accounts-twitter@1.5.3-rc360.1
accounts-weibo@1.4.2-rc360.1
accounts-facebook@1.3.5
accounts-github@1.5.2
accounts-google@1.4.2
accounts-meetup@1.5.2
accounts-meteor-developer@1.5.2
accounts-oauth@1.4.8
accounts-password@3.3.2
accounts-passwordless@3.1.2
accounts-twitter@1.5.3
accounts-weibo@1.4.2
aldeed:collection2@4.2.2
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.2-rc360.1_1
allow-deny@2.1.2_1
audit-argument-checks@1.0.8
autoupdate@2.0.2-rc360.1
babel-compiler@7.15.2-rc360.1
autoupdate@2.0.2
babel-compiler@7.15.2
babel-runtime@1.5.2
base64@1.0.15-rc360.1
binary-heap@1.0.14-rc360.1
base64@1.0.15
binary-heap@1.0.14
blaze@3.0.3
blaze-tools@2.0.1
boilerplate-generator@2.2.0-rc360.1
boilerplate-generator@2.2.0
caching-compiler@2.0.1
caching-html-compiler@2.0.1
callback-hook@1.8.1-rc360.1
check@1.5.1-rc360.1
callback-hook@1.8.1
check@1.5.1
communitypackages:core@2.0.3
communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.3-rc360.1
ddp-client@3.4.2-rc360.1
ddp-common@1.4.6-rc360.1
ddp-rate-limiter@1.3.1-rc360.1
ddp-server@3.6.0-rc360.1_1
diff-sequence@1.2.1-rc360.1
dynamic-import@0.8.1-rc360.1_1
ecmascript@0.19.2-rc360.1
ddp@1.4.3
ddp-client@3.4.2
ddp-common@1.4.6
ddp-rate-limiter@1.3.1
ddp-server@3.6.0_1
diff-sequence@1.2.1
dynamic-import@0.8.1_1
ecmascript@0.19.2
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.4.1-rc360.1
email@3.2.1-rc360.1
ejson@1.4.1
email@3.2.1
es5-shim@4.8.1
facebook-oauth@1.12.1-rc360.1_1
facts-base@1.0.3-rc360.1
fetch@0.2.1-rc360.1
geojson-utils@1.0.13-rc360.1
github-oauth@1.4.3-rc360.1
google-oauth@1.5.1-rc360.1_1
facebook-oauth@1.12.1_1
facts-base@1.0.3
fetch@0.2.1
geojson-utils@1.0.13
github-oauth@1.4.3
google-oauth@1.5.1_1
hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.1-rc360.1
instrumentation@0.0.1-rc360.1
inter-process-messaging@0.1.4-rc360.1
id-map@1.2.1
instrumentation@0.0.1
inter-process-messaging@0.1.4
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
logging@1.3.7-rc360.1
logging@1.3.7
matb33:collection-hooks@2.1.0
meetup-oauth@1.1.4-rc360.1
meteor@2.3.2-rc360.1
meetup-oauth@1.1.4
meteor@2.3.2
meteor-base@1.5.2
meteor-developer-oauth@1.3.4-rc360.1
meteor-developer-oauth@1.3.4
meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.4.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2
minifier-js@3.3.2-rc360.1
minimongo@2.2.1-rc360.1
modern-browsers@0.2.4-rc360.1
minifier-js@3.3.2
minimongo@2.2.1
modern-browsers@0.2.4
modules@0.20.3
modules-runtime@0.13.2
mongo@2.5.2-rc360.1
mongo@2.6.0
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.10-rc360.1
npm-mongo@6.18.1-rc360.1
oauth@3.0.5-rc360.1
oauth1@1.5.4-rc360.1
mongo-id@1.0.10
npm-mongo@6.18.1
oauth@3.0.5
oauth1@1.5.4
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.1-rc360.1
ordered-dict@1.2.1
ostrio:cookies@2.9.2
ostrio:cstorage@4.0.1
ostrio:files@3.0.1
ostrio:flow-router-extra@3.15.3
ostrio:i18n@3.2.1
package-stats-opt-out@1.0.8
promise@1.0.1-rc360.1
promise@1.0.1
quave:synced-cron@2.4.0
raix:eventemitter@1.0.0
random@1.2.3-rc360.1
rate-limit@1.2.1-rc360.1
random@1.2.3
rate-limit@1.2.1
react-fast-refresh@0.3.0
reactive-dict@1.3.3-rc360.1
reactive-dict@1.3.3
reactive-var@1.0.13
reload@1.3.3-rc360.1
retry@1.1.2-rc360.1
reload@1.3.3
retry@1.1.2
reywood:publish-composite@1.9.0
routepolicy@1.1.3-rc360.1
rspack@1.4.0-rc360.1
service-configuration@1.3.6-rc360.1
session@1.2.4-rc360.1
sha@1.0.11-rc360.1
shell-server@0.7.2-rc360.1_1
socket-stream-client@0.7.2-rc360.1
routepolicy@1.1.3
rspack@1.4.0
service-configuration@1.3.6
session@1.2.4
sha@1.0.11
shell-server@0.7.2_1
socket-stream-client@0.7.2
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.3.0-rc360.1
standard-minifier-js@3.3.0
templating@1.4.5
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.4.0-rc360.1
tracker@1.3.5-rc360.1
twitter-oauth@1.3.5-rc360.1
typescript@5.11.2-rc360.1
url@1.3.7-rc360.1
webapp@2.3.1-rc360.1
webapp-hashing@1.1.3-rc360.1
weibo-oauth@1.3.4-rc360.1
tools-core@1.4.0
tracker@1.3.5
twitter-oauth@1.3.5
typescript@5.11.2
url@1.3.7
webapp@2.4.0
webapp-hashing@1.1.3
weibo-oauth@1.3.4
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
wekan-accounts-oidc@1.0.10
+275 -432
View File
@@ -124,6 +124,281 @@ 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
archive is briefly unreachable: the build waits for it and retries once.
This release fixes the following bugs:
**Release builds** - what the release workflow does when a build fails.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a7ace18af8">The amd64 snaps wait out an Ubuntu archive outage instead of failing</a>. Thanks to xet7.</summary>
v13.02's native amd64 snap and both amd64 snap variants failed within eight
minutes of each other with "Failed to refresh package list: failed to run apt
update" while Snapcraft installed build-packages; the arm64 snaps, served by
`ports.ubuntu.com`, built. `releases/snap-refresh-managed-apt.py` recovered
only a stale `.deb` 404, so the one guarded retry never ran. It now also
recognises a failed apt update in the managed build instance, retries
`apt-get update` there with a 30/60/120/240 s backoff, and lets the Snapcraft
retry run only once the archive answers; a longer outage stays fatal. It
prints apt's own error lines first, so a failing job says why apt failed.
`tests/snap-managed-apt.py` pins the recovery, its bound, and that unrelated
failures are still refused.
</details>
and has the following developer-tooling fix:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6cc84a07cd">The security regression inventory counts UnassignedArchiveBleed as guarded</a>. Thanks to tsurutanmen and xet7.</summary>
`tests/securityRegressionCoverage.test.cjs` failed once the Hall of Fame
published UnassignedArchiveBleed, because its inventory did not have the name.
It is guarded now by `tests/assignedCardMethods.test.cjs` and
`tests/playwright/specs/assigned-card-methods.e2e.js`, which name it.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v13.02 2026-11-09 WeKan ® release
**In short:** **UnassignedArchiveBleed** is fixed: a member with the
**Normal, assigned only** role archives and converts only the cards assigned
to them, through every card method.
This release fixes the following CRITICAL SECURITY ISSUE of [UnassignedArchiveBleed](https://wekan.fi/hall-of-fame/unassignedarchivebleed/):
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1513b2e98c">An assigned-only member archives and converts only the cards assigned to them</a>. Thanks to tsurutanmen and xet7.</summary>
[UnassignedArchiveBleed](https://wekan.fi/hall-of-fame/unassignedarchivebleed/),
[GHSA-6xqf-jh8x-4wxc](https://github.com/wekan/wekan/security/advisories/GHSA-6xqf-jh8x-4wxc).
The DDP methods `archiveSelectedCards` and `createBoardFromCard` asked only
for the board's write capability, which the Normal, assigned only role has, so
such a member who knew a card's id archived any card of the board - and its
subtasks - and turned any card into a linked-board card. Both now also ask
the card, as `copyCard` does; a selection is checked whole before the first
card is archived. A refusal is shown in Admin Panel / Problems.
`tests/assignedCardMethods.test.cjs` fails for any server method that takes a
card id and decides by the board's write access alone.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v13.01 2026-11-08 WeKan ® release
**In short:** Translations: the strings still in English are written in **33
@@ -8184,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 -36
View File
@@ -5,53 +5,46 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v13.01 2026-11-08 WeKan ® release
# v13.06 2026-11-11 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.
**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.01](CHANGELOG-big.md#v1301-2026-11-08-wekan--release)
[All changes in v13.06](CHANGELOG-big.md#v1306-2026-11-11-wekan--release)
# v13.00 2026-11-07 WeKan ® release
# v13.05 2026-11-10 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.
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.00](CHANGELOG-big.md#v1300-2026-11-07-wekan--release)
[All changes in v13.05](CHANGELOG-big.md#v1305-2026-11-10-wekan--release)
# v12.99 2026-11-06 WeKan ® release
# v13.04 2026-11-09 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.
**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 v12.99](CHANGELOG-big.md#v1299-2026-11-06-wekan--release)
[All changes in v13.04](CHANGELOG-big.md#v1304-2026-11-09-wekan--release)
# v12.98 2026-11-06 WeKan ® release
# v13.03 2026-11-09 WeKan ® release
**GroupLinkBleed** is fixed: a board admin can no longer skip the
board methods by writing the board directly, to attach an organization or team
they do not belong to or to add a member past the invitation checks.
**Public board CSV and TSV exports** honour the selected columns, and the REST
**card archive, restore and custom-field** endpoints answer an unmatched card
with a JSON 404 instead of 500, and a REST **card update** can set sort 0, turn
overtime off and clear a description. The **notifications drawer** gets its
attachments again, and the developer instructions cover the **weba** Gambas
companion repository.
The amd64 **snaps** no longer fail the release when the Ubuntu
archive is briefly unreachable: the build waits for it and retries once.
[All changes in v12.98](CHANGELOG-big.md#v1298-2026-11-06-wekan--release)
[All changes in v13.03](CHANGELOG-big.md#v1303-2026-11-09-wekan--release)
# v12.97 2026-11-06 WeKan ® release
# v13.02 2026-11-09 WeKan ® release
`build.sh` and the `releases/` scripts now work on **Arch Linux**
and **Omarchy**, and the release builds no longer lose the **OpenBSD arm64
bundle** to a smoke test that left WeKan running, or the **s390x snap** to
Launchpad outages that were counted as build failures.
**UnassignedArchiveBleed** is fixed: a member with the
**Normal, assigned only** role archives and converts only the cards assigned
to them, through every card method.
[All changes in v12.97](CHANGELOG-big.md#v1297-2026-11-06-wekan--release)
[All changes in v13.02](CHANGELOG-big.md#v1302-2026-11-09-wekan--release)
+2 -2
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=13.01
ARG VERSION=13.06
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
@@ -149,7 +149,7 @@ ENV \
DEBUG=false \
DDP_TRANSPORT=sockjs \
NODE_VERSION=v26.13.0 \
METEOR_RELEASE=METEOR@3.6-rc.1 \
METEOR_RELEASE=METEOR@3.6 \
USE_EDGE=false \
NPM_VERSION=12.2.2 \
SRC_PATH=./ \
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v13.01.0"
appVersion: "v13.06.0"
files:
userUploads:
- README.md
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
export PACKAGE_DIRS="$(pwd)/packages"
+4 -1
View File
@@ -195,7 +195,10 @@ template(name="activity")
if(currentData.timeKey)
| {{_ activity.activityType }}
= ' '
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
//- A date cleared has no new value (server/lib/timingChangeActivity.js):
only the one it had, below, rather than an empty date.
if currentData.timeValue
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
if (currentData.timeOldValue)
= ' '
| {{{activityMessage "previous_as" }}}
@@ -0,0 +1,3 @@
.minicard-title .minicard-complete-toggle {
display: none;
}
+1
View File
@@ -54,4 +54,5 @@ import '/client/components/cards/subtasks.css';
import '/client/components/cards/similarCards.css';
import '/client/components/cards/fromText.css';
import '/client/components/cards/whiteboard.css';
import '/client/components/cards/minicardOverrides.css';
@@ -1,14 +0,0 @@
# WeKan settings. Every setting is listed, with comments, in
# https://github.com/wekan/wekan/blob/main/docker-compose.yml
# Restart after editing: systemctl restart wekan
# The address users open WeKan at. WeKan builds its links from it and its
# live updates connect to it, so it must match the address in the browser.
ROOT_URL=http://localhost:8080
PORT=8080
# Database, attachments and avatars: /var/lib/wekan/files/{db,attachments,avatars}
WRITABLE_PATH=/var/lib/wekan
#MAIL_URL=smtp://user:password@mailserver.example.com:25/
#MAIL_FROM=WeKan <wekan@example.com>
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-13.01-win64.zip](https://github.com/wekan/wekan/releases/download/v13.01/wekan-13.01-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.01-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)
+2 -2
View File
@@ -855,11 +855,11 @@
"filter-no-due-date": "Da a ɛsɛ sɛ ewie nni hɔ",
"filter-overdue": "Hwehwɛ ade yi",
"filter-due-any": "Awiei da biara (nea enni da nso ka ho)",
"filter-due-today": "Hwehwɛ ade yi",
"filter-due-today": "Ɛsɛ sɛ ewie nnɛ",
"filter-due-previous-week": "Ɛsɛ sɛ ɛba awiei nnawɔtwe a etwaam no",
"filter-due-next-month": "Ɛsɛ sɛ ɛba awiei bosome a edi hɔ no",
"filter-due-this-week": "Ɛsɛ sɛ ewie dapɛn yi",
"filter-due-next-week": "Hwehwɛ ade yi",
"filter-due-next-week": "Ɛsɛ sɛ ewie dapɛn a edi hɔ no",
"filter-due-tomorrow": "Ɛsɛ sɛ ewie ɔkyena",
"list-filter-label": "Yi mu Nhyehyɛe denam Ti asɛm",
"filter-clear": "Hwehwɛ ade yi",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "Diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "Nächste Woche fällig",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "nächste Woche fällig",
"filter-due-tomorrow": "morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "Diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "Nächste Woche fällig",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "In der Vorwoche fällig",
"filter-due-next-month": "Im nächsten Monat fällig",
"filter-due-this-week": "Diese Woche fällig",
"filter-due-next-week": "Diese Woche fällig",
"filter-due-next-week": "Nächste Woche fällig",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Liste nach Titel filtern",
"filter-clear": "Filter entfernen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "An der viregter Woch fälleg",
"filter-due-next-month": "Den nächste Mount fälleg",
"filter-due-this-week": "Dës Woche fällig",
"filter-due-next-week": "Dës Woche fällig",
"filter-due-next-week": "Déi nächst Woch fälleg",
"filter-due-tomorrow": "Morgen fällig",
"list-filter-label": "Lëscht no Titel filtern",
"filter-clear": "Filter ewechhuelen",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "Förföll förra veckan",
"filter-due-next-month": "Förfaller nästa månad",
"filter-due-this-week": "Förfaller denna vecka",
"filter-due-next-week": "Förfaller denna vecka",
"filter-due-next-week": "Förfaller nästa vecka",
"filter-due-tomorrow": "Förfaller imorgon",
"list-filter-label": "Filtrera lista efter titel",
"filter-clear": "Rensa filter",
+1 -1
View File
@@ -859,7 +859,7 @@
"filter-due-previous-week": "Leswi faneleke ku hela vhiki leri hundzeke",
"filter-due-next-month": "Leswi faneleke ku hela n’hweti leyi taka",
"filter-due-this-week": "fanele mhaka vhiki",
"filter-due-next-week": "fanele mhaka vhiki",
"filter-due-next-week": "fanele vhiki leri taka",
"filter-due-tomorrow": "fanele mundzuku",
"list-filter-label": "sefa nxaxamelo hi nhlokomhaka",
"filter-clear": "basisa sefa",
+4 -4
View File
@@ -3912,7 +3912,7 @@ async function cardMembers(userId, doc, fieldNames, modifier) {
if (modifier.$addToSet && modifier.$addToSet.members) {
memberId = modifier.$addToSet.members;
const user = await ReactiveCache.getUser(memberId);
const username = user.username;
const username = user && user.username;
if (!(doc.members || []).includes(memberId)) {
await Activities.insertAsync({
userId,
@@ -3931,7 +3931,7 @@ async function cardMembers(userId, doc, fieldNames, modifier) {
if (modifier.$pull && modifier.$pull.members) {
memberId = modifier.$pull.members;
const user = await ReactiveCache.getUser(memberId);
const username = user.username;
const username = user && user.username;
// Check that the former member is member of the card
if ((doc.members || []).includes(memberId)) {
await Activities.insertAsync({
@@ -3955,7 +3955,7 @@ async function cardAssignees(userId, doc, fieldNames, modifier) {
if (modifier.$addToSet && modifier.$addToSet.assignees) {
assigneeId = modifier.$addToSet.assignees;
const user = await ReactiveCache.getUser(assigneeId);
const username = user.username;
const username = user && user.username;
if (!(doc.assignees || []).includes(assigneeId)) {
await Activities.insertAsync({
userId,
@@ -3973,7 +3973,7 @@ async function cardAssignees(userId, doc, fieldNames, modifier) {
if (modifier.$pull && modifier.$pull.assignees) {
assigneeId = modifier.$pull.assignees;
const user = await ReactiveCache.getUser(assigneeId);
const username = user.username;
const username = user && user.username;
// Check that the former assignee is assignee of the card
if ((doc.assignees || []).includes(assigneeId)) {
await Activities.insertAsync({
+7
View File
@@ -232,6 +232,13 @@ const CATALOG = {
// added or soft-deleted attachments, through the REST API, on a card that
// was no longer assigned to them. Only a caller with a kept card id reaches it.
'authz.assigned-attachment-write': { category: 'authz', bleed: 'UnassignedAttachBleed', severity: 'medium', cwe: 'CWE-863' },
// 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' },
+7 -3
View File
@@ -352,6 +352,10 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
}
return tagIdByTitle.get(name);
};
// A list's own tag. A list with no title - a card whose list is archived
// is collected with listTitle '' - is "Untitled", as its panel is named,
// so the panel shows its cards and every task's tags exist.
const listTag = title => tagFor(String(title || '').trim() || 'Untitled');
const inProgressTag = () => {
if (!tagState.entities[SP_IN_PROGRESS_TAG_ID]) {
tagState.ids.push(SP_IN_PROGRESS_TAG_ID);
@@ -398,7 +402,7 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
const isDone = kind === 'done' || ended !== undefined;
const tagIds = (Array.isArray(item.labels) ? item.labels : []).map(tagFor).filter(Boolean);
if (kind === 'progress' && !isDone) tagIds.push(inProgressTag());
if (kind === 'other') tagIds.push(tagFor(item.listTitle));
if (kind === 'other') tagIds.push(listTag(item.listTitle));
const notes = [String(item.description || '').trim(), checklistMarkdown(item.checklists)].filter(Boolean).join('\n\n');
const created = msOf(item.createdAt) || now;
const spent = Number(item.spentTime);
@@ -449,7 +453,7 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
});
// One board, one panel per list, filtering the way the list decides.
const otherTags = listTitles.filter(title => listKind(title) === 'other').map(tagFor);
const otherTags = listTitles.filter(title => listKind(title) === 'other').map(listTag);
const panel = (title, index, filter) => ({
id: `wekan-panel-${index + 1}`, title: title || 'Untitled', taskIds: panelTasks.get(title) || [],
includedTagIds: [], excludedTagIds: [], projectIds: [''],
@@ -464,7 +468,7 @@ export function formatSuperProductivity({ board, lists, swimlanes, items } = {},
excludedTagIds: [...(tagState.entities[SP_IN_PROGRESS_TAG_ID] ? [SP_IN_PROGRESS_TAG_ID] : []), ...otherTags],
backlogState: backlogUsed ? 2 : 1,
});
default: return panel(title, index, { includedTagIds: [tagFor(title)] });
default: return panel(title, index, { includedTagIds: [listTag(title)] });
}
});
+433 -1
View File
@@ -14,7 +14,439 @@ Releases per day:
| 11 | 6 |
| 12 | 5 |
| 13 | 1 |
| 14 | 1 |
| 14 | 2 |
# v12.41 2026-10-14 WeKan ® release
**In short:** **custom board roles**, **per-list permissions** and boards
**every signed-in user may edit**; board **keys** (`DEV-7`) and **shared
labels**; WeKan serves **HTTPS** itself; **Bun single files** and **musl
bundles and AppImages** for Alpine; on **Sandstorm**, webhooks and list sync
leave through the Powerbox and the REST API takes a grain API key. Seventeen
locale files that held **another
language**, and six full of machine-fill prefixes, are rewritten in their
own, and fourteen **GitHub CodeQL** alerts are fixed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2352f1f44f">HTML tags are removed until none are left, and no input is spliced into a pattern</a>. Thanks to GitHub CodeQL and xet7.</summary>
- **#570, #571:** an HTML-only email became card text with one pass of each
removal, so a script element spliced together by removing an inner one
survived, and its contents reached the card. Both removals now run until
nothing changes.
- **#574:** the single file's settings writer put the setting name into a
regular expression. A setting must be an environment variable name and is
compared literally, and a value must be one line. The kept-versions reader
now reads file names literally too.
- **#572, #573, #575-#580:** tests escaped only some characters, or matched
example hostnames with unescaped dots; they compare literally or escape
fully now.
`tests/tagStrippingFixedPoint.test.cjs` now also catches the `<[^>]+>`
spelling it missed, which also found the search-index text of Office
documents: never rendered, so recorded as such. `tests/emailToCard.test.cjs`
fails on the old code. Nothing here is an attempt the server refuses at run
time, so no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/167ccf9e2d">An email's HTML becomes text in one pass, with no pattern to take apart</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alerts #581 and #582 were raised on the fix above: email to card
still removed `<script>` elements and tags with regular expressions, looped
to a fixed point through a helper that CodeQL does not follow. `htmlToText`
in `models/lib/emailToCard.js` now walks the characters once. A tag is
dropped whole, a script, style or head element goes with what is in it (also
unclosed), an unfinished tag is not kept as text, and a `<` that does not
start a tag stays text - except right before another `<`, where it would
read as a tag once the next one is gone. `tests/emailToCard.test.cjs` covers
those cases, and `tests/tagStrippingFixedPoint.test.cjs` no longer accepts a
fixed-point helper and now also catches a whole element removed by pattern,
in every file. Nearly every HTML email has a `<style>`, so a removed element
is ordinary use, not an attempt: no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/73fcfb8545">No HTTP response sends the request back, tests included</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alert #583: the HTTPS test's server answered with the request's own
URL in the body, the shape of reflected cross-site scripting. It now names
the page by comparison. `tests/noReflectedRequest.test.cjs` keeps the shape
out of every file. Test code only, so no Admin Panel / Problems key is added.
</details>
and adds the following new features:
**Board roles** - who may do what on a board, and where a membership comes from.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">Custom board roles, built in the Admin Panel from what a member may do</a>. Thanks to xet7.</summary>
In Admin Panel / People / Roles a site admin builds named roles from five
capabilities: sees all cards, comments, edits, moves cards, invites. A board
admin then gives one to a member in the same popup as the built-in roles.
Board admin stays a built-in role. Every check asks the capability table,
which answers from the custom role. The member's built-in flags carry the
strongest built-in role that grants nothing more, so code reading a flag
never grants more, and a deleted role leaves exactly that. This holds for
every combination, which `tests/customBoardRoles.test.cjs` checks.
Invitations, REST reads, imports and member mapping follow it. The comment
form, reactions and chat now ask the comment capability, so a No comments
member is no longer offered reactions the server refuses. The new strings are
translated in all 234 languages, and the Roles Status table gains a Move cards
column. See [Board roles](docs/Features/Members/Roles.md#custom-roles).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">A membership that came through a team or organization leaves with it</a>. Thanks to mfnalex and xet7.</summary>
Joining a team already added its boards; leaving it now takes away the
memberships it gave, and only those. Each records where it came from
(`grantedBy`), so a direct membership is never touched and a board admin is
kept. A board admin's own add or removal is final. This covers user edits,
LDAP, OIDC, the board sidebar, and organizations that propagate their
members. `tests/groupBoardMembership.test.cjs` covers the decisions, and a
runtime test joins, leaves and rejoins a team against a real database.
</details>
**Outgoing requests** - webhooks and downloads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/78957e14cb">Outgoing webhooks go through an HTTP proxy, with the SSRF checks kept</a>. Thanks to gbarre and xet7.</summary>
`HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` work for every outgoing request
(on the snap: `http-proxy`, `https-proxy`, `no-proxy`). The destination is
still resolved and checked first, and the proxy is given that checked
address, never the name: an `http://` request goes in absolute form, an
`https://` one through a `CONNECT` tunnel, with TLS still checked against the
real name. A destination the guard refuses never reaches the proxy.
`tests/outboundProxy.test.cjs` runs a real proxy on loopback. See
[Proxy](docs/Features/Webhooks/Proxy.md).
</details>
**Who may edit where** - boards open to every signed-in user, and lists with
rules of their own.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">A board can let every signed-in user edit it, when the Admin Panel allows it</a>. Thanks to Meeques and xet7.</summary>
Admin Panel / Settings / Visibility gains "Allow boards that every signed-in
user may edit", off by default. With it on, a board admin of a public board
or one open to every signed-in user can turn it on for that board in the
visibility popup. A signed-in non-member may then comment, edit and move
cards like a Normal member - never change the board - and their first edit
makes them a Normal member, so every change stays attributed. A member keeps
their own role, a member a board admin removed stays removed, and rules,
webhooks, inbound email and list sync never act with a non-member's rights.
Board-wide settings such as list width and sticky headers stay members-only.
`tests/instanceEditing.test.cjs` and a runtime test cover it. See
[Visibility](docs/Features/Admin-Panel/Settings/Visibility.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Lists can allow or refuse editing and commenting per role</a>. Thanks to Meeques and xet7.</summary>
The list menu gains List permissions, for board admins: for each role -
built-in, custom, and on an open board everyone signed in - Edit and Comment
are as on the board, allowed, or not allowed in this list. The issue's
example works: a read-only board with one list where everybody may add cards
and comment. A board admin is never restricted, and a removed member stays
removed. The server applies it to every card, checklist, attachment and
comment write and to moves into the list, and the REST API honours a list's
refusal on every per-list card route. Only the board admin method writes the
setting; a direct write is refused and shown in Admin Panel / Problems.
`tests/listPermissions.test.cjs` and a runtime test cover it. See
[List permissions](docs/Features/Board/List-Permissions.md).
</details>
**Boards and cards** - keys, card numbers and labels shared between boards.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Every board has a key, and card numbers show as KEY-7</a>. Thanks to justinr1234 and xet7.</summary>
A key is 2-10 capital letters and digits, unique in the instance, suggested
from the board's title (Development Team: DT) and set for every existing
board at startup. A board admin can change it in the rename popup. Cards show
`DEV-7` instead of `#7`, and searching for `DEV-7` finds that card. The
per-board numbers themselves are unchanged, so nothing is renumbered.
`tests/boardKey.test.cjs` and a runtime test cover it. See
[Board key](docs/Features/Board/Boards/Board-Key.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Shared labels for every board, an organization's boards or a team's</a>. Thanks to tiFabGrinch, ejlkemper and xet7.</summary>
Admin Panel / Labels keeps labels shared by every board, or by the boards of
one organization or team. A board offers them in its labels popup beside its
own, and using one copies it to the board linked to the shared label, so
renaming it or changing its colour in the Admin Panel changes it everywhere
it is used. Deleting a shared label leaves it on those boards as their own.
Only labels a member's own organizations and teams share are sent to them.
`tests/sharedLabels.test.cjs` and a runtime test cover it. See
[Shared labels](docs/Features/Board/Shared-Labels.md).
</details>
**Servers** - running WeKan without a proxy in front of it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1bf7a62cab">WeKan serves HTTPS itself, when a certificate is configured</a>. Thanks to 68040 and xet7.</summary>
With `HTTPS_PORT`, `HTTPS_CERT_FILE` and `HTTPS_KEY_FILE` set (a path or
the PEM itself), WeKan also listens for HTTPS, on the same app, and
`HTTPS_REDIRECT=true` sends plain-HTTP requests there. Without them nothing
changes, and a proxy such as Caddy stays the recommendation for production.
The snap has `https-port`, `https-cert-file`, `https-key-file` and
`https-redirect`. `tests/httpsServer.test.cjs` makes a real TLS connection.
See [HTTPS without a proxy](docs/Platforms/Webserver/HTTPS-without-proxy.md).
</details>
**Platforms** - more ways to run WeKan.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b1e58094f4">Bun single files: one executable per Bun platform, musl included</a>. Thanks to xet7.</summary>
Each Bun bundle also comes as one executable:
`wekan-<version>-bun-<target>-single-file` for Linux x64 and arm64 (glibc
and musl, so Alpine too), macOS and Windows. It is the published bundle ZIP,
unchanged, inside that bundle's own Bun, built on the platform itself, so the
runtime is the one the bundle was tested with. The first start unpacks the
bundle once into a cache directory, without a second Bun, and every start
runs it with the executable itself as Bun. Like the Bun bundle it needs
`MONGO_URL` and `ROOT_URL`. Its ZIP reader refuses any entry or link that
would land outside the cache directory. Before it is published each file is
checked: it names itself, refuses to start without a database, unpacks once
and loads every native addon of the bundle. `tests/bunSingleFile.test.cjs`
covers the reader and its refusals. See
[Single file](docs/Platforms/Single-File.md#bun-single-files).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a1d2994c1c">musl bundles and AppImages for Alpine and the other musl systems</a>. Thanks to xet7.</summary>
Every release also has `wekan-<version>-linux-x64-musl.zip` and
`-linux-arm64-musl.zip`, with FerretDB, and the AppImages
`WeKan-<version>-x86_64-musl` and `-aarch64-musl`. Each is the release's
Linux bundle rebuilt inside Alpine on a runner of its CPU: the musl Node.js of
the same version, checked against its SHA256, and every native addon
compiled against musl. Before it is published, each addon's architecture is
checked, sharp must load, and the bundle must start and answer HTTP 200; the
AppImages are started inside Alpine too. `tests/muslBundles.test.cjs`
covers the build and the workflows. See [Alpine](docs/Platforms/FOSS/OS/Alpine.md).
</details>
**Sandstorm** - reaching out of a grain, and into it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">Outgoing webhooks leave a grain through the Powerbox</a>. Thanks to xet7.</summary>
A grain has no network but the bridge proxy, so a webhook was never sent.
Each saved webhook now offers "Allow this grain to reach this address", which
opens the Powerbox for exactly that address; once approved, the webhook goes
through the bridge with that grant. The grant belongs to the destination, not
to a person, and is approved as the address's parent path so the bridge
delivers to the exact address. Off Sandstorm nothing changes: every sender
still goes through the SSRF guard. `tests/sandstormWebhookEgress.test.cjs`
runs it against a fake bridge proxy. See
[Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#outgoing-webhooks).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">The REST API works with a Sandstorm API key</a>. Thanks to xet7.</summary>
A Sandstorm API key keeps the Authorization header for itself, so WeKan saw
no user. In a grain the bridge says who is calling, and WeKan now acts as
that user - only in a grain, only for an account that exists and is enabled,
and a key without the participate permission reads only. Off Sandstorm the
`X-Sandstorm-*` headers are never believed.
`tests/sandstormApiIdentity.test.cjs`
covers both sides.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d53b4da799">List sync from GitHub, GitLab, Gitea and Forgejo works in a grain</a>. Thanks to xet7.</summary>
A board member who may write saves a list's sync source and allows the grain
to reach that tracker's API in the Powerbox; the periodic sync then goes
through the bridge with that grant, which belongs to the tracker, so no one
needs to be signed in. GitLab's token is sent as `private_token`, because the
bridge drops `PRIVATE-TOKEN`, so private GitLab projects sync too; GitHub,
Gitea and Forgejo sync public repositories, whose token headers cannot pass
the bridge. Jira signs in only with an `Authorization` header, which the
bridge replaces, so it is refused in a grain with that reason. Off Sandstorm
nothing changes. `tests/sandstormSyncEgress.test.cjs` runs it against a fake
bridge proxy. See [Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#list-sync).
</details>
and closes the following already-done feature request:
- [Each member chooses how often email notifications come: never, periodically or instantly](https://github.com/wekan/wekan/commit/3f88fb39a5).
Thanks to saurabharch and xet7.
and fixes the following bugs:
**Lists and swimlanes** - what goes with a list, and where it is shown.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a3797dbf6">Archiving a list archives its cards, and restoring it brings them back</a>. Thanks to Raymond-Voogt and xet7.</summary>
Only template lists archived their cards, so archiving any other list left
its cards visible in the Lists, Table and Calendar views. The server now
archives the cards of every list with it, whichever way it is archived - the
list menu, the Archive sidebar, the REST API - and restoring the list
restores exactly those cards, the rule #2292 gave swimlanes. A card that was
archived on its own stays archived. Cards archived with the list get the
list's own archive time, so a browser clock that differs from the server's
cannot keep them from coming back. `tests/archiveListCards.test.cjs` and a
runtime test cover it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ebf2a019a6">Lists missing their swimlane counts only lists that lost it, and Restore never hides cards</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Problems counted every board-wide list whose history recorded
the swimlane it was created in, so lists made since the automatic
un-binding stopped (#6515, in v10.31) were counted too, and Restore would
have bound them there, hiding the cards they hold in other swimlanes. Now a
list created after v10.31 is never offered, and a list with any card in
another swimlane is never restored. A list that really lost its binding is
still restored. `tests/listSwimlaneRestore.test.cjs` and a runtime test
cover both rules.
</details>
**Board roles** - helpers that read a role.
- [Five role helpers that answered the same for everybody: Import never offered, rule controls for every member, the last admin not protected, OIDC default-board roles](https://github.com/wekan/wekan/commit/548834c4ee).
Thanks to xet7.
and fixes the following build bug:
- [The OpenBSD bundles are built on OpenBSD 7.8, where Node.js 26 now builds](https://github.com/wekan/wekan/commit/209e3b0c47).
Thanks to xet7.
and updates the following documentation:
- [Outgoing webhooks to Node-RED, and why an internal address is refused](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to yeurplc and xet7.
- [Uberspace 7: install, the update script, and email on one environment line](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to jeyca and xet7.
and updates the following translations:
**Languages that held another language** - each locale file now says what its
tag says.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f3c92273ae">Hong Kong Chinese in Traditional characters, and Flemish in Dutch</a>. Thanks to xet7.</summary>
Hong Kong Chinese had 90 values in Simplified Chinese; they are now
Traditional. Flemish held 158 values in about a dozen other languages, Venda
and Vietnamese among them, and they are now Flemish, with its 225 values
still in English filled too.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ba6d0014f7">Locale files that held another language are rewritten in their own</a>. Thanks to xet7.</summary>
Romanian held Italian, French and Spanish; Aromanian, Ladin, Romansh and
Venetian held Italian; Galician held Portuguese, Acehnese Malay, Venda Zulu,
Slovak Czech and Polish, Upper Sorbian Czech, Volapük Esperanto and Bislama
Tok Pisin, and Uzbek in Arabic script was half Latin script. Each is now in
its own language. Machine-fill prefixes and notes ("Faka-Tonga:", "Kay
willaymi:", "Wolayttatto:", "WeKan:" and others) are gone from Tongan,
Quechua, Wolaytta, Upper Sorbian, Aragonese, Friulian and Luganda. Chinese
no longer calls a board a wooden plank, and List actions no longer reads as
"list the actions" in seven languages. Values already reviewed - pinned by a
test, corrected by an audit, or quoted by another string - are kept.
`tests/wrongLanguageLocales.test.cjs` keeps the old languages and prefixes
out and checks every placeholder against English. Native speakers are asked
to review these languages.
</details>
**Igbo** - notification, integration, workflow and recovery settings.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/238354f6a54a234c9db1cf88a9436d15087c6ba0">Translate Igbo notification delivery and integration settings</a>. Thanks to xet7.</summary>
Translate 42 notification and integration strings, preserving grouping,
schedules, quiet hours, inherited webhook settings, identity omission and
literal link-rule syntax. Direct technical wording needs fluent review.
Both translation and guarded-correction suites pass with 30,943 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One notification browser case passes syntax and discovery; execution is pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/daac3ead0dceb451bdb69970cc5b19678d17ce6d">Translate Igbo workflow and permission controls</a>. Thanks to xet7.</summary>
Translate 19 workflow, directory-sync and permission messages, preserving
Wrike status behavior, environment identifiers and subtask completion choices.
Technical prose is lower confidence and needs fluent review.
Both translation and guarded-correction suites pass with 30,962 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
Three browser cases pass syntax and discovery; execution remains pending.
Igbo has 69 detected placeholders left. All-language semantic review is
unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c5391fb98d3619308aa2ba3328f47db870e5655">Translate Igbo stopped list sync recovery messages</a>. Thanks to xet7.</summary>
Translate 18 recovery messages, preserving applied changes, discarded pending
writes, replay restrictions and the oldest-50 display limit. Recovery prose
needs fluent review.
Both translation and guarded-correction suites pass with 30,980 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One browser case passes syntax and discovery; execution remains pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8c25ce78b94d65d3a7fd4540bdb065443a6c6dbd">Translate Igbo interrupted import recovery messages</a>. Thanks to xet7.</summary>
Translate 21 messages, preserving permanent deletion, retained partial boards,
source-file limitations, untouched foreign boards and recovery limits.
Direct recovery prose needs fluent review.
Both translation and guarded-correction suites pass with 31,001 corrections.
All 21 human-preference checks pass; existing Chinese values are unchanged
(38,430 checked). One browser case passes syntax and discovery; execution is
pending. All-language translation and semantic verification remain unfinished.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.40 2026-10-14 WeKan ® release
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v13.01.0",
"version": "v13.06.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v13.01.0",
"version": "v13.06.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -80,7 +80,7 @@
"yjs": "13.6.33"
},
"devDependencies": {
"@meteorjs/rspack": "^3.0.0-rc.2",
"@meteorjs/rspack": "^3.0.0",
"@rsdoctor/rspack-plugin": "^1.6.4",
"@rspack/cli": "^2.2.9",
"@rspack/core": "^2.2.9",
@@ -2973,9 +2973,9 @@
}
},
"node_modules/@meteorjs/rspack": {
"version": "3.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@meteorjs/rspack/-/rspack-3.0.0-rc.2.tgz",
"integrity": "sha512-0YwVxT5X7dBSrBOoQTXWdiWStnAHg7IYIs229TBxVifCfEHulxsGkuV+OIgz1arZWUQACOyZxQIUdS3aR4fK8w==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@meteorjs/rspack/-/rspack-3.0.0.tgz",
"integrity": "sha512-INccdjjsdxUzwgeColyJY3pNBO2h3auag7XikHmuNKDiNqKbt5pIP2Ky1+10NoiiLZIQo8EL17zsGoCinvM2TQ==",
"dev": true,
"license": "ISC",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v13.01.0",
"version": "v13.06.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -38,7 +38,7 @@
"postinstall": "node scripts/patch-yargs-dirname.cjs && node scripts/patch-pdfkit-entry.cjs && node scripts/patch-meteor-node-stubs-pbkdf2.cjs"
},
"devDependencies": {
"@meteorjs/rspack": "^3.0.0-rc.2",
"@meteorjs/rspack": "^3.0.0",
"@rsdoctor/rspack-plugin": "^1.6.4",
"@rspack/cli": "^2.2.9",
"@rspack/core": "^2.2.9",
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
name: 'allow-deny',
version: '2.1.2-rc360.1_1',
version: '2.1.2_1',
// Brief, one-line summary of the package.
summary: 'Implements functionality for allow/deny and client-side db operations',
// URL to the Git repository containing the source code for this package.
+2 -2
View File
@@ -1,10 +1,10 @@
Package.describe({
summary: "Meteor's latency-compensated distributed data server",
version: '3.6.0-rc360.1_1',
version: '3.6.0_1',
documentation: null,
});
// WeKan fork of Meteor 3.6-rc.1's ddp-server, for the Admin Panel / Problems
// WeKan fork of Meteor 3.6's ddp-server, for the Admin Panel / Problems
// report of a refused DDP message. uWebSockets.js is left out, as Meteor's
// published 3.4.1-rc360.0 isopack had no copy of it either: it is only loaded
// for the opt-in "uws" transport, and it would be fetched from a git URL at
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
name: "dynamic-import",
version: '0.8.1-rc360.1_1',
version: '0.8.1_1',
summary: "Runtime support for Meteor 1.5 dynamic import(...) syntax",
documentation: "README.md"
});
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
summary: "Facebook OAuth flow",
version: '1.12.1-rc360.1_1',
version: '1.12.1_1',
});
Package.onUse(api => {
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
summary: "Google OAuth flow",
version: '1.5.1-rc360.1_1',
version: '1.5.1_1',
});
Cordova.depends({
+1 -1
View File
@@ -1,6 +1,6 @@
Package.describe({
name: "shell-server",
version: '0.7.2-rc360.1_1',
version: '0.7.2_1',
summary: "Server-side component of the `meteor shell` command.",
documentation: "README.md",
devOnly: true,
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v13.01</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.01","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.06","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: WeKan REST API
version: v13.01
version: v13.06
description: |
The REST API allows you to control and extend WeKan with ease.
+6 -2
View File
@@ -61,6 +61,10 @@ npm_cli="$(dirname "$(readlink -f "$(command -v npm)")")/npm-cli.js"
[ -f "$npm_cli" ] || npm_cli="$PREFIX/lib/node_modules/npm/bin/npm-cli.js"
npm() { "$node" "$npm_cli" "$@"; }
export -f npm 2>/dev/null || true
# npm-retry.sh is a new bash: it is given the command itself, "$node"
# "$npm_cli", never the npm function above. The function reaches it through
# export -f, but $node and $npm_cli do not, and under its set -u v13.04's
# Android bundles all died there with "environment: node: unbound variable".
server="$root/bundle/programs/server"
appmods="$server/npm/node_modules"
@@ -76,7 +80,7 @@ fi
# have no Android prebuild, were never compiled, and WeKan did not start. It
# is idempotent, so a bundle already prepared is left as it is.
"$node" "$root/releases/prepare-bundle-npm.mjs" "$root/bundle"
(cd "$server" && 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"
+3 -2
View File
@@ -55,10 +55,11 @@ against its assets: new or removed OS/CPU targets fail planning visibly.
Meteor's Bun/ESM support is still proposed in
[meteor/meteor#14751](https://github.com/meteor/meteor/pull/14751).
The workflow clones the app's exact Meteor release into `.tools/meteor-bun`.
For Meteor 3.6-rc.1 it applies `meteor-3.6-rc.1.patch`, ported from
For Meteor 3.6 it applies `meteor-3.6.patch`, ported from
`dupontbertrand/meteor` commit `c0ab9318822f36f902da6124c1356ede58b71a4e` onto
Meteor 3.6-rc.0 (`cc8cf7d21d24da5f4a4fd456527225bc5ce0e721`); it applies
unchanged to 3.6-rc.1 (`311f5cd2d26f74ce187dc2e639bd57aab81929e4`).
unchanged to 3.6-rc.1 (`311f5cd2d26f74ce187dc2e639bd57aab81929e4`) and to 3.6
(`e730097ce42a0dbc4c134aeb1706147b02112844`).
The port preserves the 3.6 transport documentation and Rspack's await of the
server bundle's exported promise. It does not downgrade package pins, change
File renamed without changes.
+4 -4
View File
@@ -12,9 +12,9 @@ meteor_dir="$root/.tools/meteor-bun"
git clone --depth 1 --branch "release/METEOR@$release" https://github.com/meteor/meteor.git "$meteor_dir"
if [ ! -f "$meteor_dir/tools/static-assets/server/esm-loader.mjs" ]; then
# Do not silently downgrade core packages to the PR's older 3.5 base.
[ "$release" = 3.6-rc.1 ] || { echo "Port the Bun patch to Meteor $release before building"; exit 1; }
[ "$(git -C "$meteor_dir" rev-parse HEAD)" = 311f5cd2d26f74ce187dc2e639bd57aab81929e4 ] || exit 1
git -C "$meteor_dir" apply --check "$root/releases/bun/meteor-3.6-rc.1.patch"
git -C "$meteor_dir" apply "$root/releases/bun/meteor-3.6-rc.1.patch"
[ "$release" = 3.6 ] || { echo "Port the Bun patch to Meteor $release before building"; exit 1; }
[ "$(git -C "$meteor_dir" rev-parse HEAD)" = e730097ce42a0dbc4c134aeb1706147b02112844 ] || exit 1
git -C "$meteor_dir" apply --check "$root/releases/bun/meteor-3.6.patch"
git -C "$meteor_dir" apply "$root/releases/bun/meteor-3.6.patch"
fi
printf '%s\n' "$meteor_dir" >> "$GITHUB_PATH"
+3 -1
View File
@@ -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
+72 -19
View File
@@ -1,29 +1,64 @@
#!/usr/bin/env python3
"""Recover stale .deb URLs in Snapcraft's managed base or build instance."""
"""Recover stale APT state in Snapcraft's managed base or build instance.
Two failures are recoverable, and nothing else is:
- a .deb that 404s because the instance's package indexes are stale;
- "Failed to refresh package list: failed to run apt update" while installing
build-packages, which is the Ubuntu archive being briefly unreachable or
mid-sync (v13.02: every amd64 snap failed within eight minutes while the
arm64 ones, served by ports.ubuntu.com, built). That one is retried with a
backoff until apt-get update succeeds in the same instance, so the Snapcraft
retry only runs once the archive answers again.
"""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import time
APT_UPDATE_FAILED = 'Failed to refresh package list: failed to run apt update'
# Waits before each further apt-get update attempt for an archive outage.
UPDATE_BACKOFF = (30, 60, 120, 240)
APT_DIAGNOSTIC = re.compile(r'(?:^|\s)(?:E:|W:|Err:)|Hash Sum mismatch|Failed to fetch|Could not resolve|'
r'Temporary failure|Connection failed|Unable to connect')
def apt_diagnostics(text, limit=20):
"""The apt error lines from a Snapcraft log, so the job says WHY apt failed."""
text = text.replace('\\n', '\n')
lines = [line.strip() for line in text.splitlines() if APT_DIAGNOSTIC.search(line)]
return list(dict.fromkeys(lines))[-limit:]
def project_instance(text):
# craft-providers logs each LXC command with shlex.join. Build and stage
# packages are handled in the project instance, after base provisioning
# finishes. Never fall back to the base here: changing it cannot repair its clone.
if 'Failed to run snapcraft in instance' not in text:
raise ValueError('Package failure is not from a failed managed Snapcraft build')
instances = set(re.findall(
r'lxc --project snapcraft exec local:(snapcraft-[a-zA-Z0-9-]+) ', text))
if len(instances) != 1:
raise ValueError('Cannot identify one failed Snapcraft build instance in the log')
return instances.pop()
def is_update_failure(text):
return APT_UPDATE_FAILED in text.replace('\\n', '\n')
def failed_instance(text):
text = text.replace('\\n', '\n')
if is_update_failure(text):
return project_instance(text)
if not re.search(
r'Failed to fetch[^\n]*\.deb[^\n]*(?:\n[^\n]*)?404', text):
raise ValueError('Not a managed APT package 404; refusing to retry this build failure')
raise ValueError('Not a managed APT package 404 or apt update failure; '
'refusing to retry this build failure')
if 'Failed to fetch package:' in text:
if 'Failed to run snapcraft in instance' not in text:
raise ValueError('Stage-package 404 is not from a failed managed Snapcraft build')
# craft-providers logs each LXC command with shlex.join. Stage packages
# are fetched in the project instance, after base provisioning finishes.
# Never fall back to the base here: changing it cannot repair its clone.
instances = set(re.findall(
r'lxc --project snapcraft exec local:(snapcraft-[a-zA-Z0-9-]+) ', text))
if len(instances) != 1:
raise ValueError('Cannot identify one failed Snapcraft build instance in the log')
return instances.pop()
return project_instance(text)
instances = re.findall(
r'lxc --project snapcraft exec local:(base-instance-snapcraft-[a-zA-Z0-9-]+) '
r'[^\n]*apt-get install', text) if 'Failed to install packages' in text else []
@@ -32,8 +67,9 @@ def failed_instance(text):
return instances[-1]
def refresh(text):
def refresh(text, sleep=time.sleep):
name = failed_instance(text)
waits = UPDATE_BACKOFF if is_update_failure(text) else ()
prefix = ['lxc', '--project', 'snapcraft']
def run(args):
return subprocess.run(prefix + args, check=True, text=True,
@@ -50,11 +86,24 @@ def refresh(text):
try:
# craft-parts uses /var/lib/apt for stage-package indexes as well.
# Refresh them in the guest; downloaded packages and part state stay intact.
print('Refreshing APT indexes in failed Snapcraft instance ' + name, flush=True)
result = run(['exec', 'local:' + name, '--', 'env', 'DEBIAN_FRONTEND=noninteractive',
'apt-get', '-o', 'Acquire::Retries=3',
'-o', 'APT::Update::Error-Mode=any', 'update'])
print(result.stdout, end='')
update = ['exec', 'local:' + name, '--', 'env', 'DEBIAN_FRONTEND=noninteractive',
'apt-get', '-o', 'Acquire::Retries=3',
'-o', 'APT::Update::Error-Mode=any', 'update']
for attempt, wait in enumerate((0,) + tuple(waits), 1):
if wait:
print(f'apt-get update still failing; waiting {wait}s for the archive '
f'(attempt {attempt}/{len(waits) + 1}) ...', flush=True)
sleep(wait)
print('Refreshing APT indexes in failed Snapcraft instance ' + name, flush=True)
try:
result = run(update)
except subprocess.CalledProcessError as error:
if attempt > len(waits):
raise
print((error.stdout or '').rstrip(), flush=True)
continue
print(result.stdout, end='')
break
finally:
if stopped:
run(['stop', 'local:' + name, '--timeout', '30'])
@@ -73,6 +122,10 @@ if __name__ == '__main__':
raise ValueError('No Snapcraft execution log found; original failure remains fatal')
log = max(logs, key=lambda item: item.stat().st_mtime_ns)
print('Inspecting ' + str(log), flush=True)
refresh(log.read_text(encoding='utf-8', errors='replace'))
text = log.read_text(encoding='utf-8', errors='replace')
diagnostics = apt_diagnostics(text)
if diagnostics:
print('APT reported:\n ' + '\n '.join(diagnostics), flush=True)
refresh(text)
except (ValueError, OSError, subprocess.SubprocessError) as error:
parser.exit(1, str(error) + '\n')
+2 -2
View File
@@ -38,9 +38,9 @@ check() {
echo "=== MongoDB 7.0.14 (amd64 and arm64 native; amd64 also used via QEMU for armhf/s390x/ppc64el) ==="
check "MongoDB 7.0.14 amd64 (x86_64, ubuntu2204)" \
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.45.tgz"
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.48.tgz"
check "MongoDB 7.0.14 arm64 (aarch64, ubuntu2204)" \
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.45.tgz"
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.48.tgz"
echo " NOTE MongoDB Community has no prebuilt builds for s390x, ppc64le, or arm/v7."
echo " The amd64 binary above is also used on those arches via qemu-x86_64-static."
echo ""
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1301,
appVersion = 1306,
# Increment this for every release.
appMarketingVersion = (defaultText = "13.01~2026-11-07"),
appMarketingVersion = (defaultText = "13.06~2026-11-10"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+11 -1
View File
@@ -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;
+28
View File
@@ -59,6 +59,10 @@
// rejected by FerretDB ("infinity values are not
// allowed"), aborting MongoDB->FerretDB migration ->
// reset to a finite 0
// card-overtime-field #6888: REST PUT stored isOverTime, which nothing
// reads; the schema and UI field is isOvertime ->
// folded into isOvertime where that is unset, and
// the stray field removed
// card-children-boardid #6793: comments/reactions/text notes/attachments
// still carrying the board their card was moved
// AWAY from -> the card's current board id
@@ -863,6 +867,30 @@ const steps = [
},
},
{
// card-overtime-field (#6888): the REST card PUT stored its isOverTime
// parameter under that spelling (b895e61237 until #6888), while the
// schema, the UI and every reader use isOvertime - so an overtime set
// through the API answered 200 and was seen nowhere. #6888 writes the
// right field from now on; this carries over what was written before.
// Where a card has both, isOvertime - the one shown - is kept. Two
// updateMany batches and an $unset; idempotent, nothing left after.
name: 'card-overtime-field',
async check(db) {
return !!(await db.collection('cards').findOne({ isOverTime: { $exists: true } }, { projection: { _id: 1 } }));
},
async run(db) {
const cards = db.collection('cards');
let fixed = 0;
for (const value of [true, false]) {
const r = await cards.updateMany({ isOverTime: value, isOvertime: { $exists: false } }, { $set: { isOvertime: value } });
fixed += (r && r.modifiedCount) || 0;
}
await cards.updateMany({ isOverTime: { $exists: true } }, { $unset: { isOverTime: '' } });
return { fixed, unresolved: 0 };
},
},
{
// checklist-minicard-unset: `showChecklistAtMinicard: false` written by the
// old schema default, which now means something it did not mean then.
+40
View File
@@ -0,0 +1,40 @@
// The card date a Cards.before.update records as its a-<field> activity:
// the update's first changed field, when it is one of the four dates.
//
// A date SET carries timeValue; a date CLEARED (unsetDue/unsetStart/unsetEnd/
// unsetReceived send only a $unset, or a null value) carries timeOldValue
// alone - the same record a rule's removeDate writes
// (server/lib/syncRuleCardCommand.js), and the one models/lib/boardTimeline.js
// rewinds a card's dates through. The hook used to read modifier.$set[field]
// whatever the update was, so a $unset alone threw a TypeError inside it and,
// with a $set beside it, wrote timeValue: undefined.
//
// A cleared date is not "a card's date is set or changed" (#2474,
// server/triggersDef.js): isTimingClear() lets the rules skip it.
export const TIMING_FIELDS = ['receivedAt', 'dueAt', 'startAt', 'endAt'];
const has = (o, k) => !!o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
// { field, set: true, value } for a date set, { field, set: false } for a
// date cleared, or null when the update is not a change of a date.
export function timingChange(fieldNames, modifier) {
const fields = (Array.isArray(fieldNames) ? fieldNames : []).filter(name => name !== 'dateLastActivity');
const field = fields[0];
if (!TIMING_FIELDS.includes(field) || !modifier) return null;
const set = modifier.$set;
if (has(set, field)) {
const value = set[field];
if (value === null || value === undefined || value === '') return { field, set: false };
return { field, set: true, value };
}
if (has(modifier.$unset, field)) return { field, set: false };
return null;
}
// Is this activity a card date cleared - an a-<date> without a timeValue?
export function isTimingClear(activity) {
if (!activity || typeof activity.activityType !== 'string') return false;
const field = activity.activityType.replace(/^a-/, '');
return activity.activityType.startsWith('a-') && TIMING_FIELDS.includes(field) &&
(activity.timeValue === undefined || activity.timeValue === null || activity.timeValue === '');
}
+10
View File
@@ -627,6 +627,16 @@ Meteor.methods({
// so it is auditable rather than silent.
// #4178: a member the team brings carries the board's active teams they
// belong to, so leaving those teams takes the membership away again.
// Add every member of an assigned active team, since the browser of a non-site-admin cannot see them
const assignedTeamIds = boardTeamsArray.filter(t => t.isActive !== false).map(t => t.teamId);
if (assignedTeamIds.length > 0) {
const teamUsers = await Meteor.users.find({ 'teams.teamId': { $in: assignedTeamIds } }, { fields: { _id: 1 } }).fetchAsync();
for (const teamUser of teamUsers) {
if (!membersArray.some(m => m.userId === teamUser._id)) {
membersArray.push({ userId: teamUser._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false });
}
}
}
const activeTeamIds = boardTeamsArray.filter(t => t.isActive).map(t => t.teamId);
const newIds = membersArray.map(m => m.userId).filter(id => !board.members.some(m => m && m.userId === id));
const teamsOfNew = new Map();
+40 -7
View File
@@ -17,6 +17,7 @@ const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency'
const { stampCardListEntry } = require('/models/lib/cardListEntry');
import { titleChanged } from '/server/lib/titleChangeActivity';
import { descriptionChanged } from '/server/lib/descriptionChangeActivity';
import { timingChange } from '/server/lib/timingChangeActivity';
const { collectionWriteSucceeded } = require('/server/lib/collectionWriteOutcome');
const { deferSyncRecording, deferSyncLabelActivities } = require('/server/lib/syncRecordingScope');
const { movedScrumMetadata } = require('/models/lib/scrumCopy');
@@ -72,6 +73,18 @@ function getTranslatedString(key, fallback, options) {
// route used to check only READ access on the source board and clone the whole
// source card, so a read-only or assigned-only member could link - and copy -
// cards the method refuses them.
// GHSA-6xqf-jh8x-4wxc: an assigned-only member's write on a card not assigned
// to them. No view shows them such a card, so it is an attempt: recorded in
// Admin Panel -> Problems as UnassignedArchiveBleed (medium: never disables).
function recordUnassignedCardWrite(userId, source) {
try {
require('/server/lib/securityLog').record({
key: 'authz.assigned-card-write', action: 'blocked', source, userId,
detail: 'Assigned-only member changed a card not assigned to them.',
});
} catch (e) { /* logging must never break the guard */ }
}
async function createLinkedCardFor(userId, sourceCardId, boardId, swimlaneId, listId, sort, source) {
check(sourceCardId, String);
check(boardId, String);
@@ -162,6 +175,13 @@ Meteor.methods({
if (!sourceBoard || !allowIsBoardMemberWithWriteAccess(this.userId, sourceBoard)) {
throw new Meteor.Error('not-authorized');
}
// GHSA-6xqf-jh8x-4wxc: the board's write capability is not enough. An
// assigned-only member converts only a card assigned to them, as copyCard
// allows them only theirs (models/lib/boardCardScope.js).
if (!require('/models/lib/boardCardScope').assignedOnlyAllowsCard(sourceBoard, this.userId, card)) {
recordUnassignedCardWrite(this.userId, 'method:createBoardFromCard');
throw new Meteor.Error('not-authorized');
}
// CreateBleed: the card becomes a new working board.
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
await assertMayCreateBoard(this.userId, { source: 'method:createBoardFromCard', type: 'board' });
@@ -252,6 +272,16 @@ Meteor.methods({
throw new Meteor.Error('invalid-card-selection');
}
// GHSA-6xqf-jh8x-4wxc: the board's write capability is not enough. An
// assigned-only member archives only cards assigned to them - every card
// of the selection is checked before the first is archived, so a refused
// selection changes nothing.
const { assignedOnlyAllowsCard } = require('/models/lib/boardCardScope');
if (!cards.every(card => assignedOnlyAllowsCard(board, this.userId, card))) {
recordUnassignedCardWrite(this.userId, 'method:archiveSelectedCards');
throw new Meteor.Error('not-authorized');
}
// Validate the complete selection before changing the first card. Preserve
// the board order used by Multi-Selection for deterministic activity order.
const byId = new Map(cards.map(card => [card._id, card]));
@@ -1235,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);
@@ -1267,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,
@@ -1956,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;
}
+7
View File
@@ -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, {
+7
View File
@@ -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,
+5 -1
View File
@@ -2549,7 +2549,11 @@ Users.after.insert(async (userId, doc) => {
}
const disableRegistration = (await ReactiveCache.getCurrentSetting()).disableRegistration;
if (doc.authenticationMethod !== 'ldap' && disableRegistration) {
// TrueOG: an account created by the OIDC service (services.oidc, which only the server-side OIDC login sets)
// needs no invitation code: the identity provider (Staff-Auth) decides who gets in. Without this, with
// registration disabled, a first OIDC sign-in created the account and then failed with
// error-invitation-code-not-exist. Password registration stays closed.
if (doc.authenticationMethod !== 'ldap' && disableRegistration && !doc.services?.oidc) {
let invitationCode = null;
// #6620: authenticationMethod is only set for oauth2/ldap signups (see
// ATCreateUserServer/enrollOrLoginOidcUser above) - a normal
+3
View File
@@ -27,6 +27,7 @@ import { relativeDateOffset } from '/models/lib/relativeDateOffset';
import { resolveRuleSwimlaneId, resolveRuleListId } from '/models/lib/ruleActionResolve';
import { cardTitleMatchList, cardTitleFilterMatches } from '/models/lib/ruleCardTitleFilter';
import { triggerMatchesWithVars } from '/models/lib/ruleTriggerVars';
import { isTimingClear } from '/server/lib/timingChangeActivity';
import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
import { tripCanary } from '/server/lib/canary';
import { substituteVars, recipientVars } from '/models/lib/ruleVarsSubstitute';
@@ -424,6 +425,8 @@ export const RulesHelper = {
return { activity: { ...activity, boardId: card.boardId }, followedFrom: activity.boardId };
},
async findMatchingRules(activity) {
// A card date cleared is not "a card's date is set or changed" (#2474).
if (isTimingClear(activity)) return [];
const activityType = activity.activityType;
const matchingRules = [];
if (TriggersDef[activityType] !== undefined) {
+6 -6
View File
@@ -7,7 +7,7 @@
# FOUR channels — stable, candidate, beta and edge. Every snap job does exactly
# that (snap-native, snap-launchpad and snap-variants all publish
# `stable,candidate,beta,edge`), for the default snap and for the wekan-ondra and
# wekan-gantt-gpl variants alike. It ships the newest WeKan (Meteor 3.6-rc.1,
# wekan-gantt-gpl variants alike. It ships the newest WeKan (Meteor 3.6,
# Node.js 26, FerretDB v1, MongoDB 7, Caddy 2).
#
# snapcraft-core26.yaml is the SAME WeKan on `base: core26`, kept for testing the
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '13.01'
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.01-${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.01/wekan-13.01-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.01/${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}"
+5 -3
View File
@@ -37,7 +37,7 @@ test('the bundle is rebuilt by the Android Node.js itself, checked and started',
// happened in programs/server/node_modules, where sharp is not.
assert.match(build, /appmods="\$server\/npm\/node_modules"\nrm -rf "\$appmods\/@img\/sharp-linux-"\*/);
assert.doesNotMatch(build, /rm -rf "\$server\/node_modules\/@img/, 'not the wrong directory');
assert.match(build, /cd "\$appmods\/sharp" && 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', () => {
+114
View File
@@ -0,0 +1,114 @@
'use strict';
// Guard: UnassignedArchiveBleed, GHSA-6xqf-jh8x-4wxc (2026-11-08). The DDP methods archiveSelectedCards
// and createBoardFromCard asked only for the board's `write` capability. The
// normal-assigned-only role has write: true but sees only its own cards, so a
// member with that role archived - and hid - cards not assigned to them, and
// turned any card into a linked-board card. Both now ask the card as well
// (assignedOnlyAllowsCard), as copyCard does, and record a refusal.
// Run: node tests/assignedCardMethods.test.cjs
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
const { assignedOnlyAllowsCard } = require('../models/lib/boardCardScope');
const { memberCan } = require('../models/lib/boardRoleCapabilities');
const board = {
_id: 'B',
members: [
{ userId: 'owner', isActive: true, isAdmin: true },
{ userId: 'member', isActive: true, isNormalAssignedOnly: true },
{ userId: 'normal', isActive: true },
],
};
// The decision both methods make now: board write AND the card.
const mayChangeCard = (userId, card) => memberCan(board.members, userId, 'write') && assignedOnlyAllowsCard(board, userId, card);
test('the reported attack: an assigned-only member may not change a card not assigned to them', () => {
const theirs = { _id: 'C', boardId: 'B', assignees: ['someone-else'] };
// The old check alone let them through - that was the hole.
assert.equal(memberCan(board.members, 'member', 'write'), true);
assert.equal(mayChangeCard('member', theirs), false);
assert.equal(mayChangeCard('member', { _id: 'D', boardId: 'B' }), false, 'a card with no assignees is not theirs either');
});
test('their own card, and everybody else with write, still may', () => {
assert.equal(mayChangeCard('member', { _id: 'C', boardId: 'B', assignees: ['member'] }), true);
for (const userId of ['owner', 'normal']) {
assert.equal(mayChangeCard(userId, { _id: 'C', boardId: 'B', assignees: [] }), true);
}
});
function methodBody(src, name) {
const start = src.indexOf(`async ${name}(`);
assert.ok(start >= 0, `${name} exists`);
const next = src.slice(start + 1).search(/\n async [\w']+\(/);
return next < 0 ? src.slice(start) : src.slice(start, start + 1 + next);
}
test('archiveSelectedCards checks every card of the selection before archiving the first', () => {
const body = methodBody(read('server/models/cards.js'), 'archiveSelectedCards');
const check = body.indexOf('cards.every(card => assignedOnlyAllowsCard(board, this.userId, card))');
assert.ok(check > 0, 'every card is checked');
assert.ok(check < body.indexOf('.archive()'), 'before the first card is archived');
assert.match(body, /recordUnassignedCardWrite\(this\.userId, 'method:archiveSelectedCards'\);\n\s+throw new Meteor\.Error\('not-authorized'\)/);
});
test('createBoardFromCard checks the card before making a board or changing the card', () => {
const body = methodBody(read('server/models/cards.js'), 'createBoardFromCard');
const check = body.indexOf('assignedOnlyAllowsCard(sourceBoard, this.userId, card)');
assert.ok(check > 0);
assert.ok(check < body.indexOf('Boards.insertAsync'), 'before the board is made');
assert.ok(check < body.indexOf('Cards.updateAsync'), 'before the card is changed');
assert.match(body, /recordUnassignedCardWrite\(this\.userId, 'method:createBoardFromCard'\);\n\s+throw new Meteor\.Error\('not-authorized'\)/);
});
test('a refusal is recorded as UnassignedArchiveBleed, and logging never breaks the guard', () => {
const src = read('server/models/cards.js');
const fn = src.slice(src.indexOf('function recordUnassignedCardWrite'), src.indexOf('async function createLinkedCardFor'));
assert.match(fn, /try \{\n\s+require\('\/server\/lib\/securityLog'\)\.record\(\{\n\s+key: 'authz\.assigned-card-write', action: 'blocked', source, userId,/);
assert.match(fn, /\} catch \(e\) \{ \/\* logging must never break the guard \*\/ \}/);
assert.match(read('models/lib/securityCategories.js'),
/'authz\.assigned-card-write':\s*\{ category: 'authz', bleed: 'UnassignedArchiveBleed', severity: 'medium', cwe: 'CWE-863' \}/);
});
// The shape, anywhere on the server: a method that takes a card id and decides
// by the write capability of that card's own board alone. Every such method
// must also ask the card (assignedOnlyAllowsCard, mayCopyFromBoard, or
// canEditCardOrLinkedCard, which asks it). A check of a DESTINATION board
// (destBoard, destination, target) is a different question and not counted.
function serverFiles(dir) {
const out = [];
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...serverFiles(rel));
else if (entry.name.endsWith('.js')) out.push(rel);
}
return out;
}
test('negative: no method taking a card id decides by board write access alone', () => {
const offenders = [];
for (const file of serverFiles('server')) {
const src = read(file);
const starts = [...src.matchAll(/\n\s+async '?([\w.]+)'?\(([^)]*)\)\s*\{/g)];
starts.forEach((m, i) => {
const params = m[2];
if (!/\bcardIds?\b/.test(params)) return;
const body = src.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : undefined);
const boards = [...body.matchAll(/allowIsBoardMemberWithWriteAccess\(this\.userId, (\w+)\)/g)].map(x => x[1]);
const own = boards.filter(v => !/dest|destination|target/i.test(v));
if (!own.length) return;
if (!/assignedOnlyAllowsCard\(|mayCopyFromBoard\(|canEditCardOrLinkedCard\(/.test(body)) {
offenders.push(`${file}: ${m[1]}`);
}
});
}
assert.deepEqual(offenders, [], 'these decide a card write by the board alone');
});
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../models/cards.js'), 'utf8');
const start = source.indexOf('async function cardMembers(');
const end = source.indexOf('async function cardLabels(', start);
assert.ok(start >= 0 && end > start, 'membership activity hooks are present');
function hooks(user) {
const activities = [];
const context = {
ReactiveCache: { getUser: async () => user },
Activities: { insertAsync: async activity => activities.push(activity) },
};
vm.runInNewContext(source.slice(start, end), context);
return { ...context, activities };
}
for (const [field, hook, idKey, joined, removed] of [
['members', 'cardMembers', 'memberId', 'joinMember', 'unjoinMember'],
['assignees', 'cardAssignees', 'assigneeId', 'joinAssignee', 'unjoinAssignee'],
]) {
const card = { _id: 'card', boardId: 'board', listId: 'list', swimlaneId: 'lane' };
test(`${field}: removing an absent user is a no-op without activity`, async () => {
const h = hooks(undefined);
for (const membership of [undefined, [], ['other']]) {
await h[hook]('actor', { ...card, [field]: membership }, [field], { $pull: { [field]: 'missing' } });
}
assert.equal(h.activities.length, 0);
});
test(`${field}: a deleted user's assignment can be removed and recorded`, async () => {
const h = hooks(undefined);
await h[hook]('actor', { ...card, [field]: ['deleted'] }, [field], { $pull: { [field]: 'deleted' } });
assert.equal(h.activities.length, 1);
assert.deepEqual({ ...h.activities[0] }, {
userId: 'actor', username: undefined, activityType: removed,
boardId: card.boardId, cardId: card._id, [idKey]: 'deleted',
listId: card.listId, swimlaneId: card.swimlaneId,
});
});
test(`${field}: normal joins and removals retain the username and activity scope`, async () => {
const h = hooks({ username: 'member' });
await h[hook]('actor', { ...card, [field]: [] }, [field], { $addToSet: { [field]: 'member' } });
await h[hook]('actor', { ...card, [field]: ['member'] }, [field], { $pull: { [field]: 'member' } });
assert.deepEqual(h.activities.map(activity => activity.activityType), [joined, removed]);
for (const activity of h.activities) {
assert.equal(activity.username, 'member');
assert.equal(activity[idKey], 'member');
assert.equal(activity.userId, 'actor');
assert.equal(activity.cardId, card._id);
}
await h[hook]('actor', { ...card, [field]: [] }, [field], { $pull: { [field]: 'member' } });
await h[hook]('actor', { ...card, [field]: ['member'] }, ['title'], { $pull: { [field]: 'member' } });
assert.equal(h.activities.length, 2);
});
}
// The join halves had the same unguarded user.username (#6889 follow-up):
// REST checks the user before adding, but a client $addToSet, a copied or
// imported card, or a rule can add an id whose account is gone.
for (const [field, hook, idKey, joined] of [
['members', 'cardMembers', 'memberId', 'joinMember'],
['assignees', 'cardAssignees', 'assigneeId', 'joinAssignee'],
]) {
test(`${field}: adding a user whose account is gone does not throw, and records the id`, async () => {
const h = hooks(undefined);
const card = { _id: 'card', boardId: 'board', listId: 'list', swimlaneId: 'lane' };
await assert.doesNotReject(() => h[hook]('actor', { ...card, [field]: [] }, [field], { $addToSet: { [field]: 'gone' } }));
assert.equal(h.activities.length, 1);
assert.equal(h.activities[0].activityType, joined);
assert.equal(h.activities[0][idKey], 'gone');
assert.equal(h.activities[0].username, undefined);
});
}
// Negative, tree-wide: a user looked up is never dereferenced before it is
// known to exist - the shape both halves of #6889 had.
test('nowhere: `const x = user.field;` straight after getUser() without a guard', () => {
const root = path.join(__dirname, '..');
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (['node_modules', '.meteor', 'tests', 'packages', 'public'].includes(e.name) || e.name.startsWith('_build') || e.name.startsWith('.')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (p.endsWith('.js')) {
const lines = fs.readFileSync(p, 'utf8').split('\n');
lines.forEach((line, i) => {
const m = /const (\w+) = await ReactiveCache\.getUser\(/.exec(line);
if (!m) return;
const next = (lines.slice(i + 1).find(l => l.trim()) || '').trim();
if (new RegExp(`^const \\w+ = ${m[1]}\\.\\w+;`).test(next)) offenders.push(`${path.relative(root, p)}:${i + 2}`);
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(path.join(root, dir));
assert.deepEqual(offenders, []);
});
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// A card date cleared ($unset - unsetDue/unsetStart/unsetEnd/unsetReceived)
// went through the Cards.before.update timing hook, which read
// modifier.$set[field] whatever the update was: a $unset alone threw a
// TypeError inside the hook, and with a $set beside it an a-dueAt with
// timeValue: undefined was written - counted as a due date change and
// matched by the "date set or changed" rule triggers. Now a date set
// carries timeValue, a date cleared timeOldValue alone (as a rule's
// removeDate records it, and models/lib/boardTimeline.js rewinds), and the
// rules skip a clear.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { deferSyncRecording } = require('../server/lib/syncRecordingScope');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const helper = () => import('../server/lib/timingChangeActivity.js');
test('timingChange: a date set, a date cleared, or no date change', async () => {
const { timingChange } = await helper();
const at = new Date('2099-12-31T17:00:00Z');
assert.deepEqual(timingChange(['dueAt'], { $set: { dueAt: at } }), { field: 'dueAt', set: true, value: at });
assert.deepEqual(timingChange(['dateLastActivity', 'startAt'], { $set: { dateLastActivity: at, startAt: at } }),
{ field: 'startAt', set: true, value: at });
// The bug: a $unset alone, and a $unset beside a stamped $set.
assert.doesNotThrow(() => timingChange(['dueAt'], { $unset: { dueAt: '' } }));
assert.deepEqual(timingChange(['dueAt'], { $unset: { dueAt: '' } }), { field: 'dueAt', set: false });
assert.deepEqual(timingChange(['endAt', 'modifiedAt'], { $unset: { endAt: '' }, $set: { modifiedAt: at } }), { field: 'endAt', set: false });
assert.deepEqual(timingChange(['receivedAt'], { $set: { receivedAt: null } }), { field: 'receivedAt', set: false });
// Negative: not a date change.
for (const [fields, modifier] of [[['title'], { $set: { title: 'T' } }], [['title', 'dueAt'], { $set: { title: 'T', dueAt: at } }],
[[], {}], [['dueAt'], null], [['dueAt'], { $inc: { dueAt: 1 } }]]) {
assert.equal(timingChange(fields, modifier), null, JSON.stringify([fields, modifier]));
}
});
test('isTimingClear: an a-<date> without timeValue, and nothing else', async () => {
const { isTimingClear } = await helper();
assert.equal(isTimingClear({ activityType: 'a-dueAt', timeOldValue: new Date(1) }), true);
assert.equal(isTimingClear({ activityType: 'a-startAt', timeValue: null }), true);
assert.equal(isTimingClear({ activityType: 'a-dueAt', timeValue: new Date(1) }), false);
for (const activityType of ['a-changedTitle', 'addComment', 'a-isOvertime', undefined]) {
assert.equal(isTimingClear({ activityType }), false, String(activityType));
}
});
async function timingHook(card) {
const { timingChange } = await helper();
const src = read('server/models/cards.js');
const start = src.indexOf('Cards.before.update(async (userId, doc, fieldNames, modifier) => {\n // A date set carries timeValue');
const end = src.indexOf('// Issue #3619', start);
assert.ok(start > 0 && end > start, 'the timing hook is where this test looks for it');
const hooks = [], activities = [];
const context = {
Cards: { before: { update: fn => hooks.push(fn) } }, timingChange, deferSyncRecording, console,
ReactiveCache: { getCard: async () => card, getUser: async () => ({ username: 'writer' }) },
Lists: { direct: { updateAsync: async () => 1 } }, add: () => ({ toISOString: () => 'x' }), now: () => 0,
Activities: { insertAsync: async doc => activities.push(doc) },
};
vm.runInNewContext(src.slice(start, end), context);
return { activities, run: (doc, fieldNames, modifier) => hooks[0]('user', doc, fieldNames, modifier) };
}
test('the timing hook records a set, records a clear without throwing, and nothing for clearing nothing', async () => {
const card = { _id: 'card', listId: 'list', swimlaneId: 'lane', list: async () => ({ _id: 'list', boardId: 'board' }) };
const h = await timingHook(card);
const old = new Date('2026-01-01T00:00:00Z'), at = new Date('2099-12-31T17:00:00Z');
const doc = { _id: 'card', boardId: 'board', title: 'T', dueAt: old };
await h.run(doc, ['dueAt'], { $set: { dueAt: at } });
assert.equal(h.activities[0].activityType, 'a-dueAt');
assert.equal(h.activities[0].timeValue, at);
assert.equal(h.activities[0].timeOldValue, old);
// The bug: this threw "Cannot read properties of undefined".
await assert.doesNotReject(() => h.run(doc, ['dueAt'], { $unset: { dueAt: '' } }));
const cleared = h.activities[1];
assert.equal(cleared.activityType, 'a-dueAt');
assert.ok(!Object.hasOwn(cleared, 'timeValue'), 'a clear carries no new date');
assert.equal(cleared.timeOldValue, old);
// A stamped $set beside the $unset: still a clear, never timeValue: undefined.
await h.run(doc, ['dueAt', 'modifiedAt'], { $unset: { dueAt: '' }, $set: { modifiedAt: at } });
assert.ok(!Object.hasOwn(h.activities[2], 'timeValue'));
// Negative: clearing a date the card does not have records nothing.
await h.run({ ...doc, dueAt: undefined }, ['dueAt'], { $unset: { dueAt: '' } });
assert.equal(h.activities.length, 3);
});
test('a clear is recorded as a rule\'s removeDate records it', () => {
// server/lib/syncRuleCardCommand.js leaves timeValue out when the date is gone.
const rule = read('server/lib/syncRuleCardCommand.js');
assert.match(rule, /Object\.hasOwn\(after, field\) \? \{ timeValue: after\[field\] \} : \{\}/);
assert.match(read('server/models/cards.js'), /\.\.\.\(change\.set \? \{ timeValue: change\.value \} : \{\}\)/);
});
test('the rules skip a cleared date before matching any trigger', () => {
const src = read('server/rulesHelper.js');
const body = src.slice(src.indexOf('async findMatchingRules(activity) {'));
assert.ok(body.indexOf('if (isTimingClear(activity)) return [];') > 0);
assert.ok(body.indexOf('if (isTimingClear(activity)) return [];') < body.indexOf('TriggersDef[activityType]'),
'skipped before the triggers are matched');
});
test('the activity feed shows no empty date for a clear', () => {
const jade = read('client/components/activities/activities.jade');
assert.match(jade, /if currentData\.timeValue\n\s+i\(title=currentData\.timeValue\)/);
});
// Negative, tree-wide: no hook reads a computed key from a modifier's $set
// without knowing the $set is there - the shape of this bug.
test('nowhere: a modifier.$set[...] read that assumes the $set exists', () => {
const skip = new Set(['node_modules', '.meteor', '.build', '_build', '.tools', 'tests', 'packages', 'public']);
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (skip.has(e.name) || e.name.startsWith('_build')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (p.endsWith('.js')) {
read(path.relative(root, p)).split('\n').forEach((line, i) => {
// A read (right of =, or an argument), not the assignment of a modifier we built.
if (/[=(,]\s*modifier\.\$set\[/.test(line) && !/modifier\.\$set\s*&&|\?\.\[/.test(line)) {
offenders.push(`${path.relative(root, p)}:${i + 1}`);
}
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(path.join(root, dir));
assert.deepEqual(offenders, []);
});
+5 -2
View File
@@ -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; },
});
+4
View File
@@ -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: {
+44
View File
@@ -0,0 +1,44 @@
'use strict';
// #6887: in German the filter sidebar's "Due next week" option said "Diese
// Woche fällig", the same as "Due this week", so the two options that return
// different cards could not be told apart. The same copy sat in de-AT, de_DE,
// de-CH, lb, sv and ts, and in ak "Due today" and "Due next week" were both
// "search this".
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { translationTokens } = require('../releases/translations/placeholder-tokens.mjs');
const dir = path.join(__dirname, '../imports/i18n/data');
const read = code => JSON.parse(fs.readFileSync(path.join(dir, `${code}.i18n.json`), 'utf8'));
const en = read('en');
const keys = Object.keys(en).filter(key => key.startsWith('filter-due-'));
assert.ok(keys.includes('filter-due-this-week') && keys.includes('filter-due-next-week'), 'the due filter keys exist');
// Positive: the German labels say which week.
for (const [code, thisWeek, nextWeek] of [
['de', 'Diese Woche fällig', 'Nächste Woche fällig'],
['de-AT', 'Diese Woche fällig', 'Nächste Woche fällig'],
['de_DE', 'Diese Woche fällig', 'Nächste Woche fällig'],
['de-CH', 'diese Woche fällig', 'nächste Woche fällig'],
]) {
const locale = read(code);
assert.equal(locale['filter-due-this-week'], thisWeek, `${code}: this week`);
assert.equal(locale['filter-due-next-week'], nextWeek, `${code}: next week`);
}
assert.equal(read('sv')['filter-due-next-week'], 'Förfaller nästa vecka', 'sv: next week');
// Negative: in no language do two due-date options share one label.
const locales = fs.readdirSync(dir).filter(file => file.endsWith('.i18n.json')).map(file => file.replace('.i18n.json', ''));
for (const code of locales) {
const locale = read(code);
const seen = new Map();
for (const key of keys) {
const value = locale[key];
if (!value) continue;
assert.deepEqual(translationTokens(value), translationTokens(en[key]), `${code}:${key}: exact placeholders`);
assert.ok(!seen.has(value), `${code}: ${key} and ${seen.get(value)} are both ${JSON.stringify(value)}`);
seen.set(value, key);
}
}
console.log(`due filter labels distinct in ${locales.length} locales`);
+5 -1
View File
@@ -52,7 +52,11 @@ test('#4912: description changes log activityType a-changedDescription', () => {
});
test('#4912: due/start/end/received date changes log activityType a-${action}', () => {
assert.match(cardsSrc, /const timingaction = \[\s*'receivedAt',\s*'dueAt',\s*'startAt',\s*'endAt',?\s*\];/);
// The four dates moved from an inline list into server/lib/timingChangeActivity.js
// when the hook stopped reading a $set a cleared date does not have.
const timingSrc = fs.readFileSync(path.join(__dirname, '../server/lib/timingChangeActivity.js'), 'utf8');
assert.match(timingSrc, /export const TIMING_FIELDS = \[\s*'receivedAt',\s*'dueAt',\s*'startAt',\s*'endAt',?\s*\];/);
assert.match(cardsSrc, /const change = timingChange\(fieldNames, modifier\);/);
assert.match(cardsSrc, /const activityType = `a-\$\{action\}`;/);
});
+67
View File
@@ -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\);/);
+3 -2
View File
@@ -51,9 +51,10 @@ test('on macOS: the profile really refuses the home directory and allows the che
const profile = path.join(tmp, 'test.sb');
fs.writeFileSync(profile, `(version 1)\n(allow default)\n(deny file-read* file-write* (subpath "/Users") (subpath "/Volumes"))\n`
+ `(allow file-read* file-write* (subpath ${JSON.stringify(root)}))\n`);
// The paths are arguments, not script text (CodeQL #601's shape).
const r = spawnSync('sandbox-exec', ['-f', profile, '/bin/bash', '-c',
`ls ${JSON.stringify(home + '/Desktop')} >/dev/null 2>&1 && echo home=read || echo home=refused;`
+ `touch ${JSON.stringify(tmp + '/ok')} && echo checkout=written`], { encoding: 'utf8' });
'ls "$1" >/dev/null 2>&1 && echo home=read || echo home=refused; touch "$2" && echo checkout=written',
'_', home + '/Desktop', tmp + '/ok'], { encoding: 'utf8' });
assert.match(r.stdout, /home=refused/);
assert.match(r.stdout, /checkout=written/);
} finally {
+6 -5
View File
@@ -3,7 +3,8 @@
// Meteor 3.5.3's six security fixes, in WeKan's forks of Meteor's own
// packages in packages/ (a local package overrides the release's). The forks
// were made because Meteor 3.6-rc.0 lacked the fixes; 3.6-rc.1 carries them,
// and the forks are rebased onto it for what is WeKan's own: the Admin Panel /
// and the forks are rebased onto it - and onto Meteor 3.6, whose six packages
// differ from 3.6-rc.1's only in their version line - for what is WeKan's own: the Admin Panel /
// Problems reports and the crypto-RNG keys (tests/meteorForkRandomKeys.test.cjs).
// This suite keeps checking that the fixes are there, whichever side has them.
// Run: node tests/meteor353SecurityFixes.test.cjs
@@ -25,9 +26,9 @@ const { METEOR_SECURITY_KEYS, meteorSecurityEvent } = require('../models/lib/met
const { CATALOG } = require('../models/lib/securityCategories.js');
const { METEOR_PACKAGE_FORKS: FORKS } = require('./lib/meteorPackageForks.cjs');
const BASE_RELEASE = 'METEOR@3.6-rc.1';
const BASE_RELEASE = 'METEOR@3.6';
// The wrap number a fork adds to the release's own version of the package.
const FORK_VERSION = /-rc360\.1_1$/;
const FORK_VERSION = /^\d+\.\d+\.\d+_1$/;
let passed = 0;
let failed = 0;
@@ -70,7 +71,7 @@ const SOURCES = ['packages', 'server', 'models', 'imports', 'client']
test(`the forks are for ${BASE_RELEASE}, and none outlives it`, () => {
// A local package overrides the release's own SILENTLY. Moved to another
// Meteor release, these would keep 3.6-rc.1's code under the new release's
// Meteor release, these would keep 3.6's code under the new release's
// name. So this fails the moment .meteor/release changes: check whether the
// new release has the 3.5.3 fixes (the GitHub release notes list them) and
// delete packages/<name> for each that does, or rebase the fork onto it.
@@ -83,7 +84,7 @@ test('every fork is marked as one, and .meteor/versions resolves to it', () => {
const versions = read('.meteor/versions');
for (const name of FORKS) {
const version = read(`packages/${name}/package.js`).match(/version:\s*'([^']+)'/)[1];
assert.match(version, FORK_VERSION, `${name}'s fork keeps 3.6-rc.1's version with a _1 wrap number: ${version}`);
assert.match(version, FORK_VERSION, `${name}'s fork keeps 3.6's version with a _1 wrap number: ${version}`);
assert.ok(versions.split('\n').includes(`${name}@${version}`),
`.meteor/versions must resolve ${name} to the fork ${version}`);
}
+91
View File
@@ -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,50 @@
'use strict';
// UnassignedArchiveBleed, GHSA-6xqf-jh8x-4wxc (2026-11-08): an assigned-only member passed the
// board-level write check of archiveSelectedCards and createBoardFromCard for
// every card on the board, so they archived - and hid - cards not assigned to
// them, and turned any card into a linked-board card. Both methods now ask the
// card as well, and a refusal is recorded in Admin Panel -> Problems.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken } = require('../helpers/auth');
const BASE_URL = process.env.WEKAN_BASE_URL || 'http://localhost:3000';
const call = (page, name, ...args) => page.evaluate(async ({ name, args }) => {
try { return { result: await window.Meteor.callAsync(name, ...args) }; } catch (error) { return { error: error.error }; }
}, { name, args });
test('an assigned-only member archives and converts only the cards assigned to them', async ({ page, user2, board }) => {
const [theirs, mine] = db.find('cards', { boardId: board.boardId });
const original = db.getBoard(board.boardId).members;
db.updateOne('boards', { _id: board.boardId }, { $set: { members: [...original,
{ userId: user2.id, isActive: true, isAdmin: false, isNormalAssignedOnly: true }] } });
db.updateOne('cards', { _id: theirs._id }, { $set: { assignees: [] } });
db.updateOne('cards', { _id: mine._id }, { $set: { assignees: [user2.id] } });
const recorded = () => db.find('eventlog', { bleed: 'UnassignedArchiveBleed' })
.filter(row => /archiveSelectedCards|createBoardFromCard/.test(row.source || ''))
.reduce((sum, row) => sum + (row.count || 1), 0);
const before = recorded();
try {
await page.goto(`${BASE_URL}/sign-in`);
await loginWithToken(page, user2.id, user2.token);
// The reported attack: a card that is not theirs.
expect((await call(page, 'archiveSelectedCards', board.boardId, [theirs._id])).error).toBe('not-authorized');
expect((await call(page, 'createBoardFromCard', theirs._id, 'x')).error).toBe('not-authorized');
// A selection with one card not theirs changes none of it.
expect((await call(page, 'archiveSelectedCards', board.boardId, [mine._id, theirs._id])).error).toBe('not-authorized');
expect(db.findOne('cards', { _id: theirs._id }).archived).not.toBe(true);
expect(db.findOne('cards', { _id: mine._id }).archived).not.toBe(true);
expect(db.findOne('cards', { _id: theirs._id }).type).not.toBe('cardType-linkedBoard');
// Each attempt is visible in Admin Panel -> Problems.
await expect.poll(recorded).toBeGreaterThanOrEqual(before + 3);
// Their own card: still allowed.
expect((await call(page, 'archiveSelectedCards', board.boardId, [mine._id])).result).toEqual({ archived: 1 });
expect(db.findOne('cards', { _id: mine._id }).archived).toBe(true);
} finally {
db.updateOne('boards', { _id: board.boardId }, { $set: { members: original } });
db.updateOne('cards', { _id: mine._id }, { $set: { archived: false } });
}
});
@@ -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] });
}
});
+3 -2
View File
@@ -92,7 +92,8 @@ test('a download is readable by everyone, as an AppImage needs (v12.69)', () =>
// after `chmod +x`, and appimagetool refused to pack them.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-'));
const out = path.join(dir, 'ferretdb');
const r = spawnSync('bash', ['-c', `umask 022; bash "${fetchSh}" -o "${out}" "${base}/ok" && chmod +x "${out}"`],
// Arguments, not script text: the paths cannot change the command (CodeQL #601's shape).
const r = spawnSync('bash', ['-c', 'umask 022; bash "$1" -o "$2" "$3" && chmod +x "$2"', '_', fetchSh, out, `${base}/ok`],
{ encoding: 'utf8', env: Object.assign({}, process.env, { FETCH_SLEEPS: '0' }) });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual((fs.statSync(out).mode & 0o777).toString(8), '755');
@@ -101,7 +102,7 @@ test('a download is readable by everyone, as an AppImage needs (v12.69)', () =>
test('a download still follows a stricter umask (negative)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-'));
const out = path.join(dir, 'private');
const r = spawnSync('bash', ['-c', `umask 077; bash "${fetchSh}" -o "${out}" "${base}/ok"`],
const r = spawnSync('bash', ['-c', 'umask 077; bash "$1" -o "$2" "$3"', '_', fetchSh, out, `${base}/ok`],
{ encoding: 'utf8', env: Object.assign({}, process.env, { FETCH_SLEEPS: '0' }) });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual((fs.statSync(out).mode & 0o777).toString(8), '600');
+8 -3
View File
@@ -77,17 +77,22 @@ for (const sort of [null, '', 'abc', true, {}, [], 'Infinity', Number.NaN]) {
}
for (const [isOverTime, written] of [[false, false], ['false', false], [true, true], ['true', true]]) {
test(`card PUT writes isOverTime ${JSON.stringify(isOverTime)}`, async () => {
test(`card PUT stores isOverTime ${JSON.stringify(isOverTime)} in the schema's isOvertime field`, async () => {
const f = await fixture();
assert.equal((await f.put({ isOverTime })).code, 200);
assert.deepEqual(f.writes.map(w => w.modifier), [{ $set: { isOverTime: written } }]);
// The API spelling differs from the schema and UI. Checking only the
// request spelling allowed successful responses with no persisted change.
assert.deepEqual(f.writes, [{
selector: { _id: 'card', listId: 'list', boardId: 'board', archived: false },
modifier: { $set: { isOvertime: written } },
}]);
});
}
for (const isOverTime of [null, 0, 'yes', {}]) {
test(`card PUT refuses isOverTime ${JSON.stringify(isOverTime)} with 400`, async () => {
const f = await fixture();
assert.deepEqual(await f.put({ isOverTime }), { code: 400, data: { error: 'Invalid isOverTime' } });
assert.deepEqual(await f.put({ isOverTime, title: 'Renamed' }), { code: 400, data: { error: 'Invalid isOverTime' } });
assert.deepEqual(f.writes, []);
});
}
+9 -6
View File
@@ -57,15 +57,18 @@ test('negative: the 4 date triggers are distinct activityType keys, none shared'
// reuses actually exists and is not something this change invents.
test('the Cards.before.update timing-field hook logs a-receivedAt/a-startAt/a-dueAt/a-endAt', () => {
const block = serverCardsSrc.slice(
serverCardsSrc.indexOf("const timingaction = ['receivedAt', 'dueAt', 'startAt', 'endAt'];"),
);
const block = serverCardsSrc.slice(serverCardsSrc.indexOf('const change = timingChange(fieldNames, modifier);'));
const body = block.slice(0, block.indexOf('\n});') + 4);
assert.ok(/activityType = `a-\$\{action\}`;/.test(body));
assert.ok(/await Activities\.insertAsync\(/.test(body));
// Reads modifier.$set - only fires for a real SET, not for an $unset clear
// (unsetDue/unsetStart/unsetEnd/unsetReceived), matching "set or changed".
assert.ok(/modifier\.\$set\[action\]/.test(body));
// It read modifier.$set[action] to "only fire for a real SET", but a cleared
// date ($unset) went through it too - throwing, or writing timeValue:
// undefined. Now a set carries timeValue and a clear only timeOldValue
// (tests/cardTimingActivity.test.cjs), and "set or changed" still means a
// real value: the rules skip a clear before matching any trigger.
assert.ok(/\.\.\.\(change\.set \? \{ timeValue: change\.value \} : \{\}\)/.test(body));
assert.ok(!/modifier\.\$set\[action\]/.test(body), 'negative: no read of a $set that may not be there');
assert.ok(/if \(isTimingClear\(activity\)\) return \[\];/.test(fs.readFileSync(path.join(__dirname, '../server/rulesHelper.js'), 'utf8')));
});
// --- server/rulesHelper.js: the matcher works generically off TriggersDef --
+47
View File
@@ -956,6 +956,53 @@ await test('nonfinite-sort-repair: repairs every sorted collection (lists/swimla
assert.strictEqual(db._dump('checklistItems')[1].sort, 2);
});
// ── card-overtime-field (#6888) ──────────────────────────────────────────────
const overtimeStep = steps.find(s => s.name === 'card-overtime-field');
await test('card-overtime-field: a REST isOverTime becomes isOvertime, the shown one wins, the stray field goes', async () => {
const db = liveSortDb({ cards: [
{ _id: 'on', isOverTime: true },
{ _id: 'off', isOverTime: false },
{ _id: 'both', isOverTime: true, isOvertime: false },
{ _id: 'ui', isOvertime: true },
{ _id: 'plain' },
] });
assert.strictEqual(await overtimeStep.check(db), true);
const r = await overtimeStep.run(db);
assert.strictEqual(r.fixed, 2);
const byId = Object.fromEntries(db._dump('cards').map(c => [c._id, c]));
assert.deepStrictEqual(byId.on, { _id: 'on', isOvertime: true });
assert.deepStrictEqual(byId.off, { _id: 'off', isOvertime: false });
assert.deepStrictEqual(byId.both, { _id: 'both', isOvertime: false }, 'the value the UI showed is kept');
assert.deepStrictEqual(byId.ui, { _id: 'ui', isOvertime: true });
assert.deepStrictEqual(byId.plain, { _id: 'plain' });
assert.strictEqual(await overtimeStep.check(db), false, 'idempotent: nothing left after one pass');
});
await test('negative: card-overtime-field does not run on cards without the stray field', async () => {
const db = liveSortDb({ cards: [{ _id: 'ui', isOvertime: true }, { _id: 'plain' }] });
assert.strictEqual(await overtimeStep.check(db), false);
});
// Negative, tree-wide: nothing writes the request spelling into a card again.
await test('negative: no server code writes isOverTime into a card document', () => {
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
if (['node_modules', '.meteor', 'tests', 'packages'].includes(e.name) || e.name.startsWith('_build') || e.name.startsWith('.')) continue;
const rel = path.join(dir, e.name);
if (e.isDirectory()) walk(rel);
else if (rel.endsWith('.js')) {
read(rel).split('\n').forEach((line, i) => {
if (/\$(set|setOnInsert)\s*:\s*\{[^}]*\bisOverTime\s*:/.test(line)) offenders.push(`${rel}:${i + 1}`);
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(dir);
assert.deepStrictEqual(offenders, []);
});
await test('negative: nonfinite-sort-repair does not run on healthy finite sorts', async () => {
const db = liveSortDb({ cards: [{ _id: 'c1', sort: 0 }, { _id: 'c2', sort: 1e300 }, { _id: 'c3', sort: -1e300 }] });
assert.strictEqual(await sortStep.check(db), false);
+5 -1
View File
@@ -221,6 +221,8 @@ 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'],
};
@@ -371,7 +373,9 @@ test('the whole published list is accounted for', () => {
// 144 -> 145: CreateBleed (GHSA-qcfm-2g4w-wqcc, 2026-11-07), guarded. It went
// unnoticed until the companion checkout moved to .tools/w/wekan.fi and this
// file looked there: at the old path the comparison was skipped.
assert.strictEqual(total, 145, 'the Hall of Fame and this list disagree on how many there are');
// 145 -> 146: UnassignedArchiveBleed (GHSA-6xqf-jh8x-4wxc, 2026-11-08), guarded.
// 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', () => {
+76
View File
@@ -22,6 +22,16 @@ STAGE_LOG = (LOG.split('* Command standard error')[0]
"Failed to fetch package: The item '/root/.cache/snapcraft/download/"
"libcurl4t64_8.5.0-2ubuntu10.15_amd64.deb' could not be fetched: "
'404 Not Found [IP: 91.189.91.82 80].\nFailed to run snapcraft in instance\n')
# v13.02 amd64: build-packages could not run apt update in the project
# instance while the amd64 archive was unreachable.
UPDATE_LOG = ('Executing in container: /snap/bin/lxc --project snapcraft exec local:' + BUILD
+ ' --cwd /root/project --mode non-interactive -- env CRAFT_MANAGED_MODE=1 snapcraft pack\n'
':: Installing build-packages\n'
':: E: Failed to fetch http://archive.ubuntu.com/ubuntu/dists/noble-updates/InRelease '
'Could not connect to archive.ubuntu.com:80 (185.125.190.81), connection timed out\n'
':: W: Some index files failed to download. They have been ignored, or old ones used instead.\n'
'Failed to refresh package list: failed to run apt update.\n'
'Failed to run snapcraft in instance\n')
class RecoveryTests(unittest.TestCase):
@@ -46,6 +56,72 @@ class RecoveryTests(unittest.TestCase):
with self.subTest(text=text), self.assertRaises(ValueError):
recovery.failed_instance(text)
def test_apt_update_failure_selects_project_instance(self):
self.assertEqual(recovery.failed_instance(UPDATE_LOG), BUILD)
self.assertEqual(recovery.failed_instance(UPDATE_LOG.replace('\n', '\\n')), BUILD)
for text in [UPDATE_LOG.replace('Failed to run snapcraft in instance', ''),
UPDATE_LOG.replace(BUILD, 'other-container'),
UPDATE_LOG + 'lxc --project snapcraft exec local:snapcraft-other-123 -- env\n',
UPDATE_LOG.replace('failed to run apt update', 'failed to run apt upgrade'),
'Build failed\nFailed to run snapcraft in instance\n'
'lxc --project snapcraft exec local:' + BUILD + ' -- env\n']:
with self.subTest(text=text), self.assertRaises(ValueError):
recovery.failed_instance(text)
def test_apt_diagnostics_say_why(self):
lines = recovery.apt_diagnostics(UPDATE_LOG)
self.assertEqual(len(lines), 2)
self.assertIn('Could not connect to archive.ubuntu.com', lines[0])
self.assertIn('Some index files failed', lines[1])
self.assertEqual(recovery.apt_diagnostics('Building\nStaged\n'), [])
def exercise_update(self, failures, status='Stopped'):
"""apt-get update fails `failures` times, then succeeds."""
calls, sleeps, left = [], [], [failures]
def run(args, **kwargs):
self.assertTrue(kwargs['check'])
calls.append(args[3:])
if args[3] == 'list':
return subprocess.CompletedProcess(args, 0, json.dumps([{'name': BUILD, 'status': status}]))
if args[3] == 'exec':
self.assertEqual(args[-1], 'update')
if left[0]:
left[0] -= 1
raise subprocess.CalledProcessError(100, args, 'E: Could not connect')
return subprocess.CompletedProcess(args, 0, '')
with patch.object(recovery.subprocess, 'run', side_effect=run):
if failures > len(recovery.UPDATE_BACKOFF):
with self.assertRaises(subprocess.CalledProcessError):
recovery.refresh(UPDATE_LOG, sleep=sleeps.append)
else:
recovery.refresh(UPDATE_LOG, sleep=sleeps.append)
return [c[0] for c in calls], sleeps
def test_apt_update_outage_waits_until_the_archive_answers(self):
self.assertEqual(self.exercise_update(0), (['list', 'start', 'exec', 'stop'], []))
calls, sleeps = self.exercise_update(2)
self.assertEqual(calls, ['list', 'start', 'exec', 'exec', 'exec', 'stop'])
self.assertEqual(sleeps, list(recovery.UPDATE_BACKOFF[:2]))
def test_apt_update_outage_that_never_ends_is_fatal_and_bounded(self):
calls, sleeps = self.exercise_update(len(recovery.UPDATE_BACKOFF) + 1)
self.assertEqual(calls.count('exec'), len(recovery.UPDATE_BACKOFF) + 1)
self.assertEqual(calls[-1], 'stop')
self.assertEqual(sleeps, list(recovery.UPDATE_BACKOFF))
# Bounded well inside the 90-minute snap-variants job.
self.assertLess(sum(recovery.UPDATE_BACKOFF), 15 * 60)
def test_package_404_is_refreshed_once_without_waiting(self):
sleeps = []
def run(args, **kwargs):
if args[3] == 'list':
return subprocess.CompletedProcess(args, 0, json.dumps([{'name': NAME, 'status': 'Running'}]))
raise subprocess.CalledProcessError(100, args)
with patch.object(recovery.subprocess, 'run', side_effect=run):
with self.assertRaises(subprocess.CalledProcessError):
recovery.refresh(LOG, sleep=sleeps.append)
self.assertEqual(sleeps, [])
def exercise(self, status='Stopped', fail=False, name=NAME, log=LOG):
calls = []
def run(args, **kwargs):
+2 -2
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const { test } = require('node:test');
const root = path.resolve(__dirname, '..');
test('managed APT recovery handles package 404s and rejects unrelated failures', () => {
test('managed APT recovery handles package 404s and apt update outages, and rejects unrelated failures', () => {
const result = spawnSync('python3', ['-B', 'tests/snap-managed-apt.py'], {
cwd: root, encoding: 'utf8', timeout: 10000,
});
@@ -20,7 +20,7 @@ test('native and variant Snap jobs allow exactly one guarded recovery', () => {
// Pinned to a commit of v1 (tests/workflowHardening.test.cjs).
assert.equal((job.match(/uses: snapcore\/action-build@[0-9a-f]{40} # v1\./g) || []).length, 2);
assert.match(job, /id: build-first\s+continue-on-error: true/);
assert.match(job, /Refresh stale managed APT indexes after a package 404\s+if: steps.build-first.outcome == 'failure'\s+run: sudo .*snap-refresh-managed-apt.py/);
assert.match(job, /Refresh managed APT after a package 404 or a failed apt update\s+if: steps.build-first.outcome == 'failure'\s+run: sudo .*snap-refresh-managed-apt.py/);
assert.match(job, /id: build-retry\s+if: steps.build-first.outcome == 'failure'\s+uses:/);
assert.match(job, /BUILT_SNAP: \$\{\{ steps.build-first.outputs.snap \|\| steps.build-retry.outputs.snap \}\}/);
assert.match(job, /test -n "\$BUILT_SNAP"\s+test -f "\$BUILT_SNAP"/);
+23
View File
@@ -297,6 +297,29 @@ async function main() {
assert.equal(empty.data.project.entities['wekan-project-1'].title, 'Empty');
});
// A card whose list is archived is collected with listTitle '': the export
// threw "Cannot read properties of undefined (reading 'taskIds')" on its
// missing tag. It is the "Untitled" list now, its tag and its panel alike.
test('a card with no list title exports into the Untitled list', () => {
const backup = JSON.parse(formatSuperProductivity({ board: { title: 'B' }, lists: [{ title: 'Doing' }], swimlanes: [],
items: [{ cardId: 'a', title: 'Orphan', listTitle: '', labels: [] }, { cardId: 'b', title: 'Kept', listTitle: 'Doing', labels: [] }] }, { now: 1 }));
assertSuperProductivityAccepts(backup);
const orphan = backup.data.task.entities.a;
const untitled = Object.values(backup.data.tag.entities).find(t => t.title === 'Untitled');
assert.ok(untitled, 'the Untitled list has its tag');
assert.deepEqual(orphan.tagIds, [untitled.id]);
assert.deepEqual(untitled.taskIds, ['a']);
const panel = backup.data.boards.boardCfgs[0].panels.find(p => p.title === 'Untitled');
assert.deepEqual(panel.includedTagIds, [untitled.id], 'its panel filters by that tag');
// Negative: no task names a tag that does not exist, and no panel a null tag.
for (const task of Object.values(backup.data.task.entities)) {
for (const id of task.tagIds) assert.ok(backup.data.tag.entities[id], `${task.id} names ${id}`);
}
for (const p of backup.data.boards.boardCfgs[0].panels) {
assert.ok(!p.includedTagIds.includes(null) && !p.excludedTagIds.includes(null), `${p.title} names no null tag`);
}
});
test('the format is wired into import, export, the import page and the export menu', () => {
assert.equal(EXTERNAL_PARSERS.superproductivity, parseSuperProductivity);
assert.equal(typeof formatters.superproductivity, 'function');
+31 -6
View File
@@ -1,9 +1,11 @@
'use strict';
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550): tests
// ran `bash -c` with a script that interpolated an absolute path built from
// __dirname, e.g. `source "${helper}"`. A checkout path with a quote, `$(` or a
// backtick would change the command. The path goes in through the environment
// instead (`source "$HELPER"`), so the script text is constant.
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550, #601):
// tests ran `bash -c` with a script that interpolated an absolute path built
// from __dirname or the temporary directory, e.g. `source "${helper}"`,
// `node_bin=${JSON.stringify(prog)}` or `bash "${fetchSh}" -o "${out}"`. A path
// with a quote, `$(` or a backtick would change the command - JSON's quotes are
// not the shell's. The path goes in through the environment (`"$HELPER"`) or
// as an argument (`"$1"`), so the script text is constant.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
@@ -26,4 +28,27 @@ assert.ok(SHAPE.test('`\n source "${configPath}"\n`'));
assert.ok(!SHAPE.test('[\'-c\', \'source "$SOCKET_DENIED_HELPER"; socket_denied\']'));
assert.ok(!SHAPE.test('`${name}. ${detail}`'));
console.log('testShellPathsFromEnv: no test sources an interpolated path');
// #601: any value quoted into a `-c` script - "${...}" or ${JSON.stringify(...)}
// - is data pasted into code. A script built from the test's own snippets
// (${functions}, ${body}) is code, and stays allowed.
const DASH_C = /['"]-c['"],\s*`([^`]*)`/g;
const QUOTED_VALUE = /"\$\{|\$\{JSON\.stringify\(/;
function quotedValues(src) {
const found = [];
for (const m of src.matchAll(DASH_C)) if (QUOTED_VALUE.test(m[1])) found.push(m[1].slice(0, 80));
return found;
}
const quoted = fs.readdirSync(path.join(root, 'tests'))
.filter(name => /\.(c?js|mjs)$/.test(name) && name !== path.basename(__filename))
.flatMap(name => quotedValues(fs.readFileSync(path.join(root, 'tests', name), 'utf8')).map(s => `${name}: ${s}`))
.sort();
assert.deepEqual(quoted, [], 'pass the value as an argument ("$1") or in the environment ("$NAME")');
// Negative: the guard catches #601's shapes...
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `set -u; node_bin=${JSON.stringify(prog)}; npm`])").length, 1);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `umask 022; bash \"${fetchSh}\" -o \"${out}\"`])").length, 1);
// ...and not the fixed forms, nor a script built from the test's own code.
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', 'bash \"$1\" -o \"$2\"', '_', fetchSh, out])").length, 0);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `set -u; node_bin=\"$NODE_BIN\"; ${body}`])").length, 0);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `${functions}\ntool_repo_dir ${args}`])").length, 0);
console.log('testShellPathsFromEnv: no test sources an interpolated path, nor quotes a value into a -c script');
+48
View File
@@ -0,0 +1,48 @@
# TrueOG deployment of WeKan (todo.true-og.net)
How TrueOG runs this fork: Dockerless, under OpenRC, on FerretDB 2.7 over PostgreSQL 17, behind nginx and
Cloudflare, with sign-in only through Staff-Auth (TrueOG's Ory Hydra OpenID Connect provider). Nothing here holds
a secret: values that are secret on the server appear as `<placeholders>`. The working copies live on the server in
`/home/system/gitea-upgrade` (scripts) and `/opt/wekan`, `/etc` (configuration); this folder records them.
| Path | Live location | What it is |
| --- | --- | --- |
| `wekan.env.example` | `/opt/wekan/wekan.env` | Environment, sourced by the OpenRC script. Secrets replaced. |
| `openrc/wekan.initd`, `openrc/wekan.confd` | `/etc/init.d/wekan`, `/etc/conf.d/wekan` | Service; `node=` picks the Node version (26 since WeKan 13.06). |
| `nginx/wekan.conf` | `/etc/nginx/sites-available/wekan.conf` | Reverse proxy; injects the sign-in-page style (below). |
| `authwatch/` | `/usr/local/sbin/trueog-wekan-authwatch.mjs`, `/etc/init.d/trueog-wekan-authwatch`, `/etc/logrotate.d/` | Locks and signs out WeKan accounts when their Staff-Auth sign-in ends. Installed by `install-wekan-authwatch.sh`; tests in `trueog-wekan-authwatch-test.mjs`. |
| `upgrade/` | `/home/system/gitea-upgrade` | `upgrade-wekan.sh` (step-by-step upgrades with verified backups) and its helpers; `wekan-patch-13.06.py` applies TrueOG's patches to the compiled release. |
## Sign-in (since 2026-11-11)
- **Staff-Auth only.** Hydra client "TrueOG WeKan" (authorization code, scopes `openid profile email`,
`client_secret_post`, access tokens 2160 h). `OAUTH2_*` in the environment; `OAUTH2_ID_MAP=sub`, so a WeKan
account is linked to a Staff-Auth account by `services.oidc.id` = Staff-Auth's user uuid. Any active Staff-Auth
account may sign in; the first sign-in creates the WeKan account (named after the Minecraft name).
- **No passwords.** `PASSWORD_LOGIN_ENABLED=false` hides the form; no account has a stored password, so the server
refuses password logins too. Registration and "forgot password" are disabled in the Admin Panel.
- **Straight to Staff-Auth.** `OIDC_REDIRECTION_ENABLED=true`: a signed-out visit goes to Staff-Auth (one automatic
try per browser tab). WeKan only redirects after its own sign-in page has drawn, so nginx injects a style that keeps
that page (`.auth-layout`) invisible for 3 seconds; if the redirect did not happen, the button appears.
- **Real client addresses.** `HTTP_FORWARDED_COUNT=1`: nginx resolves the visitor from Cloudflare's
`CF-Connecting-IP` and appends it as the last `X-Forwarded-For` entry; WeKan listens on 127.0.0.1 only.
- **Patches** (`upgrade/wekan-patch-13.06.py`, same changes as this fork's source): `setBoardTeams` (members of a
board's teams become board members server-side) and `oidcSignup` (accounts created by the OIDC service need no
invitation code although registration is disabled; `server/models/users.js`).
- **Ending a sign-in.** `trueog-wekan-authwatch` locks a WeKan account (disabled, sessions ended; open tabs closed
within about 2 s through WeKan's own API) when its Staff-Auth account is deactivated or deleted, unlocks it when the
account is active again, and signs a person out of WeKan (sessions ended, account not disabled) when Hydra no longer
accepts the token from their last sign-in (logout, revocation, expiry). No account is exempt. Add each new WeKan
version to `REVIEWED_WEKAN` after reading its source, or the service only watches.
## Admin Panel settings (stored in WeKan's database, not in files)
| Setting | Value |
| --- | --- |
| Disable registration / forgot password | on / on |
| Display authentication method | off |
| OIDC button text (`oidcBtnText`) | `Sign in with Staff-Auth` |
| Custom HTML after body start | `<style>/* TrueOG: hide the minicard completion checkbox */ .minicard-title .minicard-complete-toggle { display: none !important; }</style>` |
The WeKan account `staffauth-lockbot` (no password, marked `trueogLockBot`) is created by the authwatch installer;
it must stay an administrator and enabled for open tabs to close at once.
+238
View File
@@ -0,0 +1,238 @@
#!/bin/sh
# Install (or update) trueog-wekan-authwatch, the service that locks a WeKan account within about a second of
# its Staff-Auth account being deactivated or deleted (and unlocks it when that account is active again).
# doas sh /home/system/gitea-upgrade/install-wekan-authwatch.sh install or update, then verify
# doas sh /home/system/gitea-upgrade/install-wekan-authwatch.sh remove stop and remove it (and its DB role)
# Complete output and INSTALL_WEKAN_AUTHWATCH_EXIT=<status> are saved in logs/install-wekan-authwatch-<time>.log.
# WeKan, Staff-Auth and Hydra are not restarted or reconfigured. What it sets up:
# - PostgreSQL role wekan_authwatch (login, SELECT on staffauth users(uuid, deactivated) only), with a new
# random password in /etc/trueog-wekan-authwatch/pgpass (system, 0600) on every run;
# - WeKan admin account staffauth-lockbot (made by this tool, marked trueogLockBot) with NO password: it cannot
# sign in; the service writes itself a login token into the account and keeps it in memory. The service locks
# in the database, then asks WeKan through its REST API to disable the account too (open tabs close in ~2 s);
# - /usr/local/sbin/trueog-wekan-authwatch.mjs, /etc/init.d/trueog-wekan-authwatch (supervise-daemon, user
# system, WeKan's Node), /etc/logrotate.d/trueog-wekan-authwatch; default runlevel.
# Everything that runs WeKan's Node or loads modules from /opt/wekan runs as system (those files belong to system,
# so running them as root would hand system root). Nothing is installed unless the tests pass and the service's own --check, as system against the live
# databases, connects and finds nothing to lock right now.
SRC=/home/system/gitea-upgrade
[ "$(id -u)" = 0 ] || { echo "run as root: doas sh $0" >&2; exit 1; }
if [ -z "${WEKAN_AUTHWATCH_LOGGED:-}" ]; then
mkdir -p "$SRC/logs"
LOG=$SRC/logs/install-wekan-authwatch-$(date +%F_%H-%M-%S).log
: > "$LOG"; chmod 644 "$LOG"; chown system:system "$LOG"
echo "started $(date -Is): sh $0 $*" >> "$LOG"
WEKAN_AUTHWATCH_LOGGED=1 sh "$0" "$@" >> "$LOG" 2>&1
rc=$?
echo "finished $(date -Is)" >> "$LOG"
echo "INSTALL_WEKAN_AUTHWATCH_EXIT=$rc" >> "$LOG"
cat "$LOG"
exit "$rc"
fi
set -eu
NAME=trueog-wekan-authwatch
BIN=/usr/local/sbin/$NAME.mjs
INITD=/etc/init.d/$NAME
ROTATE=/etc/logrotate.d/$NAME
CONFDIR=/etc/$NAME
PGPASS=$CONFDIR/pgpass
SVCLOG=/var/log/wekan/$NAME.log
ROLE=wekan_authwatch
NODE=$(sed -n 's/^node="\(.*\)"$/\1/p' /etc/conf.d/wekan)
[ -n "$NODE" ] && [ -x "$NODE" ] || { echo "no Node from node= in /etc/conf.d/wekan" >&2; exit 1; }
as_system() { (cd / && env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/home/system PGPASSFILE="$PGPASS" runuser -u system -- "$@"); }
pg() { runuser -u postgres -- /usr/bin/psql -X -q -At -v ON_ERROR_STOP=1 "$@"; }
enabled() { rc-update show default | grep -Eq "(^|[[:space:]])$NAME([[:space:]]|\$)"; }
running() { pgrep -f "^$NODE $BIN" >/dev/null; }
stop_service() {
rc-service "$NAME" stop >/dev/null 2>&1 || true
n=0
while running; do
[ "$n" -lt 30 ] || { echo "$NAME did not stop after 30 seconds" >&2; return 1; }
sleep 1; n=$((n + 1))
done
}
# Start detached, then require: OpenRC says started, supervise-daemon supervises it, and its log shows the
# installed code starting and its first pass ("watching N linked WeKan accounts").
start_and_verify() {
version=$(sha256sum "$BIN" | cut -c1-16)
mark=0
[ ! -f "$SVCLOG" ] || mark=$(wc -l < "$SVCLOG")
setsid -f /bin/sh -c "rc-service $NAME start >>/var/log/wekan/openrc-detached-start.log 2>&1"
n=0
until rc-service "$NAME" status >/dev/null 2>&1 &&
pgrep -f "^supervise-daemon $NAME " >/dev/null && running &&
tail -n +"$((mark + 1))" "$SVCLOG" 2>/dev/null | grep -q "started version=$version " &&
tail -n +"$((mark + 1))" "$SVCLOG" 2>/dev/null | grep -q "watching [0-9]* linked WeKan accounts"; do
[ "$n" -lt 30 ] || { echo "$NAME was not started, supervised and watching after 30 seconds" >&2
tail -n +"$((mark + 1))" "$SVCLOG" 2>/dev/null | tail -5 >&2; return 1; }
sleep 1; n=$((n + 1))
done
if tail -n +"$((mark + 1))" "$SVCLOG" | grep -q "not reviewed"; then
echo "$NAME is running but watches only (WeKan version not in REVIEWED_WEKAN)" >&2; return 1
fi
if ! tail -n +"$((mark + 1))" "$SVCLOG" | grep -q "started version=$version .*API nudge on"; then
echo "$NAME is running without its WeKan API nudge (open tabs would close only at WeKan's next poll)" >&2; return 1
fi
[ "$(pgrep -c -f "^$NODE $BIN")" = 1 ] || { echo "more than one $NAME process" >&2; return 1; }
tail -n +"$((mark + 1))" "$SVCLOG"
}
if [ "${1:-}" = remove ]; then
stop_service
rc-update del "$NAME" default >/dev/null 2>&1 || true
rm -f "$BIN" "$INITD" "$ROTATE"
if [ -s "$SRC/$NAME.mjs" ]; then
as_system "$NODE" --no-warnings "$SRC/$NAME.mjs" --remove-bot
else
echo "note: $SRC/$NAME.mjs is missing, so the WeKan account staffauth-lockbot was not removed" >&2
fi
rm -rf "$CONFDIR"
if [ "$(pg -d postgres -c "select 1 from pg_roles where rolname='$ROLE'")" = 1 ]; then
pg -d staffauth -c "revoke all on users from $ROLE; revoke all on schema public from $ROLE"
pg -d postgres -c "revoke all on database staffauth from $ROLE; drop role $ROLE"
fi
echo "removed $NAME, the WeKan account staffauth-lockbot and the role $ROLE (Staff-Auth still refuses sign-in for deactivated or deleted accounts;"
echo "WeKan sessions already open are no longer ended by it)"
echo "INSTALL_WEKAN_AUTHWATCH_REMOVED"
exit 0
fi
[ "$#" = 0 ] || { echo "usage: doas sh $0 [remove]" >&2; exit 2; }
for f in "$SRC/$NAME.mjs" "$SRC/$NAME-test.mjs" "$SRC/$NAME.initd" "$SRC/$NAME.logrotate"; do
[ -s "$f" ] || { echo "missing source $f" >&2; exit 1; }
done
as_system "$NODE" --check "$SRC/$NAME.mjs"
sh -n "$SRC/$NAME.initd"
WORK=$(mktemp -d /var/tmp/$NAME-install.XXXXXX)
chmod 700 "$WORK"
# The tests use a scratch FerretDB database only (wekan_authwatch_test, dropped afterwards); as system they skip
# the scratch-PostgreSQL part, which needs root (run the test file as root by hand for that part).
tests=0
as_system "$NODE" --no-warnings "$SRC/$NAME-test.mjs" > "$WORK/tests" 2>&1 || tests=$?
tail -3 "$WORK/tests" | sed 's/^/tests: /'
if [ "$tests" != 0 ] || ! tail -1 "$WORK/tests" | grep -Eq '^([0-9]+)/\1 passed$'; then
echo "tests failed; nothing installed" >&2; rm -rf "$WORK"; exit 1
fi
# The database role: read-only, two columns. A new password on every run, written only to the pgpass file
# (sent to psql on its standard input, so it never appears in a process list).
mkdir -p "$CONFDIR"; chown system:system "$CONFDIR"; chmod 700 "$CONFDIR"
PW=$(/usr/bin/python3 -I -c 'import secrets; print(secrets.token_hex(24))')
if [ "$(pg -d postgres -c "select 1 from pg_roles where rolname='$ROLE'")" != 1 ]; then
pg -d postgres -c "create role $ROLE login nosuperuser nocreatedb nocreaterole noinherit"
fi
# The new pgpass is written first and moved into place only after PostgreSQL took the password, so a failure
# leaves the running service with a matching pair. The statement is kept out of PostgreSQL's log, even on error.
umask 077
printf '127.0.0.1:5432:staffauth:%s:%s\n' "$ROLE" "$PW" > "$PGPASS.new"
chown system:system "$PGPASS.new"; chmod 600 "$PGPASS.new"
umask 022
printf "set log_statement = 'none';\nset log_min_error_statement = 'panic';\n\\\\set pw '%s'\nalter role %s login password :'pw';\n" "$PW" "$ROLE" | pg -d postgres -f -
mv "$PGPASS.new" "$PGPASS"
unset PW
pg -d postgres -c "revoke all on database staffauth from $ROLE; grant connect on database staffauth to $ROLE"
pg -d staffauth -c "revoke all on users from $ROLE; grant usage on schema public to $ROLE; grant select (uuid, deactivated) on users to $ROLE"
echo "role $ROLE: login, SELECT (uuid, deactivated) on staffauth.users only; password in $PGPASS"
# The WeKan account the service uses to make WeKan disable a locked account itself (closing open tabs at
# once): created or reset (administrator, enabled, no password, no tokens); the tool refuses an existing account
# of that name it did not make, and proves through the REST API that a token it mints acts as the admin bot.
rm -f "$CONFDIR/wekan-bot" "$CONFDIR/allow-mass-lock"
as_system "$NODE" --no-warnings "$SRC/$NAME.mjs" --provision-bot > "$WORK/bot" 2>&1 || {
sed 's/^/bot: /' "$WORK/bot"; echo "could not set up the WeKan account staffauth-lockbot; service not installed" >&2; rm -rf "$WORK"; exit 1; }
sed 's/^/bot: /' "$WORK/bot"
grep -q '^WEKAN_LOCKBOT_OK$' "$WORK/bot" || { echo "staffauth-lockbot not verified; service not installed" >&2; rm -rf "$WORK"; exit 1; }
# The service's own check, as system, against the live databases: reads Staff-Auth through the new role,
# reads WeKan's users, writes nothing. It must report nothing to lock right now.
preflight=0
as_system "$NODE" --no-warnings "$SRC/$NAME.mjs" --check > "$WORK/preflight" 2>&1 || preflight=$?
sed 's/^/preflight: /' "$WORK/preflight"
if [ "$preflight" != 0 ] || ! grep -q '^WEKAN_AUTHWATCH_CHECK_OK$' "$WORK/preflight"; then
echo "preflight check failed; service not installed (the role and $PGPASS stay; 'remove' drops them)" >&2
rm -rf "$WORK"; exit 1
fi
if ! grep -q 'would lock \[\], unlock \[\]' "$WORK/preflight" || grep -q -E 'NOT reviewed|refused:' "$WORK/preflight" ||
! grep -q 'API: bot signs in$' "$WORK/preflight"; then
echo "preflight: it would change accounts right now, refuses a mass lock, this WeKan version is not reviewed," >&2
echo "or the bot cannot sign in; check by hand first" >&2
rm -rf "$WORK"; exit 1
fi
BACKUP=$WORK/previous
mkdir "$BACKUP"
[ ! -e "$BIN" ] || cp -p "$BIN" "$BACKUP/service"
[ ! -e "$INITD" ] || cp -p "$INITD" "$BACKUP/initd"
[ ! -e "$ROTATE" ] || cp -p "$ROTATE" "$BACKUP/logrotate"
was_enabled=no; ! enabled || was_enabled=yes
was_running=no; ! running || was_running=yes
DONE=no
# Any failing exit from here on puts the previous files, runlevel membership and running state back (a
# first installation is removed again); the same signal list as install-authwatch.sh.
SIGNALS="1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 24 25 26 27 29 30 31"
n=34
while [ "$n" -le 64 ]; do SIGNALS="$SIGNALS $n"; n=$((n + 1)); done
rollback() {
status=$?
trap '' $SIGNALS
trap - EXIT
if [ "$DONE" != yes ]; then
echo "installation failed; restoring the previous state" >&2
ok=yes
stop_service || ok=no
for pair in "service:$BIN:755" "initd:$INITD:755" "logrotate:$ROTATE:644"; do
name=${pair%%:*}; rest=${pair#*:}; dst=${rest%%:*}; mode=${rest#*:}
if [ -e "$BACKUP/$name" ]; then
install -m "$mode" -o root -g root "$BACKUP/$name" "$dst" && cmp -s "$BACKUP/$name" "$dst" || ok=no
else
rm -f "$dst" || true
[ ! -e "$dst" ] || ok=no
fi
done
if [ "$was_enabled" = yes ]; then
enabled || rc-update add "$NAME" default >/dev/null 2>&1 || true
enabled || ok=no
else
rc-update del "$NAME" default >/dev/null 2>&1 || true
! enabled || ok=no
fi
if [ "$was_running" = yes ] && [ "$ok" = yes ]; then
start_and_verify >/dev/null 2>&1 || { ok=no; echo "the previous $NAME is restored but not verified running" >&2; }
fi
if [ "$ok" = yes ]; then
rm -rf "$WORK"
echo "previous state restored (the role $ROLE and $PGPASS stay; 'remove' drops them)" >&2
else
echo "ROLLBACK INCOMPLETE: files, runlevel or running state differ from before; backup kept in $BACKUP" >&2
fi
[ "$status" != 0 ] || status=1
fi
exit "$status"
}
trap rollback EXIT
for sig in $SIGNALS; do
trap "trap '' $SIGNALS; exit $((128 + sig))" "$sig"
done
stop_service
install -m 755 -o root -g root "$SRC/$NAME.mjs" "$BIN"
install -m 755 -o root -g root "$SRC/$NAME.initd" "$INITD"
install -m 644 -o root -g root "$SRC/$NAME.logrotate" "$ROTATE"
cmp -s "$SRC/$NAME.mjs" "$BIN" && cmp -s "$SRC/$NAME.initd" "$INITD" && cmp -s "$SRC/$NAME.logrotate" "$ROTATE"
echo "installed $BIN, $INITD and $ROTATE"
rotate_status=0
logrotate -d "$ROTATE" > "$WORK/logrotate" 2>&1 || rotate_status=$?
if [ "$rotate_status" != 0 ] || grep -Ei "^error|duplicate log entry|^warning: .*$NAME:[0-9]+" "$WORK/logrotate" >&2; then
echo "logrotate rejects $ROTATE (status $rotate_status)" >&2; exit 1
fi
enabled || rc-update add "$NAME" default >/dev/null
enabled || { echo "$NAME is not in the default runlevel" >&2; exit 1; }
start_and_verify
DONE=yes
trap - EXIT $SIGNALS
rm -rf "$WORK"
echo "$NAME $(sha256sum "$BIN" | cut -c1-16) is enabled, started, supervised and watching"
echo "INSTALL_WEKAN_AUTHWATCH_OK"
@@ -0,0 +1,272 @@
#!/usr/bin/env node
// Tests for trueog-wekan-authwatch. Run as root from /:
// /home/system/.nvm/versions/node/v26.13.0/bin/node /home/system/gitea-upgrade/trueog-wekan-authwatch-test.mjs
// Uses a scratch FerretDB database (wekan_authwatch_test, dropped at the end) through WeKan's own driver and
// MONGO_URL, and a scratch PostgreSQL database (wekan_authwatch_test, dropped at the end) for the psql path.
// The live WeKan and Staff-Auth databases are never written.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const here = path.dirname(new URL(import.meta.url).pathname);
const W = await import(path.join(here, 'trueog-wekan-authwatch.mjs'));
let failed = 0, passed = 0;
const ok = (cond, name) => { if (cond) passed++; else { failed++; console.log('FAIL', name); } };
const s = { ...W.settings({}), confirmMs: 20, exempt: [] };
const live = W.mongoUrl('/opt/wekan/wekan.env');
const u = new URL(live);
if (u.pathname.replace(/^\//, '') === 'wekan_authwatch_test') throw new Error('refusing');
u.pathname = '/wekan_authwatch_test';
const { MongoClient } = W.loadMongo('/opt/wekan');
const client = new MongoClient(u.toString(), { serverSelectionTimeoutMS: 5000 });
await client.connect();
const db = client.db();
if (db.databaseName !== 'wekan_authwatch_test') throw new Error('wrong scratch db ' + db.databaseName);
await db.dropDatabase();
const users = db.collection('users');
const id = n => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
const tok = [{ hashedToken: 'x', when: new Date() }];
async function reset(docs) {
await users.deleteMany({});
await users.insertMany(docs.map(d => ({ services: { resume: { loginTokens: tok } }, ...d })));
}
const user = (name, n, extra = {}) => ({ _id: name, username: name, services: { oidc: { id: id(n) }, resume: { loginTokens: tok } }, ...extra });
const staffMap = entries => new Map(entries.map(([n, active]) => [id(n), active]));
const nudged = [];
const ctxWith = (staffSeq, over = {}) => {
let i = 0;
const notes = [];
return {
users, s: over.s || s, write: true, version: '13.06', notes,
readStaff: async () => { const v = staffSeq[Math.min(i++, staffSeq.length - 1)]; if (v instanceof Error) throw v; return v; },
note: (k, m) => notes.push(k + ': ' + m), alert: m => notes.push('refused: ' + m),
nudge: async id => { nudged.push(id); }, ...over,
};
};
const get = name => users.findOne({ _id: name });
// 1 active, untouched
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
let r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])]));
ok(r.done.length === 0 && !(await get('a')).loginDisabled, 'active accounts untouched');
// 2 deactivated -> locked, tokens cleared, reason set
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]])]));
let a = await get('a');
ok(a.loginDisabled === true && a.loginDisabledReason === 'staff-auth' && a.services.resume.loginTokens.length === 0, 'deactivated -> locked, tokens cleared');
ok((await get('b')).services.resume.loginTokens.length === 1, 'other account keeps its tokens');
ok(a.services.oidc.id === id(1) && a.username === 'a', 'nothing else changed on the locked account');
// 3 reactivated -> unlocked, reason gone
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])]));
a = await get('a');
ok(a.loginDisabled === false && a.loginDisabledReason === undefined, 'reactivated -> unlocked');
// 4 deleted (missing from Staff-Auth) -> locked
r = await W.runPass(ctxWith([staffMap([[2, true], [9, true]])]));
ok((await get('a')).loginDisabled === true, 'deleted -> locked');
// 5 transient: deactivated on first read, active on the confirming read -> nothing
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]]), staffMap([[1, true], [2, true], [9, true]])]));
ok(!(await get('a')).loginDisabled, 'not confirmed -> not locked');
// 6 exempt owner deactivated -> not locked, reported (exempt by Staff-Auth uuid, any username)
s.exempt = [id(9)];
await users.updateOne({ _id: 'owner' }, { $set: { username: 'Renamed' } });
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, false]])]));
ok(!(await get('owner')).loginDisabled && r.exemptHits.length === 1, 'exempt account never locked');
// 7 disabled by someone else, Staff-Auth active -> stays disabled; deactivated -> untouched
await reset([user('a', 1, { loginDisabled: true, loginDisabledReason: 'inactive' }), user('b', 2, { loginDisabled: true }), user('c', 3)]);
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [3, true]])]));
ok((await get('a')).loginDisabled === true && (await get('a')).loginDisabledReason === 'inactive' && (await get('b')).loginDisabled === true, 'other disables never undone');
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [3, true]])]));
ok((await get('a')).loginDisabledReason === 'inactive' && (await get('a')).services.resume.loginTokens.length === 1, 'already disabled account untouched');
// 8 mass: three at once -> refused
await reset([user('a', 1), user('b', 2), user('c', 3), user('d', 4), user('owner', 9)]);
let ctx = ctxWith([staffMap([[4, true], [9, true]])]);
r = await W.runPass(ctx);
ok(r.refused && !(await get('a')).loginDisabled && !(await get('c')).loginDisabled && ctx.notes.some(n => n.startsWith('refused')), 'three at once refused');
// 9 every lockable account (2 of 2) -> refused; 1 of 2 -> allowed
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
r = await W.runPass(ctxWith([staffMap([[9, true], [5, true]])]));
ok(r.refused && !(await get('a')).loginDisabled, 'every lockable account refused');
r = await W.runPass(ctxWith([staffMap([[1, true], [9, true]])]));
ok((await get('b')).loginDisabled === true && !r.refused, 'one of two allowed');
// 10 read error -> throws, nothing changed
await reset([user('a', 1), user('b', 2)]);
let threw = false;
try { await W.runPass(ctxWith([new Error('db down')])); } catch { threw = true; }
ok(threw && !(await get('a')).loginDisabled, 'read error changes nothing');
threw = false;
try { await W.runPass(ctxWith([staffMap([[1, false], [2, true]]), new Error('db down')])); } catch { threw = true; }
ok(threw && !(await get('a')).loginDisabled, 'error on the confirming read changes nothing');
// 11 unreviewed WeKan version -> watch only
ctx = ctxWith([staffMap([[1, false], [2, true]])], { version: '13.99' });
r = await W.runPass(ctx);
ok(!(await get('a')).loginDisabled && ctx.notes.some(n => n.startsWith('unreviewed')), 'unreviewed version watches only');
ctx = ctxWith([staffMap([[1, false], [2, true]])], { version: null });
r = await W.runPass(ctx);
ok(!(await get('a')).loginDisabled, 'unknown version watches only');
// 12 --check style (write false) -> nothing written
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true]])], { write: false }));
ok(r.lock.length === 1 && !(await get('a')).loginDisabled, 'dry run writes nothing');
// 13 accounts without Staff-Auth link or with a malformed id are ignored
await reset([{ _id: 'local', username: 'local' }, { _id: 'bad', username: 'bad', services: { oidc: { id: 'not-a-uuid' } } }, user('a', 1), user('b', 2)]);
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true]])]));
ok(!(await get('local')).loginDisabled && !(await get('bad')).loginDisabled && r.linked === 2, 'unlinked and malformed ignored');
// 14 upper-case uuid in WeKan matches Staff-Auth's lower-case
await reset([user('a', 1), user('b', 2)]);
await users.updateOne({ _id: 'a' }, { $set: { 'services.oidc.id': id(1).toUpperCase() } });
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true]])]));
ok(!(await get('a')).loginDisabled, 'upper-case id treated as the same subject');
// 18 nudge: called once per lock, never for unlocks; its failure keeps the lock
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
nudged.length = 0;
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]])]));
ok(nudged.length === 1 && nudged[0] === 'a', 'nudge called for the locked account');
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])]));
ok(nudged.length === 1, 'no nudge on unlock');
a = await get('a');
ok(a.inactivityClockStart instanceof Date, 'unlock restarts the inactivity clock');
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]])], { nudge: async () => { throw new Error('HTTP 500'); } }));
ok((await get('a')).loginDisabled === true && r.done.length === 1, 'failed nudge keeps the lock');
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])], { nudge: null }));
ok((await get('a')).loginDisabled === false, 'works without a nudge');
// 19 mass-lock override file
await reset([user('a', 1), user('b', 2), user('c', 3), user('owner', 9)]);
r = await W.runPass(ctxWith([staffMap([[9, true]])], { allowMass: () => true }));
ok(!r.refused && (await get('a')).loginDisabled && (await get('c')).loginDisabled, 'override lets a mass lock through');
// 20 clean() blanks connection strings and long tokens
ok(!W.clean(new Error('connect mongodb://' + 'u:p' + '@h:1/db failed')).includes('u:' + 'p') && !W.clean('x ' + 'A'.repeat(40)).includes('AAAA'), 'clean redacts');
// 21 the password-less bot on the scratch database (no REST here)
await users.deleteMany({});
const botId = await W.provisionBot(users);
let bot = await users.findOne({ _id: botId });
ok(bot.isAdmin === true && bot.trueogLockBot === true && !bot.services.password && bot.loginDisabled === false, 'bot: admin, marked, no password');
const tk = await W.mintBotToken(users);
bot = await users.findOne({ _id: botId });
const sha64 = (await import('node:crypto')).createHash('sha256').update(tk).digest('base64');
ok(bot.services.resume.loginTokens.length === 1 && bot.services.resume.loginTokens[0].hashedToken === sha64, 'minted token stored as Meteor hashes it');
const tk2 = await W.mintBotToken(users);
bot = await users.findOne({ _id: botId });
ok(tk2 !== tk && bot.services.resume.loginTokens.length === 1, 'a new token replaces the old one');
await users.updateOne({ _id: botId }, { $set: { loginDisabled: true, 'services.password': { bcrypt: 'x' } } });
threw = false; try { await W.mintBotToken(users); } catch { threw = true; } ok(threw, 'no token for a disabled bot');
ok(await W.provisionBot(users) === botId, 'reprovision keeps the account');
bot = await users.findOne({ _id: botId });
ok(bot.loginDisabled === false && !bot.services.password && bot.services.resume.loginTokens.length === 0, 'reprovision re-enables, drops any password and tokens');
await users.deleteOne({ _id: botId });
await users.insertOne({ _id: 'imposter', username: W.BOT_USERNAME, isAdmin: true, profile: { trueogLockBot: true } });
threw = false; try { await W.provisionBot(users); } catch { threw = true; } ok(threw, 'refuses an account it did not make');
threw = false; try { await W.mintBotToken(users); } catch { threw = true; } ok(threw, 'no token for an account it did not make');
ok(await W.removeBot(users) === 0 && await users.findOne({ _id: 'imposter' }), 'remove leaves a foreign account alone');
await users.deleteOne({ _id: 'imposter' });
await W.provisionBot(users);
ok(await W.removeBot(users) === 1 && !(await users.findOne({ username: W.BOT_USERNAME })), 'remove deletes the bot');
// 22 sign-out: inactive Staff-Auth token ends WeKan sessions (not a lock); fresh sign-in wins; errors change nothing
await users.deleteMany({});
const withTok = (name, n, token, extra = {}) => ({ _id: name, username: name,
services: { oidc: { id: id(n), ...(token ? { accessToken: token } : {}) }, resume: { loginTokens: tok } }, ...extra });
await users.insertMany([withTok('a', 1, 'T-a'), withTok('b', 2, 'T-b'), withTok('nt', 3, null),
withTok('off', 4, 'T-off', { loginDisabled: true }), { _id: 'local', username: 'local', services: { resume: { loginTokens: tok } } },
{ ...withTok('idle', 5, 'T-idle'), services: { oidc: { id: id(5), accessToken: 'T-idle' }, resume: { loginTokens: [] } } }]);
const activeSet = new Set(['T-b']);
const soCtx = (over = {}) => ({ users, s, write: true, version: '13.06', isActive: async tk => activeSet.has(tk), ...over });
let so = await W.runSignOutPass(soCtx());
const tokensOf = async n => (await get(n)).services.resume.loginTokens.length;
ok(so.signedOut.sort().join() === 'a,nt', 'signs out inactive-token and no-token accounts');
ok(await tokensOf('a') === 0 && await tokensOf('nt') === 0 && (await get('a')).loginDisabled !== true, 'sessions cleared, account not disabled');
ok(await tokensOf('b') === 1 && await tokensOf('off') === 1 && await tokensOf('local') === 1, 'active, disabled and unlinked accounts untouched');
ok((await get('a')).services.oidc.accessToken === 'T-a', 'stored token kept');
// a fresh sign-in between the look and the write wins
await users.updateOne({ _id: 'a' }, { $set: { 'services.resume.loginTokens': tok } });
let calls = 0;
so = await W.runSignOutPass(soCtx({ isActive: async tk => { calls++; if (calls === 2) await users.updateOne({ _id: 'a' }, { $set: { 'services.oidc.accessToken': 'T-new' } }); return tk === 'T-new' || activeSet.has(tk); } }));
ok(await tokensOf('a') === 1, 'fresh sign-in during the check is not signed out');
// introspection error: nothing changes
await users.updateOne({ _id: 'a' }, { $set: { 'services.oidc.accessToken': 'T-a' } });
threw = false; try { await W.runSignOutPass(soCtx({ isActive: async () => { throw new Error('hydra down'); } })); } catch { threw = true; }
ok(threw && await tokensOf('a') === 1, 'Hydra error changes nothing');
// not confirmed on the second look (became active again): nothing
let flip = 0;
so = await W.runSignOutPass(soCtx({ isActive: async tk => tk === 'T-a' ? (flip++ > 0) : activeSet.has(tk) }));
ok(await tokensOf('a') === 1, 'not confirmed -> not signed out');
// dry run and unreviewed version write nothing
so = await W.runSignOutPass(soCtx({ write: false }));
ok(so.candidates.length === 1 && await tokensOf('a') === 1, 'dry run writes nothing');
so = await W.runSignOutPass(soCtx({ version: '13.99' }));
ok(await tokensOf('a') === 1, 'unreviewed version writes nothing');
// introspect() against a stand-in Hydra
const http = await import('node:http');
const srv = http.createServer((req, res) => { let b = ''; req.on('data', d => b += d); req.on('end', () => {
const tk = new URLSearchParams(b).get('token');
if (tk === 'boom') { res.writeHead(500); return res.end('x'); }
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(tk === 'odd' ? {} : { active: tk === 'good' })); }); });
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const hs = { ...s, hydraAdmin: 'http://127.0.0.1:' + srv.address().port };
ok(await W.introspect(hs, 'good') === true && await W.introspect(hs, 'bad') === false, 'introspect active/inactive');
for (const bad of ['boom', 'odd']) { threw = false; try { await W.introspect(hs, bad); } catch { threw = true; } ok(threw, 'introspect throws on ' + bad); }
srv.close();
// 15 parseStaff
ok(W.parseStaff(`${id(1)}\tf\n${id(2)}\tt\n`).get(id(2)) === false, 'parseStaff t/f');
for (const [bad, name] of [['', 'empty'], [`${id(1)}\tx\n`, 'bad flag'], [`nope\tf\n`, 'bad uuid'], [`${id(1)}\tf\textra\n`, 'extra column']]) {
let t = false; try { W.parseStaff(bad); } catch { t = true; } ok(t, 'parseStaff rejects ' + name);
}
// 16 mongoUrl parsing
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wauth-'));
const envf = path.join(tmp, 'e');
for (const [text, want] of [["export MONGO_URL='mongodb://x/y'\n", 'mongodb://x/y'], ['export MONGO_URL="mongodb://x/z"\n', 'mongodb://x/z'], ['export MONGO_URL=mongodb://x/w\n', 'mongodb://x/w']]) {
fs.writeFileSync(envf, '# c\nexport A=1\n' + text); ok(W.mongoUrl(envf) === want, 'mongoUrl ' + want);
}
fs.writeFileSync(envf, 'export MONGO_URL=http://x\n'); { let t = false; try { W.mongoUrl(envf); } catch { t = true; } ok(t, 'mongoUrl rejects non-mongodb'); }
if (process.getuid() === 0) {
// 17 real psql path against a scratch PostgreSQL database
const pg = (sql, db = 'postgres') => spawnSync('/usr/sbin/runuser', ['-u', 'postgres', '--', '/usr/bin/psql', '-X', '-q', '-At', '-v', 'ON_ERROR_STOP=1', '-d', db, '-c', sql], { encoding: 'utf8' });
pg('drop database if exists wekan_authwatch_test');
ok(pg('create database wekan_authwatch_test').status === 0, 'scratch pg created');
pg(`create table users (id serial, uuid uuid not null, deactivated boolean not null, totp_secret text); insert into users (uuid, deactivated, totp_secret) values ('${id(1)}', false, 'S'), ('${id(2)}', true, 'S')`, 'wekan_authwatch_test');
const wrapper = path.join(tmp, 'psql');
fs.writeFileSync(wrapper, '#!/bin/sh\nexec /usr/sbin/runuser -u postgres -- /usr/bin/psql "$@"\n', { mode: 0o755 });
const ps = { ...s, psql: wrapper, pgConninfo: 'dbname=wekan_authwatch_test' };
const staff = await W.readStaffViaPsql(ps);
ok(staff.get(id(1)) === true && staff.get(id(2)) === false && staff.size === 2, 'psql read');
threw = false; try { await W.readStaffViaPsql({ ...ps, pgConninfo: 'dbname=does_not_exist_x' }); } catch { threw = true; } ok(threw, 'psql error throws');
pg('delete from users', 'wekan_authwatch_test');
threw = false; try { await W.readStaffViaPsql(ps); } catch { threw = true; } ok(threw, 'empty Staff-Auth throws');
// column grant: a role with SELECT on uuid, deactivated only can run the query but not read totp_secret
pg(`drop role if exists wekan_authwatch_t; create role wekan_authwatch_t; grant select (uuid, deactivated) on users to wekan_authwatch_t; insert into users (uuid, deactivated) values ('${id(1)}', false)`, 'wekan_authwatch_test');
ok(pg('set role wekan_authwatch_t; select uuid, deactivated from users', 'wekan_authwatch_test').status === 0, 'column grant allows the query');
ok(pg('set role wekan_authwatch_t; select totp_secret from users', 'wekan_authwatch_test').status !== 0, 'column grant hides other columns');
pg('drop database wekan_authwatch_test'); pg('drop role wekan_authwatch_t');
} else {
console.log('psql section skipped (needs root for scratch PostgreSQL)');
}
await db.dropDatabase();
await client.close();
fs.rmSync(tmp, { recursive: true });
console.log(`${passed}/${passed + failed} passed`);
process.exit(failed ? 1 : 0);
@@ -0,0 +1,35 @@
#!/sbin/openrc-run
# /etc/init.d/trueog-wekan-authwatch: locks a WeKan account within about a second of its Staff-Auth account
# being deactivated or deleted, and unlocks it when that account is active again. Reads Staff-Auth's users
# table (role wekan_authwatch: uuid and deactivated only) and WeKan's users; see the header of
# /usr/local/sbin/trueog-wekan-authwatch.mjs. Runs as system (WeKan's user, owner of /opt/wekan/wekan.env)
# with the same Node as WeKan (node= in /etc/conf.d/wekan). Stopping it is safe: Staff-Auth still refuses
# sign-in for deactivated or deleted accounts; only WeKan sessions already open would then stay.
description="TrueOG WeKan lock on Staff-Auth deactivation"
node=$(sed -n 's/^node="\(.*\)"$/\1/p' /etc/conf.d/wekan 2>/dev/null)
export USER=system HOME=/home/system PGPASSFILE=/etc/trueog-wekan-authwatch/pgpass
supervisor="supervise-daemon"
command="$node"
command_args="/usr/local/sbin/trueog-wekan-authwatch.mjs"
command_user="system:system"
directory="/"
pidfile="/run/trueog-wekan-authwatch.pid"
respawn_delay=5
respawn_max=0
output_log="/var/log/wekan/trueog-wekan-authwatch.log"
error_log="/var/log/wekan/trueog-wekan-authwatch.log"
depend() {
need net
after wekan ferretdb postgresql
}
start_pre() {
[ -n "$node" ] && [ -x "$node" ] || { eerror "no Node from node= in /etc/conf.d/wekan"; return 1; }
[ -r /usr/local/sbin/trueog-wekan-authwatch.mjs ] || { eerror "/usr/local/sbin/trueog-wekan-authwatch.mjs is missing"; return 1; }
checkpath -d -m 0755 -o system:system /var/log/wekan
checkpath -f -m 0640 -o system:system /var/log/wekan/trueog-wekan-authwatch.log
}
@@ -0,0 +1,13 @@
# Installed to /etc/logrotate.d/trueog-wekan-authwatch by install-wekan-authwatch.sh. The log only gains a
# line when the service starts, the number of linked accounts changes, an account is locked or unlocked, or
# a problem is first seen (supervise-daemon keeps it open in append mode, hence copytruncate).
/var/log/wekan/trueog-wekan-authwatch.log {
monthly
rotate 12
compress
delaycompress
missingok
notifempty
copytruncate
su system system
}
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env node
// trueog-wekan-authwatch: locks a WeKan account within about a second of its Staff-Auth account being
// deactivated or deleted, and unlocks it when the Staff-Auth account is active again.
//
// Operator 2026-11-11: WeKan signs in through Staff-Auth (Hydra client "TrueOG WeKan"); any active staff
// account may sign in; demotion between roles does not matter for WeKan; deactivation and deletion lock;
// open sessions must end within seconds ("A": through WeKan's own API).
//
// How: once a second it reads uuid and deactivated from Staff-Auth's users table (PostgreSQL role
// wekan_authwatch, SELECT on those two columns only) and every WeKan user linked to Staff-Auth
// (services.oidc.id = the Staff-Auth uuid, which is Hydra's subject). A linked WeKan account whose
// Staff-Auth account is missing or deactivated, confirmed by a second read 0.5 s later, is locked:
// 1. directly in the database, in one conditional update: loginDisabled=true, loginDisabledReason=
// "staff-auth", resume tokens cleared. This is the lock. WeKan refuses that account's sign-in (every
// login type, OIDC included), REST, files and exports from this moment;
// 2. then through WeKan's REST API as the password-less admin account staffauth-lockbot (PUT /api/users/:id
// {action: disableLogin}, the same change made inside WeKan). A write made inside WeKan makes its
// observers re-read at once, so an open browser tab is closed within about 2 s
// (METEOR_POLLING_THROTTLE_MS) instead of at WeKan's next poll (METEOR_POLLING_INTERVAL_MS, 30 s).
// If this step fails the lock from step 1 stands and the tab closes at that next poll.
// Sign-out (operator 2026-11-11: logging out of Staff-Auth must sign people out of the apps too): every few seconds
// the Staff-Auth access token WeKan stored at the person's last sign-in (services.oidc.accessToken; the Hydra client
// "TrueOG WeKan" issues them for 2160 h) is checked with Hydra's introspection on the admin loopback. A token Hydra
// reports inactive (Staff-Auth revoked the person's grants: logout, role change, deactivation; or it expired), twice
// 0.5 s apart, ends that account's WeKan sessions: resume tokens cleared, account NOT disabled, conditional on the
// stored token being unchanged (a fresh sign-in in between wins). An account with sessions but no stored token (from
// before WeKan used Staff-Auth) is signed out too. WeKan then sends the person to Staff-Auth to sign in again. Any
// Hydra error (or a non-200 answer) changes nothing. Open tabs close at WeKan's next poll.
// An account this service locked is unlocked (loginDisabled=false, reason removed, inactivity clock
// restarted as WeKan's own re-enable does) once its Staff-Auth account is active again; the person then
// signs in again. Nothing is ever deleted.
//
// Safeguards:
// - acts only when /opt/wekan/current is a WeKan release listed in REVIEWED_WEKAN (the meaning of
// loginDisabled, the resume tokens and the REST action were read in that version's source);
// otherwise watch only;
// - an account disabled by anyone else (an administrator, WeKan's inactive-user job) is never unlocked,
// and an account already disabled is never touched. Known limit: an administrator who disables an
// account this service had already locked leaves the reason "staff-auth", so it is unlocked when the
// person's Staff-Auth account is active again;
// - no account is exempt (operator 2026-11-11: "no account should be exempt"); Staff-Auth subjects listed in
// TRUEOG_WEKAN_AUTHWATCH_EXEMPT (empty by default, matched by uuid) would never be locked;
// - any database error or an empty Staff-Auth user list ends the pass with nothing changed (backoff);
// - more than MAX_LOCKS locks at once, or locking every lockable account when two or more are linked,
// is refused (a restore or a broken read looks like that) and alerted every 10 minutes until it
// clears, unless the operator creates /etc/trueog-wekan-authwatch/allow-mass-lock (alerted every 10
// minutes while it exists; every installer run removes it).
// Off switch: rc-service trueog-wekan-authwatch stop (Staff-Auth still refuses sign-in for deactivated or
// deleted accounts; only WeKan sessions already open would then stay).
//
// Modes: no argument = run; --check = connect to both databases, one pass without writing, print what
// would change, exit 0 with WEKAN_AUTHWATCH_CHECK_OK; --provision-bot = create or reset the password-less
// WeKan admin account staffauth-lockbot and prove it acts through the REST API (WEKAN_LOCKBOT_OK);
// --remove-bot = delete that account (only one this tool made: marked trueogLockBot).
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { createHash, randomBytes, randomInt } from 'node:crypto';
export const REVIEWED_WEKAN = ['13.06'];
export const REASON = 'staff-auth';
export const BOT_USERNAME = 'staffauth-lockbot';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export function settings(env = process.env) {
return {
wekanHome: env.TRUEOG_WEKAN_HOME || '/opt/wekan',
envFile: env.TRUEOG_WEKAN_ENV_FILE || '/opt/wekan/wekan.env',
wekanUrl: env.TRUEOG_WEKAN_URL || 'http://127.0.0.1:3001',
psql: env.TRUEOG_WEKAN_AUTHWATCH_PSQL || '/usr/bin/psql',
pgConninfo: env.TRUEOG_WEKAN_AUTHWATCH_PG || 'host=127.0.0.1 dbname=staffauth user=wekan_authwatch connect_timeout=3',
pgpass: env.PGPASSFILE || '/etc/trueog-wekan-authwatch/pgpass',
hydraAdmin: env.TRUEOG_WEKAN_AUTHWATCH_HYDRA_ADMIN || 'http://127.0.0.1:4445',
signOutEveryMs: Number(env.TRUEOG_WEKAN_AUTHWATCH_SIGNOUT_MS || 3000),
massLockFile: env.TRUEOG_WEKAN_AUTHWATCH_MASS_LOCK || '/etc/trueog-wekan-authwatch/allow-mass-lock',
exempt: (env.TRUEOG_WEKAN_AUTHWATCH_EXEMPT ?? '')
.split(/[\s,]+/).filter(Boolean).map(x => x.toLowerCase()),
intervalMs: Number(env.TRUEOG_WEKAN_AUTHWATCH_INTERVAL_MS || 1000),
confirmMs: Number(env.TRUEOG_WEKAN_AUTHWATCH_CONFIRM_MS || 500),
maxLocks: 2,
};
}
const log = (...a) => console.log(new Date().toISOString(), ...a);
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Error text may quote a connection string or a token: keep it short and blank those.
export const clean = e => String(e?.message ?? e).replace(/mongodb(\+srv)?:\/\/\S+/g, 'mongodb://...')
.replace(/[A-Za-z0-9_\-]{32,}/g, '...').split('\n')[0].slice(0, 300);
// MONGO_URL from WeKan's own env file (export MONGO_URL='...'); never logged.
export function mongoUrl(envFile) {
const text = fs.readFileSync(envFile, 'utf8');
const m = text.match(/^\s*export\s+MONGO_URL=(?:'([^'\n]*)'|"([^"\n]*)"|([^\s'"#]+))\s*$/m);
const url = m && (m[1] ?? m[2] ?? m[3]);
if (!url || !/^mongodb(\+srv)?:\/\//.test(url)) throw new Error('no MONGO_URL in ' + envFile);
return url;
}
// The WeKan release /opt/wekan/current points at ("13.06"), or null.
export function wekanVersion(wekanHome) {
try {
return path.basename(fs.readlinkSync(path.join(wekanHome, 'current')));
} catch {
return null;
}
}
const bundleRequire = (wekanHome, dir) => createRequire(path.join(wekanHome, 'current/bundle/programs/server/npm/node_modules/meteor', dir, 'index.js'));
export const loadMongo = wekanHome => bundleRequire(wekanHome, 'npm-mongo/node_modules')('mongodb');
// Staff-Auth's accounts: Map(uuid -> active). Throws on any problem, including an empty table.
export function readStaffViaPsql(s) {
return new Promise((resolve, reject) => {
const child = spawn(s.psql, ['-X', '-q', '-A', '-t', '-F', '\t', '-v', 'ON_ERROR_STOP=1', '-d', s.pgConninfo,
'-c', 'select uuid, deactivated from users'], {
env: { PATH: '/usr/bin:/bin', PGPASSFILE: s.pgpass, PGCONNECT_TIMEOUT: '3', LC_ALL: 'C' },
stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000,
});
let out = '', err = '';
child.stdout.on('data', d => { out += d; });
child.stderr.on('data', d => { err += d; });
child.on('error', e => reject(new Error('psql could not run (' + e.code + ')')));
child.on('close', code => {
if (code !== 0) return reject(new Error('psql exit ' + code + ': ' + clean(err)));
try { resolve(parseStaff(out)); } catch (e) { reject(e); }
});
});
}
export function parseStaff(out) {
const staff = new Map();
for (const line of out.split('\n')) {
if (!line) continue;
const [uuid, deactivated, extra] = line.split('\t');
if (extra !== undefined || !UUID.test(uuid) || !['t', 'f'].includes(deactivated)) {
throw new Error('unexpected Staff-Auth row');
}
staff.set(uuid, deactivated === 'f');
}
if (!staff.size) throw new Error('Staff-Auth has no users (refusing to judge)');
return staff;
}
export const subOf = u => {
const id = u?.services?.oidc?.id;
return typeof id === 'string' && UUID.test(id.toLowerCase()) ? id.toLowerCase() : null;
};
// What one pass would do, from two reads of Staff-Auth taken confirmMs apart.
export function decide(users, staff, staffAgain, s, allowMass = false) {
const linked = users.filter(subOf);
const lockable = linked.filter(u => !s.exempt.includes(subOf(u)));
const lock = [], unlock = [], exemptHits = [];
for (const u of linked) {
const sub = subOf(u);
const active = staff.get(sub) === true, activeAgain = staffAgain.get(sub) === true;
if (!active && !activeAgain && u.loginDisabled !== true) {
if (s.exempt.includes(sub)) exemptHits.push(u);
else lock.push(u);
} else if (active && activeAgain && u.loginDisabled === true && u.loginDisabledReason === REASON) {
unlock.push(u);
}
}
let refused = null;
if (!allowMass) {
if (lock.length > s.maxLocks) refused = `${lock.length} accounts at once (limit ${s.maxLocks})`;
else if (lock.length >= 2 && lock.length === lockable.length) refused = `every lockable account (${lock.length})`;
}
return { lock: refused ? [] : lock, unlock, refused, exemptHits, linked: linked.length };
}
export async function lockUser(users, u) {
const r = await users.updateOne(
{ _id: u._id, 'services.oidc.id': u.services.oidc.id, loginDisabled: { $ne: true } },
{ $set: { loginDisabled: true, loginDisabledReason: REASON, 'services.resume.loginTokens': [] } });
return r.modifiedCount === 1;
}
export async function unlockUser(users, u) {
const r = await users.updateOne(
{ _id: u._id, 'services.oidc.id': u.services.oidc.id, loginDisabled: true, loginDisabledReason: REASON },
{ $set: { loginDisabled: false, inactivityClockStart: new Date() }, $unset: { loginDisabledReason: '' } });
return r.modifiedCount === 1;
}
const PROJECTION = { username: 1, loginDisabled: 1, loginDisabledReason: 1, 'services.oidc.id': 1 };
// One pass. ctx: { users, readStaff(), s, version, write, note(key, msg), alert(msg), nudge(id), allowMass() }.
export async function runPass(ctx) {
const { users, s } = ctx;
const allowMass = ctx.allowMass ? ctx.allowMass() : false;
const staff = await ctx.readStaff();
const linked = await users.find({ 'services.oidc.id': { $exists: true } }, { projection: PROJECTION }).toArray();
let d = decide(linked, staff, staff, s, allowMass);
if (!d.lock.length && !d.unlock.length && !d.refused) return { ...d, done: [] };
await sleep(s.confirmMs);
const staffAgain = await ctx.readStaff();
const fresh = await users.find({ 'services.oidc.id': { $exists: true } }, { projection: PROJECTION }).toArray();
d = decide(fresh, staff, staffAgain, s, allowMass);
const done = [];
if (d.refused) {
ctx.alert(`NOT locking ${d.refused}: looks like a restore or a broken read. Check by hand; if it is real, ` +
`create ${s.massLockFile} (and remove it afterwards)`);
}
const reviewed = REVIEWED_WEKAN.includes(ctx.version);
if ((d.lock.length || d.unlock.length) && !reviewed) {
ctx.note('unreviewed', `WeKan ${ctx.version} is not in REVIEWED_WEKAN: watching only (would lock ${d.lock.length}, unlock ${d.unlock.length})`);
return { ...d, done };
}
if (!ctx.write) return { ...d, done };
for (const u of d.lock) {
if (await lockUser(users, u)) {
log(`locked WeKan account ${u.username} (${subOf(u)}): Staff-Auth account deactivated or deleted`);
done.push(['lock', u.username]);
if (ctx.nudge) {
try {
await ctx.nudge(u._id);
log(` WeKan disabled ${u.username} itself too: open sessions close within seconds`);
} catch (e) {
log(` WeKan API nudge failed (${clean(e)}): ${u.username} is locked, open sessions close at WeKan's next poll`);
}
}
}
}
for (const u of d.unlock) {
if (await unlockUser(users, u)) { log(`unlocked WeKan account ${u.username} (${subOf(u)}): Staff-Auth account active again`); done.push(['unlock', u.username]); }
}
return { ...d, done };
}
// --- sign-out: Staff-Auth tokens Hydra no longer accepts ------------------------------------------------------
// true = active, false = inactive; throws on any doubt (network, non-200, malformed answer).
export async function introspect(s, token) {
const r = await fetch(s.hydraAdmin + '/admin/oauth2/introspect', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token }), signal: AbortSignal.timeout(5000), redirect: 'error' });
if (r.status !== 200) throw new Error('Hydra introspection: HTTP ' + r.status);
const j = await r.json();
if (typeof j?.active !== 'boolean') throw new Error('Hydra introspection: no active field');
return j.active;
}
const SIGNOUT_PROJECTION = { username: 1, loginDisabled: 1, 'services.oidc.id': 1, 'services.oidc.accessToken': 1, 'services.resume.loginTokens': 1 };
// Which signed-in, enabled, linked accounts no longer hold a Staff-Auth token Hydra accepts.
export async function signOutCandidates(users, isActive) {
const list = await users.find({ 'services.oidc.id': { $exists: true }, loginDisabled: { $ne: true },
'services.resume.loginTokens.0': { $exists: true } }, { projection: SIGNOUT_PROJECTION }).toArray();
const out = [];
for (const u of list) {
if (!subOf(u)) continue;
const token = u.services?.oidc?.accessToken;
if (typeof token !== 'string' || !token) { out.push({ u, token: null }); continue; }
if (!(await isActive(token))) out.push({ u, token });
}
return out;
}
export async function signOutUser(users, u, token) {
const filter = { _id: u._id, 'services.oidc.id': u.services.oidc.id, loginDisabled: { $ne: true },
'services.oidc.accessToken': token === null ? { $exists: false } : token };
const r = await users.updateOne(filter, { $set: { 'services.resume.loginTokens': [] } });
return r.modifiedCount === 1;
}
// One sign-out pass: candidates confirmed by a second look confirmMs later. ctx as runPass, plus isActive(token).
export async function runSignOutPass(ctx) {
const { users, s } = ctx;
const first = await signOutCandidates(users, ctx.isActive);
if (!first.length) return { signedOut: [], candidates: [] };
await sleep(s.confirmMs);
const again = await signOutCandidates(users, ctx.isActive);
const confirmed = again.filter(c => first.some(f => f.u._id === c.u._id && f.token === c.token));
const signedOut = [];
if (!ctx.write || !REVIEWED_WEKAN.includes(ctx.version)) return { signedOut, candidates: confirmed };
for (const { u, token } of confirmed) {
if (await signOutUser(users, u, token)) {
log(`signed out WeKan account ${u.username} (${subOf(u)}): ` +
(token === null ? 'it had sessions but no Staff-Auth sign-in' : 'its Staff-Auth sign-in ended (logout, revocation or expiry)'));
signedOut.push(u.username);
}
}
return { signedOut, candidates: confirmed };
}
// --- staffauth-lockbot: a WeKan administrator with no password ------------------------------------------
// It cannot sign in anywhere. The service gives it a login token by writing the token's hash straight into
// the account (as Meteor itself stores them) and keeps the token in memory only; a new one replaces the old
// at every start, every 12 hours, and whenever WeKan answers 401/403. Without a password WeKan's 2FA
// requirement does not apply to it (models/lib/twoFactorRequirement.js: password sign-ins only).
const METEOR_ID = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz';
const meteorId = () => Array.from({ length: 17 }, () => METEOR_ID[randomInt(METEOR_ID.length)]).join('');
const BOT_FILTER = { username: BOT_USERNAME, trueogLockBot: true };
// Create the bot, or reset it (administrator, enabled, no password, no tokens). Returns its _id.
export async function provisionBot(users) {
const existing = await users.findOne({ username: BOT_USERNAME });
if (existing) {
if (existing.trueogLockBot !== true) throw new Error(`a WeKan account named ${BOT_USERNAME} exists that this tool did not make`);
await users.updateOne({ _id: existing._id }, {
$set: { isAdmin: true, loginDisabled: false, 'services.resume.loginTokens': [], modifiedAt: new Date() },
$unset: { loginDisabledReason: '', 'services.password': '' } });
return existing._id;
}
const id = meteorId(), now = new Date();
await users.insertOne({
_id: id, createdAt: now, modifiedAt: now, username: BOT_USERNAME, trueogLockBot: true,
emails: [{ address: 'staffauth-lockbot@wekan.invalid', verified: true }],
services: { resume: { loginTokens: [] } },
isAdmin: true, loginDisabled: false, authenticationMethod: 'password', teams: [], orgs: [],
profile: { fullname: 'Staff-Auth lock bot (no password; used by trueog-wekan-authwatch)', initials: 'SA', boardView: 'board-view-swimlanes' },
});
return id;
}
export async function removeBot(users) {
return (await users.deleteOne(BOT_FILTER)).deletedCount;
}
// A fresh login token for the bot (replacing any other); throws if the bot is missing or disabled.
export async function mintBotToken(users) {
const token = randomBytes(32).toString('base64url');
const hashedToken = createHash('sha256').update(token).digest('base64');
const r = await users.updateOne({ ...BOT_FILTER, isAdmin: true, loginDisabled: { $ne: true } },
{ $set: { 'services.resume.loginTokens': [{ hashedToken, when: new Date() }] } });
if (r.matchedCount !== 1) throw new Error(`WeKan account ${BOT_USERNAME} is missing, disabled or not an administrator (run the installer)`);
return token;
}
async function http(url, opts) {
const r = await fetch(url, { ...opts, signal: AbortSignal.timeout(5000), redirect: 'error' });
const text = await r.text();
let body = null;
try { body = JSON.parse(text); } catch { /* not JSON */ }
return { status: r.status, body };
}
export function makeRestClient(s, mint) {
let token = null, mintedAt = 0;
const fresh = async () => { token = await mint(); mintedAt = Date.now(); };
const call = async (method, p, body) => {
for (let attempt = 0; attempt < 2; attempt++) {
if (!token || Date.now() - mintedAt > 12 * 3600e3) await fresh();
const r = await http(s.wekanUrl + p, { method, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
body: body === undefined ? undefined : JSON.stringify(body) });
if ((r.status === 401 || r.status === 403) && attempt === 0) { token = null; continue; }
if (r.status !== 200) throw new Error(`${method} ${p.replace(/[^/]+$/, ':id')}: HTTP ${r.status}`);
return r.body;
}
};
return {
async disableLogin(id) {
const b = await call('PUT', '/api/users/' + encodeURIComponent(id), { action: 'disableLogin' });
if (b?.loginDisabled !== true) throw new Error('WeKan did not report the account disabled');
},
// Signs in with a new token and proves it is the bot and an administrator.
async verify() {
await fresh();
const me = await call('GET', '/api/user');
if (me?.username !== BOT_USERNAME || me?.isAdmin !== true) throw new Error('the token does not act as the admin bot');
},
};
}
async function main() {
const s = settings();
const args = process.argv.slice(2);
const { MongoClient } = loadMongo(s.wekanHome);
const client = new MongoClient(mongoUrl(s.envFile), { serverSelectionTimeoutMS: 5000 });
await client.connect();
const users = client.db().collection('users');
const rest = makeRestClient(s, () => mintBotToken(users));
if (args[0] === '--provision-bot') {
const id = await provisionBot(users);
await rest.verify();
await users.updateOne({ _id: id }, { $set: { 'services.resume.loginTokens': [] } });
await client.close();
console.log(`${BOT_USERNAME} (${id}) is an administrator without a password and acts through the REST API`);
console.log('WEKAN_LOCKBOT_OK');
return;
}
if (args[0] === '--remove-bot') {
console.log(`removed ${await removeBot(users)} account(s) named ${BOT_USERNAME} made by this tool`);
await client.close();
return;
}
const check = args.includes('--check');
const seen = new Map();
const note = (key, msg) => { if (seen.get(key) !== msg) { seen.set(key, msg); log(msg); } };
const lastAlert = new Map();
const alert = (msg, key = 'refused') => {
if (Date.now() - (lastAlert.get(key) || 0) >= 600000) { lastAlert.set(key, Date.now()); log('ALERT ' + msg); }
};
let nudgeOn = false;
try { await rest.verify(); nudgeOn = true; } catch (e) { note('bot', `WeKan API nudge unavailable (${clean(e)}): open sessions close at WeKan's next poll`); }
const ctx = {
users, s, write: !check, note, alert, version: wekanVersion(s.wekanHome),
readStaff: () => readStaffViaPsql(s),
nudge: id => rest.disableLogin(id),
allowMass: () => fs.existsSync(s.massLockFile),
isActive: token => introspect(s, token),
};
if (check) {
let refusedMsg = null;
ctx.alert = m => { refusedMsg = m; };
const r = await runPass(ctx);
const so = await runSignOutPass(ctx);
const exemptNames = (await users.find({ 'services.oidc.id': { $exists: true } }, { projection: PROJECTION }).toArray())
.filter(u => s.exempt.includes(subOf(u))).map(u => u.username);
await users.updateOne(BOT_FILTER, { $set: { 'services.resume.loginTokens': [] } });
console.log(`WeKan ${ctx.version} (${REVIEWED_WEKAN.includes(ctx.version) ? 'reviewed' : 'NOT reviewed: watch only'}), ` +
`${r.linked} linked accounts, exempt [${exemptNames}], would lock [${r.lock.map(u => u.username)}], unlock [${r.unlock.map(u => u.username)}]` +
(refusedMsg ? `, refused: ${r.refused}` : '') + (r.exemptHits.length ? `, exempt and inactive: [${r.exemptHits.map(u => u.username)}]` : '') +
(ctx.allowMass() ? ', MASS-LOCK GUARD OFF' : '') + `, would sign out [${so.candidates.map(c => c.u.username)}]` +
`, API: ${nudgeOn ? 'bot signs in' : 'bot UNAVAILABLE'}`);
await client.close();
console.log('WEKAN_AUTHWATCH_CHECK_OK');
return;
}
let stopping = false, wake = null;
const pause = ms => stopping ? Promise.resolve() : new Promise(r => { const t = setTimeout(r, ms); wake = () => { clearTimeout(t); r(); }; });
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { stopping = true; if (wake) wake(); });
const own = createHash('sha256').update(fs.readFileSync(fileURLToPath(import.meta.url))).digest('hex').slice(0, 16);
log(`started version=${own} : WeKan ${ctx.version}${REVIEWED_WEKAN.includes(ctx.version) ? '' : ' (not reviewed: watch only)'}, ` +
`exempt [${s.exempt}], API nudge ${nudgeOn ? 'on' : 'OFF'}`);
let backoff = 0, lastLinked = -1, lastSignOut = 0;
while (!stopping) {
try {
ctx.version = wekanVersion(s.wekanHome);
if (Date.now() - lastSignOut >= s.signOutEveryMs) { lastSignOut = Date.now(); await runSignOutPass(ctx); }
if (ctx.allowMass()) alert(`${s.massLockFile} exists: the mass-lock guard is OFF; remove the file when done`, 'massfile');
const r = await runPass(ctx);
if (r.linked !== lastLinked) { log(`watching ${r.linked} linked WeKan accounts`); lastLinked = r.linked; }
for (const u of r.exemptHits) note('exempt:' + subOf(u), `exempt account ${u.username}: its Staff-Auth account is deactivated or deleted; not locked`);
if (!r.refused) lastAlert.delete('refused');
if (backoff) { log('recovered'); seen.delete('error'); }
backoff = 0;
await pause(s.intervalMs);
} catch (e) {
backoff = Math.min(backoff ? backoff * 2 : 1000, 30000);
note('error', `pass failed, nothing changed: ${clean(e)} (retrying, backoff up to 30 s)`);
await pause(backoff);
}
}
log('stopping');
await users.updateOne(BOT_FILTER, { $set: { 'services.resume.loginTokens': [] } }).catch(() => {});
await client.close().catch(() => {});
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
main().catch(e => { log('fatal:', clean(e)); process.exit(1); });
}
+124
View File
@@ -0,0 +1,124 @@
# Map for WebSocket upgrades.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Real client IP when behind Cloudflare.
# real_ip_header CF-Connecting-IP;
# set_real_ip_from 173.245.48.0/20;
# set_real_ip_from 103.21.244.0/22;
# ... (add ALL Cloudflare IPv4/IPv6 ranges)
# include /etc/nginx/conf.d/cloudflare-real-ip.conf;
# HTTP: ACME + redirect to canonical HTTPS host.
server {
listen 80;
listen [::]:80;
server_name todo.trueog.net todo.true-og.net;
# Let’s Encrypt HTTP-01 challenge (served on both names).
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type text/plain;
try_files $uri =404;
}
# Everything else: send to canonical HTTPS host.
return 301 https://todo.true-og.net$request_uri;
}
# Canonical HTTPS vhost.
# Must match wekan ROOT_URL.
server {
listen 443 ssl;
#listen [::]:443 ssl;
http2 on;
server_name todo.true-og.net;
ssl_certificate /etc/letsencrypt/live/trueog-combined/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/trueog-combined/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Bigger uploads.
client_max_body_size 100m;
# Reverse proxy defaults for Meteor/Wekan.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Preserve original host/scheme so Meteor builds correct absolute URLs.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Real client IP chain.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeouts / buffering for DDP and SockJS.
proxy_connect_timeout 60s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
# Cache immutable hashed static assets that Wekan serves.
location ~* \.(?:js|css|woff2?|ttf|svg|eot)$ {
proxy_pass http://127.0.0.1:3001;
expires 7d;
add_header Cache-Control "public, max-age=604800, must-revalidate";
}
# Be explicit about Meteor's realtime endpoints.
location /sockjs/ { proxy_pass http://127.0.0.1:3001; }
location /websocket { proxy_pass http://127.0.0.1:3001; }
# Everything else.
location / {
proxy_pass http://127.0.0.1:3001;
# TrueOG 2026-11-11: WeKan signs in only through Staff-Auth and redirects there automatically
# (OIDC_REDIRECTION_ENABLED), but only after its own sign-in page has drawn. This style keeps that page
# (.auth-layout, which only the sign-in pages have) invisible for its first 3 seconds, so people go
# straight to Staff-Auth; if the redirect did not happen, the "Sign in with Staff-Auth" button appears.
# sub_filter needs the uncompressed page, hence Accept-Encoding "". A proxy_set_header here replaces every
# server-level one, so they are all repeated (WebSocket upgrade, host/scheme, real client chain).
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<style>.auth-layout,.textBelowCustomLoginLogo{animation:trueogAuthShow 0s 3s both}@keyframes trueogAuthShow{from{visibility:hidden}to{visibility:visible}}</style></head>';
}
}
# HTTPS vhost for the non-canonical domain -> redirect.
server {
listen 443 ssl;
#listen [::]:443 ssl;
http2 on;
server_name todo.trueog.net;
ssl_certificate /etc/letsencrypt/live/trueog-combined/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/trueog-combined/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://todo.true-og.net$request_uri;
}
+1
View File
@@ -0,0 +1 @@
node="/home/system/.nvm/versions/node/v26.13.0/bin/node"
+106
View File
@@ -0,0 +1,106 @@
#!/sbin/openrc-run
description="Wekan service"
: ${user:="system"}
: ${node:="/home/system/.nvm/versions/node/v24.16.0/bin/node"}
: ${env_file:="/opt/wekan/wekan.env"}
: ${runtime_dir:="/run/wekan"}
: ${log_file:="/var/log/wekan/wekan.log"}
: ${ssd:="/usr/lib/rc/sbin/start-stop-daemon"}
. "$env_file"
command="/usr/bin/setpriv"
command_args="--reuid=$user --regid=$user --init-groups $node current/bundle/main.js"
directory="/opt/wekan"
pidfile="$runtime_dir/wekan.pid"
export HOME="/home/$user"
export USER="$user"
export LOGNAME="$user"
depend() {
after mountall bootmisc networking ferretdb
}
start_pre() {
checkpath -d -m 0750 -o "$user:$user" "$runtime_dir"
checkpath -d -m 0755 -o "$user:$user" "$(dirname "$log_file")"
checkpath -f -m 0644 -o "$user:$user" "$log_file"
# Remove state left by the previous tmux-based service.
if [ -S "$runtime_dir/tmux.sock" ]; then
rm -f "$runtime_dir/tmux.sock"
fi
return 0
}
start() {
if is_running; then
ewarn "Wekan is already running"
return 0
fi
[ -f "$pidfile" ] && rm -f "$pidfile"
ebegin "Starting Wekan"
"$ssd" --start \
--background \
--make-pidfile \
--pidfile "$pidfile" \
--chdir "$directory" \
--exec "$command" \
--stdout "$log_file" \
--stderr "$log_file" \
-- $command_args
eend $? "Failed to start Wekan"
}
stop() {
if ! is_running; then
rm -f "$pidfile"
ewarn "Wekan is not running"
return 0
fi
ebegin "Stopping Wekan"
"$ssd" --stop \
--pidfile "$pidfile" \
--retry TERM/15/KILL/5
local retval=$?
rm -f "$pidfile"
eend "$retval" "Failed to stop Wekan"
}
status() {
if is_running; then
einfo "Wekan is running (PID $(cat "$pidfile"))"
return 0
fi
einfo "Wekan is stopped"
return 3
}
is_running() {
local pid
[ -r "$pidfile" ] || return 1
pid="$(cat "$pidfile")"
case "$pid" in
''|*[!0-9]*) return 1 ;;
esac
kill -0 "$pid" 2>/dev/null || return 1
[ "$(readlink -f "/proc/$pid/exe" 2>/dev/null)" = "$node" ]
}
console() {
touch "$log_file"
exec tail -n 100 -f "$log_file"
}
extra_commands="console"
extra_started_commands="console"
description_console="Follow the Wekan log"
+284
View File
@@ -0,0 +1,284 @@
#!/bin/sh
# WeKan upgrade in steps (operator, 2026-11-10: "13.06 is the latest. update in steps if you have to for my data
# to be safe"): 11.87 -> 11.99 -> 12.99 -> 13.06, one step per run, each backed up, rehearsed and verified.
# doas sh /home/system/gitea-upgrade/upgrade-wekan.sh check read-only: where things stand
# doas sh /home/system/gitea-upgrade/upgrade-wekan.sh step <version> the next step only
# doas sh /home/system/gitea-upgrade/upgrade-wekan.sh restore <backup dir> put a step's backup back (data and release)
# Complete output and UPGRADE_WEKAN_EXIT=<status> go to logs/upgrade-wekan-<phase>-<time>.log.
#
# A step:
# 1. checks that <version> is the next one, its zip matches the pinned sha256, Node 26 exists, WeKan answers
# 2. STOPS WeKan, so the backup is one consistent moment
# 3. snapshots the database (every collection's count, the _ids of documents that must survive) and backs up into
# /opt/wekan/backups/pre-<version>-<time>: a mongodump of the whole `wekan` database, a pg_dump of PostgreSQL's
# ferretdb database (read back in full), /opt/wekan/data, wekan.env and /etc/conf.d/wekan
# 4. rehearses the restore: the mongodump is restored into a scratch database and compared with the snapshot,
# then the scratch database is dropped
# 5. unpacks and checks the release; for 13.06 also applies TrueOG's setBoardTeams patch (app.js.orig-13.06 kept)
# 6. points /etc/conf.d/wekan at Node 26 and /opt/wekan/current at the release, and starts it; from this moment the
# new release may change the data (its one-time upgrade steps)
# 7. waits for /schema-upgrade-status?json to report the upgrade steps finished with no error (up to 30 minutes)
# 7b. requires that report to be for <version>, with no failed step, nothing unresolved, a clean re-check recorded
# 8. verifies: no user, board, card, swimlane, custom field, comment, checklist, attachment, team, org or setting
# is gone; no more broken references (card->board/list/swimlane, list/swimlane->board, comment/checklist->card,
# checklist item->checklist) than before; lists may only vanish by 13.06's duplicate-list merge
# Any failure BEFORE step 6 starts the old release again unchanged. A failure AFTER it stops WeKan and leaves it
# stopped: the old release must not run on data the new one already changed. Then run `restore <backup dir>`
# (the command is printed), which checks the backup's files, drops the database, restores it, puts files, conf.d
# and the previous release back and verifies every collection's count and the surviving kinds' _ids exactly.
SRC=/home/system/gitea-upgrade
[ "$(id -u)" = 0 ] || { echo "run as root: doas sh $0 check|step <version>|restore <backup dir>" >&2; exit 1; }
phase=${1:-}; arg=${2:-}
case "$phase" in
check) [ "$#" = 1 ] || { echo "usage: $0 check" >&2; exit 2; } ;;
step|restore) [ "$#" = 2 ] || { echo "usage: $0 $phase <$( [ "$phase" = step ] && echo version || echo 'backup dir')>" >&2; exit 2; } ;;
*) echo "usage: doas sh $0 check|step <version>|restore <backup dir>" >&2; exit 2 ;;
esac
if [ -z "${UPGRADE_WEKAN_LOGGED:-}" ]; then
mkdir -p "$SRC/logs"
LOG=$SRC/logs/upgrade-wekan-$phase-$(basename "${arg:-x}")-$(date +%F_%H-%M-%S).log
: > "$LOG"; chmod 644 "$LOG"; chown system:system "$LOG"
echo "started $(date -Is): sh $0 $*" >> "$LOG"
UPGRADE_WEKAN_LOGGED=1 sh "$0" "$@" >> "$LOG" 2>&1
rc=$?
echo "finished $(date -Is)" >> "$LOG"
echo "UPGRADE_WEKAN_EXIT=$rc" >> "$LOG"
cat "$LOG"
exit "$rc"
fi
set -eu
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
W=/opt/wekan
INCOMING=$W/incoming
CONFD=/etc/conf.d/wekan
PIDFILE=/run/wekan/wekan.pid
NODE26=/home/system/.nvm/versions/node/v26.13.0/bin/node
STEPS="11.87 11.99 12.99 13.06"
REHEARSAL_DB=wekan_restore_rehearsal
sha_for() {
case "$1" in
11.99) echo 9176473ac61a8abc63224c3aa202729bec583c493ad2458964f085c62ced35f4 ;;
12.99) echo 76468004391d4e643631691f20f786131753f41578885dc93a52accf7480cb0c ;;
13.06) echo 83873c53362d47160e3fb9a7623460647800af6a799d7e9faae5fb3f19ef4836 ;;
*) return 1 ;;
esac
}
commit_for() {
case "$1" in
11.99) echo e09307cda128 ;;
12.99) echo 1f12b20291dc ;;
13.06) echo 77239ee100aa ;;
*) return 1 ;;
esac
}
as_system() { runuser -u system -- "$@"; }
current_version() { basename "$(readlink "$W/current")"; }
next_after() { echo "$STEPS" | tr ' ' '\n' | awk -v c="$1" 'f { print; exit } $0 == c { f = 1 }'; }
answering() { [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:3001/)" = 200 ]; }
db() { # db <bundle dir> <wekan-db.js arguments...>: as system, with wekan.env loaded, the URI never in argv
b=$1; shift
as_system /bin/sh -c 'set -a; . /opt/wekan/wekan.env; set +a; exec "$0" "$@"' "$NODE26" "$SRC/wekan-db.js" "$b" "$@"
}
wekan_pids() { # every running WeKan server process (node <...>/bundle/main.js run as system)
for p in $(pgrep -u system -f 'bundle/main\.js' || true); do
case "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null)" in */bin/node\ current/bundle/main.js*) echo "$p" ;; esac
done
}
stop_wekan() {
rc-service wekan stop >/dev/null 2>&1 || true
n=0
while [ -n "$(wekan_pids)" ]; do
if [ "$n" -eq 30 ]; then for p in $(wekan_pids); do kill -TERM "$p" 2>/dev/null || true; done; fi
if [ "$n" -eq 45 ]; then for p in $(wekan_pids); do kill -KILL "$p" 2>/dev/null || true; done; fi
[ "$n" -lt 60 ] || { echo "WeKan did not stop" >&2; return 1; }
sleep 1; n=$((n + 1))
done
rm -f "$PIDFILE"
rc-service wekan zap >/dev/null 2>&1 || true
}
start_wekan() { rc-service wekan start; }
set_node() { # set_node <path>: change only node= in /etc/conf.d/wekan, keep anything else
tmp=$(mktemp "$CONFD.XXXXXX")
{ [ ! -e "$CONFD" ] || sed '/^node=/d' "$CONFD"; printf 'node="%s"\n' "$1"; } > "$tmp"
chmod 644 "$tmp"; mv -f "$tmp" "$CONFD"
}
upgrade_done() { # upgrade_done <version>: 0 = its upgrade steps ran and finished clean, 1 = still running, 2 = not clean
curl -s --max-time 10 'http://127.0.0.1:3001/schema-upgrade-status?json' | /usr/bin/python3 -I "$SRC/wekan-upgrade-status.py" "$1"
}
CFG=
WORKDB=
cleanup() {
[ -z "$CFG" ] || rm -f "$CFG"
if [ -n "$WORKDB" ]; then db "$W/current/bundle" drop "$WORKDB" >/dev/null 2>&1 || true; fi
}
trap cleanup EXIT
write_cfg() { # a 0600 mongo tools config holding the URI, readable by system only
CFG=$(mktemp /run/wekan-tools.XXXXXX); chmod 600 "$CFG"; chown system:system "$CFG"
as_system /bin/sh -c 'set -a; . /opt/wekan/wekan.env; set +a; exec /usr/bin/python3 -I /home/system/gitea-upgrade/wekan-tools-uri.py' > "$CFG"
}
echo "== state"
cur=$(current_version)
echo "current release: $cur"
echo "WeKan processes: $(wekan_pids | tr '\n' ' ')"
echo "WeKan answers on 127.0.0.1:3001: $(answering && echo yes || echo NO)"
echo "FerretDB: $(rc-service ferretdb status 2>&1 | tail -1)"
echo "Node 26: $($NODE26 --version 2>/dev/null || echo MISSING)"
echo "/etc/conf.d/wekan node: $(grep '^node=' "$CONFD" 2>/dev/null || echo 'not set (Node 24 from the init script)')"
for v in 11.99 12.99 13.06; do
got=$(sha256sum "$INCOMING/wekan-$v-amd64.zip" 2>/dev/null | cut -d' ' -f1 || true)
echo "zip $v: $([ "$got" = "$(sha_for "$v")" ] && echo verified || echo "MISSING OR WRONG")"
done
echo "next step: $(next_after "$cur" || true)"
if [ "$phase" = check ]; then
echo "UPGRADE_WEKAN_CHECK_DONE"
exit 0
fi
if [ "$phase" = restore ]; then
B=$arg
[ -s "$B/manifest.json" ] && [ -d "$B/mongodump" ] && [ -s "$B/previous-release" ] && [ -s "$B/data.tar" ] || { echo "not a complete backup: $B" >&2; exit 1; }
prev=$(cat "$B/previous-release")
dbname=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["database"])' "$B/manifest.json")
[ -d "$W/releases/$prev/bundle" ] || { echo "release $prev is missing" >&2; exit 1; }
echo "== restore $B: database $dbname, files, release $prev"
rm -rf "$W/data.restore-tmp"; mkdir -m 700 "$W/data.restore-tmp"
tar -xpf "$B/data.tar" -C "$W/data.restore-tmp"
[ -d "$W/data.restore-tmp/data" ] && [ "$(ls -A "$W/data.restore-tmp" | wc -l)" = 1 ] || { echo "data.tar does not hold exactly data/" >&2; exit 1; }
[ "$(cd "$W/data.restore-tmp" && find data | wc -l)" = "$(tar -tf "$B/data.tar" | sed 's#/$##' | sort -u | wc -l)" ] || { echo "data.tar did not extract completely" >&2; exit 1; }
stop_wekan
write_cfg
db "$W/releases/$prev/bundle" dropdb "$dbname" "$B/manifest.json"
as_system mongorestore --config="$CFG" --nsInclude="$dbname.*" --quiet "$B/mongodump"
rm -rf "$W/data.before-restore"; mv "$W/data" "$W/data.before-restore"; mv "$W/data.restore-tmp/data" "$W/data"; rm -rf "$W/data.restore-tmp"
if [ -e "$B/conf.d-wekan" ]; then cp -p "$B/conf.d-wekan" "$CONFD"; else rm -f "$CONFD"; fi
ln -sfn "releases/$prev" "$W/current"
db "$W/current/bundle" verify "$B/manifest.json" exact || { echo "the restored database differs from the backup's manifest; WeKan left stopped" >&2; exit 1; }
start_wekan
n=0; until answering; do [ "$n" -lt 600 ] || { echo "WeKan $prev did not answer after the restore" >&2; exit 1; }; sleep 3; n=$((n + 3)); done
echo "restored: database, files and release $prev; previous files kept in $W/data.before-restore"
echo "UPGRADE_WEKAN_RESTORE_OK"
exit 0
fi
# ---- step ----
target=$arg
[ "$(next_after "$cur")" = "$target" ] || { echo "refusing: the next step after $cur is $(next_after "$cur"), not $target" >&2; exit 1; }
zip=$INCOMING/wekan-$target-amd64.zip
[ "$(sha256sum "$zip" | cut -d' ' -f1)" = "$(sha_for "$target")" ] || { echo "refusing: $zip does not match its pinned sha256" >&2; exit 1; }
[ -x "$NODE26" ] || { echo "refusing: $NODE26 is missing" >&2; exit 1; }
answering || { echo "refusing: WeKan does not answer before the step" >&2; exit 1; }
rc-service ferretdb status >/dev/null 2>&1 || { echo "refusing: FerretDB is not running" >&2; exit 1; }
[ "$(df -Pk /opt | awk 'NR==2 {print $4}')" -gt 5242880 ] || { echo "refusing: less than 5 GiB free on /opt" >&2; exit 1; }
B=$W/backups/pre-$target-$(date +%F_%H-%M-%S)
prev_conf_saved=no
PHASE=before-stop
on_exit() {
status=$?
trap - EXIT
cleanup
if [ "$status" != 0 ]; then
case "$PHASE" in
stopped)
echo "step failed before $target started: starting $cur again, unchanged" >&2
if [ "$prev_conf_saved" = yes ]; then if [ -e "$B/conf.d-wekan" ]; then cp -p "$B/conf.d-wekan" "$CONFD"; else rm -f "$CONFD"; fi; fi
ln -sfn "releases/$cur" "$W/current"
start_wekan || true
n=0; until answering; do [ "$n" -lt 600 ] || break; sleep 3; n=$((n + 3)); done
echo "$cur $(answering && echo is answering again || echo 'is NOT answering: see /var/log/wekan/wekan.log')" >&2 ;;
started)
echo "STEP FAILED after $target started and may have changed the data: WeKan is stopped and stays stopped." >&2
stop_wekan || true
echo "Read the reason above. To put database, files and release back as they were before this step:" >&2
echo " doas sh $SRC/upgrade-wekan.sh restore $B" >&2
echo "(or, if the reason is harmless, start $target as it is: doas rc-service wekan start)" >&2 ;;
esac
fi
exit "$status"
}
trap on_exit EXIT
echo "== stop WeKan $cur for a consistent backup"
stop_wekan
PHASE=stopped
echo "== backup into $B"
install -d -m 700 -o system -g system "$B"
echo "$cur" > "$B/previous-release"
[ ! -e "$CONFD" ] || cp -p "$CONFD" "$B/conf.d-wekan"
prev_conf_saved=yes
cp -p "$W/wekan.env" "$B/wekan.env"
db "$W/current/bundle" snapshot "$B/manifest.json"
dbname=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["database"])' "$B/manifest.json")
write_cfg
as_system mongodump --config="$CFG" --db="$dbname" --out="$B/mongodump" --quiet
want=$(/usr/bin/python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["counts"]))' "$B/manifest.json")
got=$(find "$B/mongodump/$dbname" -name '*.bson' | wc -l)
[ "$got" = "$want" ] || { echo "the mongodump has $got collections, the database $want" >&2; exit 1; }
su -s /bin/sh postgres -c "pg_dump -Fc -d ferretdb" > "$B/ferretdb.pgdump"
su -s /bin/sh postgres -c "pg_restore -f /dev/null" < "$B/ferretdb.pgdump"
tar -cpf "$B/data.tar" -C "$W" data
tar -tf "$B/data.tar" > /dev/null
chown -R system:system "$B"; chmod -R go-rwx "$B"
echo "backup: mongodump $got collections ($(du -sh "$B/mongodump" | cut -f1)), ferretdb pg_dump $(du -h "$B/ferretdb.pgdump" | cut -f1) read back in full, files $(tar -tf "$B/data.tar" | wc -l) entries, env files"
echo "== restore rehearsal into $REHEARSAL_DB"
WORKDB=$REHEARSAL_DB
db "$W/current/bundle" drop "$REHEARSAL_DB" >/dev/null
as_system mongorestore --config="$CFG" --nsFrom="$dbname.*" --nsTo="$REHEARSAL_DB.*" --nsInclude="$dbname.*" --quiet "$B/mongodump"
rehearsed=$(db "$W/current/bundle" counts "$REHEARSAL_DB")
/usr/bin/python3 -c '
import json, sys
m = json.load(open(sys.argv[1]))["counts"]; r = json.loads(sys.argv[2])
bad = [f"{k}: {v} vs {r.get(k)}" for k, v in m.items() if r.get(k) != v]
if bad:
print("REHEARSAL MISMATCH: " + "; ".join(bad)); sys.exit(1)
print(f"rehearsal: all {len(m)} collections restored with identical counts")' "$B/manifest.json" "$rehearsed"
db "$W/current/bundle" drop "$REHEARSAL_DB"
WORKDB=
echo "== release $target"
R=$W/releases/$target
tmp=$(mktemp -d "$W/releases/.unpack-$target.XXXXXX"); chown system:system "$tmp"
as_system unzip -q "$zip" -d "$tmp"
[ ! -e "$R" ] || mv "$R" "$R.replaced-$(date +%F_%H-%M-%S)"
mv "$tmp" "$R"
as_system /usr/bin/python3 -c '
import json, sys
s = json.load(open(sys.argv[1]))
assert s["nodeVersion"].startswith("26."), s["nodeVersion"]
assert s.get("gitCommitHash", "").startswith(sys.argv[2]), "unexpected build " + s.get("gitCommitHash", "")
print("release", sys.argv[3], s["meteorRelease"], "node", s["nodeVersion"], "commit", s["gitCommitHash"][:12])' "$R/bundle/star.json" "$(commit_for "$target")" "$target"
[ -f "$R/bundle/main.js" ]
if [ "$target" = 13.06 ]; then
as_system /usr/bin/python3 "$SRC/wekan-patch-13.06.py" "$R/bundle/programs/server/app/app.js"
as_system "$NODE26" --check "$R/bundle/programs/server/app/app.js"
fi
echo "== start WeKan $target"
set_node "$NODE26"
ln -sfn "releases/$target" "$W/current"
PHASE=started
start_wekan
echo "waiting for the one-time upgrade steps to finish (up to 30 minutes)"
n=0
while :; do
[ -n "$(wekan_pids)" ] || { echo "WeKan $target exited" >&2; tail -n 40 /var/log/wekan/wekan.log >&2; exit 1; }
rcode=0; report=$(upgrade_done "$target") || rcode=$?
[ "$rcode" = 0 ] && break
[ "$rcode" = 2 ] && { echo "$report"; echo "the upgrade steps did not finish clean" >&2; exit 1; }
[ "$n" -lt 1800 ] || { echo "the upgrade steps did not finish within 30 minutes" >&2; exit 1; }
sleep 5; n=$((n + 5))
done
echo "$report"
answering || { echo "WeKan $target does not answer on /" >&2; exit 1; }
echo "== verify the data"
db "$W/current/bundle" verify "$B/manifest.json" upgrade
PHASE=done
trap - EXIT
cleanup
echo "WeKan $target is running ($(wekan_pids | tr '\n' ' ')); backup and restore command: doas sh $SRC/upgrade-wekan.sh restore $B"
nxt=$(next_after "$target")
if [ -n "$nxt" ]; then echo "next: open https://todo.true-og.net and check your boards, then: doas sh $SRC/upgrade-wekan.sh step $nxt"
else echo "this was the last step: open https://todo.true-og.net and check your boards"; fi
echo "UPGRADE_WEKAN_STEP_OK $target"
+101
View File
@@ -0,0 +1,101 @@
// WeKan database checks for upgrade-wekan.sh, read-only except where noted. The database address comes from MONGO_URL
// and is never printed. Usage: node wekan-db.js <bundle dir> <command> [args]
// name the database MONGO_URL selects
// snapshot <out.json> every collection's count, and the _ids of the collections whose documents must survive
// verify <manifest> upgrade after an upgrade: no surviving-kind document is gone, no more broken references than
// in the snapshot, lists may only disappear if nothing points at them (13.06 merges lists)
// verify <manifest> exact after a restore: the same collections with the same counts and the same _ids
// dropdb <db name> <manifest> drop the WeKan database before a restore; only the manifest's database is accepted
// counts <db name> counts of every collection of another database (the restore rehearsal)
// drop <db name> drop that database; only names ending in _restore_rehearsal are accepted
const path = require("path");
const fs = require("fs");
const { MongoClient } = require(path.join(process.argv[2], "programs/server/npm/node_modules/meteor/npm-mongo/node_modules/mongodb"));
const KEEP = ["users", "boards", "cards", "swimlanes", "customFields", "card_comments", "checklists", "checklistItems",
"attachments", "avatars", "team", "org", "settings", "rules", "triggers", "actions", "integrations", "accountSettings"];
const REFS = [["cards", "boardId", "boards"], ["cards", "listId", "lists"], ["cards", "swimlaneId", "swimlanes"],
["lists", "boardId", "boards"], ["swimlanes", "boardId", "boards"], ["card_comments", "cardId", "cards"],
["checklists", "cardId", "cards"], ["checklistItems", "checklistId", "checklists"],
["activities", "listId", "lists"]];
async function broken(db) { // references with a non-empty id that points at no document
const out = {};
for (const [from, field, to] of REFS) {
const target = new Set((await db.collection(to).find({}, { projection: { _id: 1 } }).toArray()).map(x => String(x._id)));
let n = 0;
for await (const d of db.collection(from).find({}, { projection: { [field]: 1 } })) {
const v = d[field];
if (v !== undefined && v !== null && v !== "" && !target.has(String(v))) n++;
}
out[`${from}.${field}->${to}`] = n;
}
return out;
}
const clean = m => String(m).replace(/mongodb:\/\/[^@\s]*@/g, "mongodb://***@");
(async () => {
const [cmd, a1, a2] = process.argv.slice(3);
const client = await MongoClient.connect(process.env.MONGO_URL, { serverSelectionTimeoutMS: 15000 });
const db = client.db();
const names = async d => (await d.listCollections({}, { nameOnly: true }).toArray()).map(c => c.name).filter(n => !n.startsWith("system.")).sort();
const ids = async (d, n) => (await d.collection(n).find({}, { projection: { _id: 1 } }).toArray()).map(x => String(x._id)).sort();
try {
if (cmd === "name") { console.log(db.databaseName); return; }
if (cmd === "snapshot") {
const m = { database: db.databaseName, at: new Date().toISOString(), counts: {}, ids: {} };
for (const n of await names(db)) m.counts[n] = await db.collection(n).countDocuments();
for (const n of Object.keys(m.counts)) m.ids[n] = await ids(db, n);
m.broken = await broken(db);
fs.writeFileSync(a1, JSON.stringify(m), { mode: 0o600 });
console.log(`snapshot of ${Object.keys(m.counts).length} collections: ` + JSON.stringify(Object.fromEntries(Object.entries(m.counts).filter(([k]) => ["users", "boards", "lists", "swimlanes", "cards", "customFields", "activities"].includes(k)))));
return;
}
if (cmd === "verify") {
const m = JSON.parse(fs.readFileSync(a1, "utf8"));
if (m.database !== db.databaseName) throw new Error(`manifest is for database ${m.database}, connected to ${db.databaseName}`);
const problems = [];
for (const [n, before] of Object.entries(m.ids)) {
if (a2 !== "exact" && !KEEP.includes(n) && n !== "lists") continue;
const now = new Set(await ids(db, n));
const gone = before.filter(i => !now.has(i));
if (a2 === "exact") {
if (gone.length || now.size !== before.length) problems.push(`${n}: ${before.length} before, ${now.size} now, ${gone.length} missing`);
} else if (gone.length && n !== "lists") problems.push(`${n}: ${gone.length} of ${before.length} documents gone`);
else if (gone.length) console.log(`lists: ${gone.length} of ${before.length} merged away (allowed only if no card points at them)`);
}
if (a2 === "exact") {
const now = {};
for (const n of await names(db)) now[n] = await db.collection(n).countDocuments();
for (const n of new Set([...Object.keys(m.counts), ...Object.keys(now)]))
if (m.counts[n] !== now[n]) problems.push(`collection ${n}: ${m.counts[n]} in the backup, ${now[n]} now`);
} else {
const b = await broken(db);
for (const [k, v] of Object.entries(b)) if (v > ((m.broken || {})[k] || 0)) problems.push(`${k}: ${v} broken references, ${(m.broken || {})[k] || 0} before`);
console.log("broken references (before -> now): " + Object.entries(b).map(([k, v]) => `${k} ${(m.broken || {})[k] || 0}->${v}`).join(", "));
}
const counts = {};
for (const n of ["users", "boards", "lists", "swimlanes", "cards", "customFields", "activities"]) counts[n] = await db.collection(n).countDocuments();
console.log("counts now: " + JSON.stringify(counts));
if (problems.length) { console.log("VERIFY_FAILED " + problems.join("; ")); process.exitCode = 1; } else console.log("VERIFY_OK");
return;
}
if (cmd === "counts") {
const d = client.db(a1), out = {};
for (const n of await names(d)) out[n] = await d.collection(n).countDocuments();
console.log(JSON.stringify(out));
return;
}
if (cmd === "dropdb") {
const m = JSON.parse(fs.readFileSync(a2, "utf8"));
if (a1 !== m.database || a1 !== db.databaseName) throw new Error(`refusing to drop ${a1}: not the manifest's and MONGO_URL's database`);
await client.db(a1).dropDatabase();
console.log("dropped " + a1 + " for the restore");
return;
}
if (cmd === "drop") {
if (!/_restore_rehearsal$/.test(a1)) throw new Error("refusing to drop " + a1);
await client.db(a1).dropDatabase();
console.log("dropped " + a1);
return;
}
throw new Error("unknown command " + cmd);
} finally { await client.close(); }
})().catch(e => { console.error("DB_ERROR " + clean(e.message)); process.exit(1); });
+52
View File
@@ -0,0 +1,52 @@
"""TrueOG patches for WeKan 13.06's compiled server code (bundle/programs/server/app/app.js). Each patch is applied
once (marker), only if its anchor is found exactly once; app.js.orig-13.06 keeps the original.
setBoardTeams: before members are reconciled, add every user of an active team assigned to the board, since a
board admin who is not a site admin cannot see other users' teams in the browser. Same change as
server/models/boards.js in trueog/wekan.
oidcSignup (2026-11-11, operator: "staff-auth sign ups only, disable password registration"): with registration
disabled, Users.after.insert demanded an invitation code for every new OAuth2 account, so a first Staff-Auth
sign-in created the account and then failed ("error-invitation-code-not-exist"). Accounts created by the OIDC
service (services.oidc, set only by WeKan's server-side OIDC login) skip that check; Staff-Auth decides who gets
in. Password registration stays closed. Same change as server/models/users.js in trueog/wekan."""
import pathlib, shutil, sys
p = pathlib.Path(sys.argv[1])
orig = p.with_name("app.js.orig-13.06")
PATCHES = [
("setBoardTeams",
"let l=e.filter(e=>e.isActive).map(e=>e.teamId),d=t.map(e=>e.userId).filter(e=>!n.members.some(t=>t&&t.userId===e)),c=new Map",
"/*trueog-setBoardTeams*/"
"{let r=e.filter(e=>!1!==e.isActive).map(e=>e.teamId);if(r.length>0)for(let s of await v.Meteor.users.find("
'{"teams.teamId":{$in:r}},{fields:{_id:1}}).fetchAsync())t.some(e=>e.userId===s._id)||t.push({userId:s._id,'
"isAdmin:!1,isActive:!0,isNoComments:!1,isCommentOnly:!1,isWorker:!1})}",
"before"),
("oidcSignup",
'if("ldap"!==t.authenticationMethod&&r){let e=null;if("string"==typeof t.authenticationMethod&&"oauth2"===',
'if("ldap"!==t.authenticationMethod&&r&&!/*trueog-oidcSignup*/(t.services&&t.services.oidc)){let e=null;'
'if("string"==typeof t.authenticationMethod&&"oauth2"===',
"replace"),
]
s = p.read_text()
changed = []
for name, anchor, text, how in PATCHES:
marker = f"/*trueog-{name}*/"
if marker in s:
want = text + anchor if how == "before" else text
if s.count(marker) == 1 and s.count(want) == 1:
print(f"{name}: already patched")
continue
sys.exit(f"{name}: app.js has the marker but not exactly the reviewed patch; nothing written")
if s.count(anchor) != 1:
sys.exit(f"{name}: anchor found {s.count(anchor)} times, expected 1; nothing written")
s = s.replace(anchor, text + anchor if how == "before" else text)
changed.append(name)
if not changed:
print("app.js already has every patch")
sys.exit(0)
if not orig.exists():
shutil.copy2(p, orig)
p.chmod(0o644)
p.write_text(s)
p.chmod(0o444)
print("app.js patched (" + ", ".join(changed) + "), original kept as " + orig.name)
+9
View File
@@ -0,0 +1,9 @@
"""Prints a mongo tools config line for MONGO_URL without its database path, so mongodump and mongorestore work on
named databases; authSource keeps the URI's database (the default it implied), so authentication is unchanged."""
import json, os, urllib.parse
u = urllib.parse.urlsplit(os.environ["MONGO_URL"])
q = urllib.parse.parse_qsl(u.query, keep_blank_values=True)
db = u.path.lstrip("/")
if db and not any(k.lower() == "authsource" for k, _ in q):
q.append(("authSource", db))
print("uri: " + json.dumps(urllib.parse.urlunsplit((u.scheme, u.netloc, "/", urllib.parse.urlencode(q), u.fragment))))
+33
View File
@@ -0,0 +1,33 @@
"""Reads WeKan's /schema-upgrade-status?json on stdin. Usage: wekan-upgrade-status.py <version, e.g. 13.06>
Exit 0: the upgrade steps of that version ran and finished clean; 1: not finished yet (or no answer); 2: not clean."""
import json, sys
try:
s = json.load(sys.stdin)
except Exception:
sys.exit(1)
if s.get("running") or not s.get("finishedAt"):
sys.exit(1)
want = "v" + sys.argv[1] + ".0"
steps = s.get("steps") or {}
for name, st in steps.items():
line = " upgrade step {}: {}, fixed {}, unresolved {}".format(name, st.get("status"), st.get("fixed", 0), st.get("unresolved", 0))
if st.get("error"):
line += ", error " + str(st.get("error"))
print(line)
last = (s.get("lastCheck") or {}).get("version")
print(" version {}, finished {}, gated {}, clean re-check recorded for {}".format(s.get("appVersion"), s.get("finishedAt"), s.get("gated"), last))
problems = []
if s.get("appVersion") != want:
problems.append("version is {}, expected {}".format(s.get("appVersion"), want))
if s.get("gated"):
problems.append("the upgrade steps were skipped (gated), not run")
if any(st.get("status") == "error" for st in steps.values()):
problems.append("a step failed")
if any(st.get("unresolved") for st in steps.values()):
problems.append("a step left unresolved items")
if last != want:
problems.append("no clean re-check was recorded for " + want)
if problems:
print(" NOT CLEAN: " + "; ".join(problems))
sys.exit(2)
sys.exit(0)
+51
View File
@@ -0,0 +1,51 @@
# /opt/wekan/wekan.env as deployed on the TrueOG server, with secrets replaced by <placeholders>.
# Generated from the live file; never commit the real one (it is mode 600, owner system).
export MONGO_URL='mongodb://<user>:<password>@127.0.0.1:27017/wekan?authSource=<db>'
export ROOT_URL='https://todo.true-og.net'
# Listen on loopback only: nginx proxies to 127.0.0.1:3001 (todo.true-og.net).
# Meteor's webapp uses process.env.BIND_IP || '0.0.0.0'.
export BIND_IP=127.0.0.1
export PORT=3001
export MAIL_URL='smtps://<user>:<password>@<mail host>:465/'
export MAIL_FROM='Wekan Automated Message <admin@true-og.net>'
export WRITABLE_PATH='/opt/wekan/data'
export METEOR_REACTIVITY_ORDER='polling'
# Added for WeKan 10.99 (2026-09-19):
# Exports (PDF/Excel/JSON/CSV) are served from /api/... and refused unless WITH_API=true.
export WITH_API=true
# FerretDB has no oplog/change streams, so Meteor polls; calmer re-poll cadence (WeKan launcher defaults).
export METEOR_POLLING_THROTTLE_MS=2000
export METEOR_POLLING_INTERVAL_MS=30000
# Staff-Auth sign-in (2026-11-11): Hydra client "TrueOG WeKan" (/root/hydra-client-wekan.json), client_secret_post.
# Any active Staff-Auth account may sign in; existing accounts are linked by services.oidc.id = Staff-Auth uuid.
# Staff-Auth is the only sign-in (password login removed 2026-11-11). Deactivation/deletion locks via trueog-wekan-authwatch.
export OAUTH2_ENABLED='true'
export OAUTH2_LOGIN_STYLE='redirect'
export OAUTH2_CLIENT_ID='<client id from /root/hydra-client-wekan.json>'
export OAUTH2_SECRET='<client secret from /root/hydra-client-wekan.json>'
export OAUTH2_SERVER_URL='https://staff-auth.true-og.net'
export OAUTH2_AUTH_ENDPOINT='/oauth2/auth'
export OAUTH2_TOKEN_ENDPOINT='/oauth2/token'
export OAUTH2_USERINFO_ENDPOINT='/userinfo'
export OAUTH2_REQUEST_PERMISSIONS='openid profile email'
export OAUTH2_ID_MAP='sub'
export OAUTH2_USERNAME_MAP='preferred_username'
export OAUTH2_FULLNAME_MAP='name'
export OAUTH2_EMAIL_MAP='email'
# Real client address (2026-11-11). nginx (conf.d/cloudflare-realip.conf: real_ip_header CF-Connecting-IP from
# Cloudflare's ranges only) appends the visitor's address as the LAST X-Forwarded-For entry, so trust exactly one
# hop. Without this every request looked like 127.0.0.1: one shared REST-login throttle key for the whole internet.
# WeKan listens on 127.0.0.1 only (BIND_IP), so nobody can reach it without nginx writing that entry.
export HTTP_FORWARDED_COUNT=1
# Operator 2026-11-11: WeKan behaves like Gitea: a signed-out visit goes straight to Staff-Auth sign-in (one
# automatic attempt per browser tab; if it fails, the same tab shows the sign-in page with the password fallback).
export OIDC_REDIRECTION_ENABLED=true
# Operator 2026-11-11: "I dont want wekan's sign in page at all, except a link to sign in with staff-auth like
# gitea has". The password form is hidden; linked accounts have no stored password (removed, backup
# /opt/wekan/backups/pre-nopassword-*), so the server refuses password logins as well. No fallback: if
# Staff-Auth/Hydra is down, WeKan sign-in waits for it.
export PASSWORD_LOGIN_ENABLED=false