Compare commits

...
38 Commits
Author SHA1 Message Date
github-actions[bot] d200234535 Bump versions for v12.83 2026-11-02 11:40:29 +00:00
Lauri Ojansivu 8548bae527 Prepare v12.83 release 2026-11-02 13:38:58 +02:00
Lauri Ojansivu d6b2d55c36 Merge remote-tracking branch 'origin/main' 2026-11-02 13:35:16 +02:00
Lauri Ojansivu f7e3533b13 CHANGELOG: LDAP account binding. 2026-11-02 13:32:03 +02:00
Lauri Ojansivu e91c14cd9a LDAP: a reused login name no longer opens the previous owner's account.
An account found by username or e-mail that records another directory
entry's identifier (same attribute, other value) is refused at login and
skipped by the import. An existing account now records its identifier and
its attribute, so LDAP_UNIQUE_IDENTIFIER_FIELD takes effect for it.
LDAP_EMAIL_MATCH_REQUIRE looks users up by username instead of _id, which
never matched. The background sync skips an account whose username matches
several entries instead of binding it to the first.

Thanks to Raymond-Voogt and xet7 !

Fixes #6859,
2026-11-02 13:31:40 +02:00
github-actions[bot] e7439e1d45 Bump versions for v12.82 2026-11-02 11:00:45 +00:00
Lauri Ojansivu 4a34b91ecf Prepare v12.82 release 2026-11-02 12:59:36 +02:00
Lauri Ojansivu d9984d2069 Merge remote-tracking branch 'origin/main' 2026-11-02 12:54:11 +02:00
Lauri Ojansivu e982282954 CHANGELOG: card numbers on a move, a restore and a set number. 2026-11-02 12:42:43 +02:00
Lauri Ojansivu e68938698c Card numbers: a card moved to another board, restored or given a number cannot take one another card has.
#6815 checked the number of every new card, but a card also enters a board's
numbering by an update: a move from the browser, which computed the
destination number from the cards it had loaded (often none, so 1), a restore
from the archive, and a number set by the REST API or an import. A before-update
hook now does what the insert hook does: a taken number is replaced by a fresh
one from the board's counter, a free one is kept and the counter raised past it.

Thanks to Raymond-Voogt and xet7 !

Fixes #6858,
2026-11-02 12:42:33 +02:00
github-actions[bot] 55299a5172 Bump versions for v12.81 2026-11-02 10:22:59 +00:00
Lauri Ojansivu 8bbc6fcda1 Prepare v12.81 release 2026-11-02 12:20:06 +02:00
Lauri Ojansivu 71463fb445 Merge remote-tracking branch 'origin/main' 2026-11-02 12:17:05 +02:00
Lauri Ojansivu b60a1e5a85 CHANGELOG: the database health probe reports a finding once. 2026-11-02 12:15:29 +02:00
Lauri Ojansivu 114e9ecd5a Database health: one server probes the database per period, so a finding is reported once.
Every server read the same MongoDB log and counters and recorded each
finding itself: one collection scan became up to 24 identical rows in Admin
Panel / Problems on 24 replicas. A probe now claims the period in a shared
document, and the state that compares one probe with the next lives there.
A claim that cannot be made at all does not stop the probe.

Thanks to Raymond-Voogt and xet7 !

Fixes #6857,
2026-11-02 12:15:09 +02:00
github-actions[bot] c6739fe452 Bump versions for v12.80 2026-11-02 10:01:31 +00:00
Lauri Ojansivu efd45c651e Prepare v12.80 release 2026-11-02 12:00:25 +02:00
Lauri Ojansivu fdc2c57b8f Merge remote-tracking branch 'origin/main' 2026-11-02 11:52:22 +02:00
Lauri Ojansivu 2c75b95908 CHANGELOG: minimatch 11.0.4 for GHSA-g3hv-75wg-m249. 2026-11-02 11:42:44 +02:00
Lauri Ojansivu a3630d96ce Update minimatch to 11.0.4, GHSA-g3hv-75wg-m249 (Dependabot alerts #180 and #182).
minimatch before 11.0.1 grows its glob parts without bound while it
preprocesses a pattern, then compares them pairwise. archiver (readdir-glob),
@typescript-eslint/typescript-estree and @babel/cli's glob used 10.2.6; they
now use the root minimatch 11.0.4.

Thanks to dependabot and xet7 !
2026-11-02 11:41:52 +02:00
github-actions[bot] db2aafe16c Bump versions for v12.79 2026-11-02 09:35:57 +00:00
Lauri Ojansivu c4b90d08a0 Prepare v12.79 release 2026-11-02 11:34:58 +02:00
Lauri Ojansivu cb5278ddb3 CHANGELOG: a collection-scan row says which query it is. 2026-11-02 11:31:54 +02:00
Lauri Ojansivu bc79de8c57 Database health: a collection scan row names the command, the operators per field and the queryHash.
A row said only which fields a slow query filtered on, which is not enough
to find the query in the code or to run explain(). It now shows the command
(for a slow getMore, the query it continues, instead of 'filter on nothing'),
the operators used on each field - never values - and MongoDB's queryHash.

Thanks to Raymond-Voogt and xet7 !

Fixes #6856,
2026-11-02 11:31:47 +02:00
Lauri Ojansivu 81714cd695 CHANGELOG: the schema re-check and due-date reminders run once. 2026-11-02 11:28:59 +02:00
Lauri Ojansivu d42369e96a Full collection scans run once, not at every start of every server; due-date reminders are sent once.
The startup schema re-check claims a run in _wekan_migration so one server
re-checks while the others skip (a claim older than 2 hours is taken over),
and a run that leaves work unresolved is retried once a day instead of at
every start. The daily due-date reminder scan is claimed per day in
dueReminderRuns, so 24 replicas no longer send 24 reminders per card.
A partial index on lists serves the 15-minute list sync scan, and a sparse
index on boards.dueReminderDays serves the due-date lookup.

Thanks to Raymond-Voogt and xet7 !

Fixes #6855,
2026-11-02 11:28:44 +02:00
Lauri Ojansivu efff590f74 CHANGELOG: the announcement shows on Home and All Boards. 2026-11-02 11:26:12 +02:00
Lauri Ojansivu 64b5762020 Announcement: shown on Home and every All Boards list, not only on a board.
The banner waited for membership of the current board, and Home, Remaining,
Starred, Templates and the other All Boards pages have none. It now shows to
every signed-in user there, and to the members of the open board on a board.
Signed-out visitors still do not see it.

Thanks to titver968 and xet7 !

Fixes #6854,
2026-11-02 11:25:57 +02:00
Lauri Ojansivu c29a59eb95 CHANGELOG: any year by number in the date picker. 2026-11-02 11:17:04 +02:00
Lauri Ojansivu dcaa316a01 Date picker popup: the year is a number field, any year by typing or stepping.
A list of years had to end somewhere. The year is now a whole number
(0 is 1 BC) found by jumping through the calendar in use, so thousands of
years back or ahead are one entry away, up to the range a date holds.

Thanks to brlin-tw and xet7 !

Fixes #6853,
2026-11-02 11:17:02 +02:00
Lauri Ojansivu 7721c5f655 CHANGELOG: year, month and day drop-downs in the date picker. 2026-11-02 11:10:08 +02:00
Lauri Ojansivu 492895200b Date picker popup: year, month and day drop-downs under the calendar.
The heading above the calendar shows the month and year only, not the
YYYY-MM-DD of the selected day. Under the calendar, year, month and day
drop-downs pick any past, present or future date, in every calendar
system.

Thanks to brlin-tw and xet7 !

Fixes #6853,
2026-11-02 11:09:34 +02:00
Lauri Ojansivu 1b346dc465 CHANGELOG: the date picker heading names the year. 2026-11-02 10:50:22 +02:00
Lauri Ojansivu 45c291b406 Date picker popup: the heading names the year as well as the month.
Thanks to brlin-tw and xet7 !

Fixes #6853,
2026-11-02 10:50:13 +02:00
Lauri Ojansivu c3af2a08a9 CHANGELOG: the macOS sandbox keychain and Claude Code token. 2026-11-02 10:16:03 +02:00
Lauri Ojansivu 21c94ce098 Fix macOS Sandbox for VSCodium.
Thanks to xet7 !
2026-11-02 10:14:47 +02:00
Lauri Ojansivu 2d8a28c2f6 Merge remote-tracking branch 'origin/main' 2026-11-02 09:44:15 +02:00
Lauri Ojansivu c58b2896d1 Fix macOS Sandbox for VSCodium.
Thanks to xet7 !
2026-11-02 09:43:32 +02:00
46 changed files with 1757 additions and 129 deletions

No files matched your search

+266
View File
@@ -124,6 +124,272 @@ its reason.
</details>
# v12.83 2026-11-02 WeKan ® release
**In short:** An **LDAP** login with a reused login name no longer opens the
previous owner's account, and existing accounts record their directory
identifier.
This release fixes the following bugs:
**LDAP** - matching a directory entry to a WeKan account at login, import and
background sync.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/e91c14cd9a">A reused login name no longer opens the previous owner's account</a>. Thanks to Raymond-Voogt and xet7.</summary>
[\#6859](https://github.com/wekan/wekan/issues/6859). The login looked for the
account by the entry's unique identifier, then by username, then with
LDAP_EMAIL_MATCH_ENABLE by e-mail, and the last two did not check whether the
account already belonged to another directory entry. When a directory gave a
deleted employee's sAMAccountName to a new colleague, the new colleague logged
into the old account and saw its boards, cards and comments; two entries
sharing an e-mail address did the same, and so did the import with
LDAP_MERGE_EXISTING_USERS. An account that records an identifier of the same
attribute with a different value is now refused at login ("LDAP
authentication failed") and skipped by the import. An account without an
identifier, with an identifier of another attribute, or with an identifier
without its attribute (made before \#4654) is still accepted, so switching
LDAP_UNIQUE_IDENTIFIER_FIELD to objectGUID locks nobody out. When that field is
empty the identifier is the login name itself, so a reused name cannot be
detected: set it to objectGUID or entryUUID.
Three more faults in the same matching are fixed. An existing account never
recorded services.ldap.id and its attribute (only a new account did), so
accounts from before \#4654 never got an attribute and a changed
LDAP_UNIQUE_IDENTIFIER_FIELD never took effect; the login and the sync now
record both. LDAP_EMAIL_MATCH_REQUIRE looked the user up with _id equal to the
username, which never matches a random id, so every existing user's login
tried to create the account again; it uses the username. The background sync
bound an account without an identifier to the first of several entries with
its name; it now skips that account, neither updating nor disabling it.
`tests/ldapAccountBinding.test.cjs` pins each of these, and that no lookup by
_id equal to the username is left in the package and no name or e-mail match
reaches the login without the check. A refused login is not recorded in
Admin Panel -> Problems: the LDAP package cannot reach the application's
security log, and the new colleague who is refused is not an attacker.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.82 2026-11-02 WeKan ® release
**In short:** A card moved to another board, restored from the archive or given
a number no longer takes a **card number** another card on that board has.
This release fixes the following bugs:
**Card numbers** - the number each card has on its board.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/e68938698c">A moved, restored or renumbered card cannot take a number another card has</a>. Thanks to Raymond-Voogt and xet7.</summary>
[\#6858](https://github.com/wekan/wekan/issues/6858). Follow-up to
[\#6815](https://github.com/wekan/wekan/issues/6815), which checked the number
of every new card. A card also enters a board's numbering through an update: a
move to another board, where the browser computed the number from the
destination cards it had loaded, usually none, so the card got 1 or another
low number already taken; a restore from the archive, since an archived card
may share its number with an open one; and a number set by the REST API or an
import. Three card numbers used by more than one open card showed up again in
Admin Panel -> Problems -> Data health two days after the renumbering. A
before-update hook now does what the insert hook does: a number another card
on that board has is replaced by a fresh one from the board's counter, after
raising the counter to the board's highest number; a free number is kept and
the counter raised past it. The card itself never counts as taken, and
ordinary edits, moves within a board, archiving and cards without a number
are left alone. `tests/cardNumberUnique6815.test.cjs` pins which updates are
checked, the move, the restore and the free numbers.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.81 2026-11-02 WeKan ® release
**In short:** The **database health** probe reports a finding once, not once
per server.
This release fixes the following bugs:
**Database health** - the probe behind Admin Panel -> Problems -> Database.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/114e9ecd5a">One server probes the database per period, so a finding is reported once</a>. Thanks to Raymond-Voogt and xet7.</summary>
[\#6857](https://github.com/wekan/wekan/issues/6857). Every server read the
same MongoDB log and counters every five minutes and recorded each finding
itself, so one collection scan became up to 24 identical rows with 24
replicas - more than 400 warnings over 68 pages in the report. A probe now
claims the period in one shared document (collection databaseHealthProbe);
only the server that gets it probes, a duplicate key means another server has
it. The state that compares one probe with the next - log lines seen, the
scanned-documents counter, uptime, restarts, latencies - lives in that
document, so the next probe on whichever server continues where the last one
stopped. A server without the claim still checks its own data directory, and
a claim that cannot be made at all does not stop the probe.
`tests/databaseHealthProbeClaim.test.cjs` pins the claim, the shared state
and that each finding is reported once.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.80 2026-11-02 WeKan ® release
**In short:** **minimatch** is updated to 11.0.4 for GHSA-g3hv-75wg-m249, a
glob pattern that used up CPU and memory.
This release updates the following dependencies:
- **minimatch 10.2.6 → 11.0.4** — Dependabot alerts #180 and #182,
[GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249)
(CVE-2026-118764, CWE-405, moderate): before 11.0.1 a crafted glob pattern
made it grow its glob parts without bound and compare them pairwise, using
up CPU and memory. It came in through archiver, typescript-eslint and
Babel's CLI; all of them now use 11.0.4, and
`tests/minimatchGlobparts.test.cjs` fails if a 10.x copy returns
([commit](https://github.com/wekan/wekan/commit/a3630d96ce)).
Thanks to dependabot.
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.79 2026-11-02 WeKan ® release
**In short:** A cluster of servers runs the **schema re-check** and the
**due-date reminders** once, and a slow-query warning says which query it is.
The site **announcement** shows on Home and All
Boards too. The **date picker** popup shows the year and has year, month and
day drop-downs under the calendar. The **macOS sandbox** for VSCodium no longer
asks for a keychain, starts from the checkout, and can carry a **Claude Code**
token so its login sticks.
This release fixes the following bugs:
**Database load** - work that every server of a cluster repeated.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d42369e96a">The schema re-check and the due-date reminders run once, not on every server</a>. Thanks to Raymond-Voogt and xet7.</summary>
[\#6855](https://github.com/wekan/wekan/issues/6855). With 24 replicas on one
database every start of every server re-checked the schema, reading 1.1
million cards several times, and each server sent its own due-date reminder
for every card. The re-check now claims a run in the migration collection, so
one server does it and the others skip; a claim older than 2 hours, from a
server killed mid-run, is taken over. A run that leaves work it cannot do
(an attachment whose file is gone) is retried once a day instead of at every
start, and a clean run clears that. The daily reminder scan is claimed per day
in the dueReminderRuns collection, so the first server scans and the rest get
a duplicate key and skip. A partial index on lists serves the 15-minute list
sync scan, and a sparse index on boards.dueReminderDays the due-date lookup.
`tests/schemaUpgradeSteps.test.cjs` and `tests/dueScanClaim.test.cjs` pin the
claims, the stale takeover, the daily retry and that the scheduler claims
before it scans.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/bc79de8c57">A collection-scan row names the command, the operators per field and the queryHash</a>. Thanks to Raymond-Voogt and xet7.</summary>
[\#6856](https://github.com/wekan/wekan/issues/6856). Admin Panel -> Problems
-> Database named only the collection and the filtered fields, for example
"wekan.lists (filter on archived,syncSource.enabled,syncSource.type)", which
does not say which query it is, and a slow getMore read "filter on nothing".
The row now shows the command (find, distinct, aggregate, ...), for a getMore
the query it continues, each field with the operators used on it - for example
`archived:$exists` or `$nor[sort:$gte$lte]` - and MongoDB's queryHash, so
the query can be found in the code and with explain(). Values never reach the
row. `tests/databaseHealthScans.test.cjs` pins the shape, the queryHash, the
getMore origin and that no value leaks.
</details>
**Announcement** - the site announcement of Admin Panel -> Announcement.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/64b5762020">The announcement shows on Home and on every All Boards list</a>. Thanks to titver968 and xet7.</summary>
[\#6854](https://github.com/wekan/wekan/issues/6854). The banner waited
for the user to be a member of the current board, and Home, Remaining, Starred,
Templates and the other All Boards pages have no current board, so it showed
only on boards. It now shows to every signed-in user on those pages, and to the
members of the open board on a board page; a visitor who is not signed in still
does not see it (#1281).
`tests/announcementAudience.test.cjs` pins the rule and that no membership test
of a current board is left in front of the banner.
</details>
**Date picker** - the calendar in the Received, Start, Due and End date popups.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/45c291b406">The calendar heading shows the year as well as the month</a>. Thanks to brlin-tw and xet7.</summary>
[\#6853](https://github.com/wekan/wekan/issues/6853). The popup calendar
heading named only the month, so after the arrows had moved past December
there was no sign of which year was shown; the selected date beside it does
not change when the month does. The heading now reads for example "January
2027", in every calendar system. `tests/calendarPickerControls.test.cjs` pins
the year in the heading and that no picker mode leaves it out again, and the
Playwright date display spec expects it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/dcaa316a01">Any year by number, and month and day drop-downs, under the calendar</a>. Thanks to brlin-tw and xet7.</summary>
[\\#6853](https://github.com/wekan/wekan/issues/6853). The heading above the
calendar now shows only the month and the year, no longer the YYYY-MM-DD of
the selected day. Under the calendar are three controls: a year field, where
any whole year can be typed or stepped to - thousands of years back or ahead,
0 being 1 BC and -2999 3000 BC, as far as a date reaches (about 275,000 years
either side of 1970) - a drop-down of the months of that year, and one of the
days of that month. A pick selects the date and shows its month; Enter in the
year field applies the year instead of saving the popup, and a value that is
not a year puts the field back. The months and days are read from the calendar
system in use, so they hold for the Islamic, Hebrew, Japanese and every other
system, including leap months and a short thirteenth month. A day that the new
month lacks falls to its last day. Calendars whose eras restart or switch the
count (Japanese, ROC, Coptic, Ethiopic) refuse a year no number can name.
`tests/calendarDropdowns.test.cjs` checks ten calendar systems and that no
date text is left in the heading.
</details>
and has the following developer-tooling fixes:
**The macOS sandbox** - running VSCodium and Claude Code in the sandbox on a
Mac.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/c58b2896d1">VSCodium no longer asks for a keychain in the macOS sandbox</a>. Thanks to xet7.</summary>
The sandbox refuses `~/Library/Keychains`, so VSCodium's secret storage asked
for a keychain to keep "VSCodium Key" in, with a "Reset To Defaults" button
that would have replaced the login keychain. `sandbox.sh` now starts VSCodium
with `--use-mock-keychain`, so the real Keychain is never touched. The README
explains the price: extension secrets in `.tools/vscodium/user-data` are only
obfuscated. `tests/macosSandbox.test.cjs` covers the sandbox script.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/21c94ce098">Claude Code stays signed in inside the macOS sandbox, and the sandbox starts in the checkout</a>. Thanks to xet7.</summary>
Claude Code keeps its login in the Keychain, which the sandbox cannot reach.
`sandbox.sh --claude-token` now saves a token from `claude setup-token` to
`.tools/secrets/claude-oauth-token` (read from the clipboard, line breaks
joined, a cut-short token refused) and passes it as `CLAUDE_CODE_OAUTH_TOKEN`,
with onboarding marked done and the extension's login prompt off. The script
also changes to the checkout first, because Finder starts it in the home
directory, which the sandbox refuses ("EPERM: process.cwd failed"), and starts
VSCodium with `nohup` so closing the launcher's Terminal does not close it.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.78 2026-11-02 WeKan ® release
**In short:** **CrossBoardExportBleed** and **UnassignedAttachBleed** are
+26 -32
View File
@@ -5,49 +5,43 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v12.78 2026-11-02 WeKan ® release
# v12.83 2026-11-02 WeKan ® release
**CrossBoardExportBleed** and **UnassignedAttachBleed** are
fixed: a checklist scope stays on its board, every export obeys Disable all
export and Anonymize export users, and an assigned-only member changes
attachments only on their own cards. **SAML** logins use **node-saml 5.2.0**,
and **continuous backup** no longer misses a change made right after it starts.
**Keyboard shortcuts** keep working after a popup closes, and the Rules page has
a way back to the board.
An **LDAP** login with a reused login name no longer opens the
previous owner's account, and existing accounts record their directory
identifier.
[All changes in v12.78](CHANGELOG-big.md#v1278-2026-11-02-wekan--release)
[All changes in v12.83](CHANGELOG-big.md#v1283-2026-11-02-wekan--release)
# v12.77 2026-10-31 WeKan ® release
# v12.82 2026-11-02 WeKan ® release
Two **node test guards** follow the movable popups and the date
picker of v12.76.
A card moved to another board, restored from the archive or given
a number no longer takes a **card number** another card on that board has.
[All changes in v12.77](CHANGELOG-big.md#v1277-2026-10-31-wekan--release)
[All changes in v12.82](CHANGELOG-big.md#v1282-2026-11-02-wekan--release)
# v12.76 2026-10-31 WeKan ® release
# v12.81 2026-11-02 WeKan ® release
Security features: a **password policy** with an optional
breached-password check, **Admin Panel / Problems / Password length**,
**compulsory two-factor authentication**, sign-out of **idle sessions** and of
every session after a password change, an **audit log** of what administrators
did, **HSTS**, a cross-site request guard, and **S3 encryption at rest**.
**Sign-up** works again; the date picker has **Today**, the whole card title
edits and **every popup can be moved**; and an old **Sandstorm** grain migrated
from MongoDB 3 shows its boards again.
The **database health** probe reports a finding once, not once
per server.
[All changes in v12.76](CHANGELOG-big.md#v1276-2026-10-31-wekan--release)
[All changes in v12.81](CHANGELOG-big.md#v1281-2026-11-02-wekan--release)
# v12.75 2026-10-30 WeKan ® release
# v12.80 2026-11-02 WeKan ® release
Changing a password signs the account out in every other browser,
and now a test proves it in two real browsers.
**minimatch** is updated to 11.0.4 for GHSA-g3hv-75wg-m249, a
glob pattern that used up CPU and memory.
[All changes in v12.75](CHANGELOG-big.md#v1275-2026-10-30-wekan--release)
[All changes in v12.80](CHANGELOG-big.md#v1280-2026-11-02-wekan--release)
# v12.74 2026-10-30 WeKan ® release
# v12.79 2026-11-02 WeKan ® release
**Caddy** and **FerretDB**, the Go servers WeKan ships, no longer
open MPTCP listeners, which Linux kernel bug **CVE-2026-98432** is reachable
through. The real fix is a patched host kernel.
A cluster of servers runs the **schema re-check** and the
**due-date reminders** once, and a slow-query warning says which query it is.
The site **announcement** shows on Home and All
Boards too. The **date picker** popup shows the year and has year, month and
day drop-downs under the calendar. The **macOS sandbox** for VSCodium no longer
asks for a keychain, starts from the checkout, and can carry a **Claude Code**
token so its login sticks.
[All changes in v12.74](CHANGELOG-big.md#v1274-2026-10-30-wekan--release)
[All changes in v12.79](CHANGELOG-big.md#v1279-2026-11-02-wekan--release)
+1 -1
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=12.78
ARG VERSION=12.83
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v12.78.0"
appVersion: "v12.83.0"
files:
userUploads:
- README.md
+20 -2
View File
@@ -107,6 +107,24 @@
white-space: nowrap;
padding-inline: 6px;
}
.selected-calendar-heading .selected-calendar-date {
margin-inline-start: 4px;
/* #6853: year, month and day drop-downs under the calendar. */
.selected-calendar-selects {
display: flex;
gap: 4px;
margin-block-start: 6px;
}
.selected-calendar-selects select,
.selected-calendar-selects input {
flex: 1;
box-sizing: border-box;
height: auto;
margin-bottom: 0;
min-width: 0;
padding-inline: 4px;
}
.selected-calendar-selects .js-calendar-year { flex: 1.4; }
.calendar-date-input .selected-calendar-selects select:focus-visible,
.calendar-date-input .selected-calendar-selects input:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
+11 -2
View File
@@ -20,8 +20,6 @@ template(name="calendarDateInput")
button.primary.js-calendar-previous(type="button" aria-label="{{_ 'previous'}} {{_ 'month'}}") ‹
span(aria-live="polite")
| {{monthLabel}}
if inline
span.selected-calendar-date {{headingDateText}}
//- #6838: back to today's month from wherever the arrows went. It only
//- shows the month; the date stays as it is until a day is clicked.
button.primary.js-calendar-today(type="button" aria-label="{{_ 'today'}}") {{_ 'today'}}
@@ -39,3 +37,14 @@ template(name="calendarDateInput")
td(class="{{#if this.today}}is-today-cell{{/if}}")
unless this.empty
button.primary.js-calendar-day(type="button" class="{{#if today}}is-today{{/if}}" data-date=iso title=title aria-label=title aria-pressed=selected aria-current=ariaCurrent tabindex=tabIndex) {{label}}
//- #6853: any year, month and day by a pick, under the calendar.
.selected-calendar-selects
input.js-calendar-year(type="number" step="1" inputmode="numeric" value=yearValue aria-label="{{_ 'predicate-year'}}")
select.js-calendar-month(aria-label="{{_ 'month'}}")
each monthOptions
option(value=value selected=selected) {{label}}
select.js-calendar-day-select(aria-label="{{_ 'day'}}")
if noDaySelected
option(value="" selected disabled) –
each dayOptions
option(value=value selected=selected) {{label}}
+74 -5
View File
@@ -6,7 +6,10 @@ import { TAPi18n } from '/imports/i18n';
import { formatDate } from '/imports/lib/dateUtils';
import { formatDateForDisplay, dateDisplayPreferences } from '/client/lib/dateDisplay';
const { CALENDAR_SYSTEMS, nativeCalendarParts } = require('/imports/lib/calendarSystems');
const { calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear } = require('/imports/lib/calendarMonth');
const {
calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInMonth,
} = require('/imports/lib/calendarMonth');
function fromInput(value) {
if (!value) return null;
@@ -53,9 +56,45 @@ Template.calendarDateInput.helpers({
useNativeInput() { return !this.inline && dateDisplayPreferences().calendarSystem === 'gregorian'; },
inputType() { return this.withTime ? 'datetime-local' : 'date'; },
selectedDateText() { return formatDateForDisplay(Template.instance().selectedDate.get(), false); },
headingDateText() {
// #6853: the year, month and day drop-downs under the calendar. Each option
// carries a date as its value, found in the selected calendar system, so any
// past, present or future year, month and day is one pick away.
// The year as a number any year can be typed or stepped to, also one before
// the era (0 is 1 BC) or thousands of years away.
yearValue() { return calendarSignedYear(Template.instance().shownDate.get(), selectedSystem().intl); },
monthOptions() {
const tpl = Template.instance();
return formatDateForDisplay(tpl.selectedDate.get() || tpl.shownDate.get(), false);
const calendar = selectedSystem().intl;
const language = TAPi18n.getLanguage() || 'en';
return calendarYearMonths(tpl.shownDate.get(), calendar).map(month => {
let label;
try {
label = new Intl.DateTimeFormat(language, { calendar, month: 'long', numberingSystem: 'latn' }).format(month.date);
} catch (error) { label = formatDateForDisplay(month.date, false); }
return { _id: formatDate(month.date), value: formatDate(month.date), label, selected: month.selected };
});
},
dayOptions() {
const tpl = Template.instance();
const calendar = selectedSystem().intl;
const { start, end } = calendarMonthRange(tpl.shownDate.get(), calendar);
const selected = tpl.selectedDate.get();
const selectedIso = selected ? formatDate(selected) : '';
const days = [];
for (let day = shiftedDay(start, 0); day < end; day = shiftedDay(day, 1)) {
days.push({
_id: formatDate(day), value: formatDate(day),
label: nativeCalendarParts(day, calendar).day, selected: formatDate(day) === selectedIso,
});
}
return days;
},
noDaySelected() {
const tpl = Template.instance();
const selected = tpl.selectedDate.get();
if (!selected) return true;
const { start, end } = calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl);
return selected < start || selected >= end;
},
expanded() { return !!Template.instance().data.inline || Template.instance().expanded.get(); },
calendarName() { return TAPi18n.__(selectedSystem().labelKey); },
@@ -63,8 +102,11 @@ Template.calendarDateInput.helpers({
const date = Template.instance().shownDate.get();
try {
return new Intl.DateTimeFormat(TAPi18n.getLanguage() || 'en', {
calendar: selectedSystem().intl, month: 'long',
...(Template.instance().data.inline ? {} : { year: 'numeric' }), numberingSystem: 'latn',
// #6853: the year is always shown, also in the popup: month and year
// above the calendar, never the YYYY-MM-DD of the selected day.
calendar: selectedSystem().intl, month: 'long', year: 'numeric', numberingSystem: 'latn',
// A year before the era needs its era, or 3000 BC reads as 3000.
...(calendarSignedYear(date, selectedSystem().intl) <= 0 ? { era: 'short' } : {}),
}).format(date);
} catch (error) { return formatDateForDisplay(date, false); }
},
@@ -136,6 +178,33 @@ Template.calendarDateInput.events({
},
'click .js-calendar-year-previous'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), -1, selectedSystem().intl)); },
'click .js-calendar-year-next'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), 1, selectedSystem().intl)); },
// #6853: a pick in a drop-down selects that date and shows its month.
'change .js-calendar-year'(event, tpl) {
const input = event.currentTarget;
const target = dateInCalendarYear(tpl.shownDate.get(), Number(input.value), selectedSystem().intl);
if (!target || input.value.trim() === '') {
// A year that is not a whole number, or beyond what a date can hold:
// the field goes back to the year being shown.
input.value = calendarSignedYear(tpl.shownDate.get(), selectedSystem().intl);
return;
}
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
},
// Enter in the year field applies the year, not the popup's Save.
'keydown .js-calendar-year'(event) {
if (event.key !== 'Enter') return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.dispatchEvent(new Event('change', { bubbles: true }));
},
'change .js-calendar-month'(event, tpl) {
const target = sameDayInMonth(tpl.selectedDate.get() || tpl.shownDate.get(), fromInput(event.currentTarget.value), selectedSystem().intl);
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
},
'change .js-calendar-day-select'(event, tpl) {
const target = fromInput(event.currentTarget.value);
if (!target) return;
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
},
'click .js-calendar-day'(event, tpl) {
event.preventDefault();
writeValue(tpl, fromInput(event.currentTarget.dataset.date));
+2 -1
View File
@@ -245,7 +245,8 @@ template(name="header")
if appIsOffline
+offlineWarning
if currentUser.isBoardMember
//- #6854: on Home and All Boards too, not only on a board.
if announcementForUser
if hasAnnouncement
.announcement(role="status" aria-live="polite")
p
+9
View File
@@ -7,6 +7,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Announcements, {
announcementVersion,
shouldShowAnnouncement,
showAnnouncementToUser,
} from '/models/announcements';
import { Utils } from '/client/lib/utils';
// What this bar calls the page you are on. models/lib/pageTitles.js
@@ -316,6 +317,14 @@ Template.header.helpers({
return ['waiting', 'failed', 'offline'].includes(status.status);
},
announcementForUser() {
const user = ReactiveCache.getCurrentUser();
return showAnnouncementToUser({
userId: user && user._id,
board: Utils.getCurrentBoard(),
});
},
hasAnnouncement() {
const announcement = Announcements.findOne();
if (!announcement || !announcement.enabled) {
@@ -111,7 +111,7 @@ from a probe that runs at startup and every five minutes:
| `db.network-filesystem` | The data directory is on a CIFS/SMB, NFS or FUSE filesystem. Only when WeKan can see that directory itself (the snap, the bundle with its embedded database, or a shared volume). |
| `db.slow-storage` | Reads averaged over 100 ms across the interval - storage latency, the "Slow query" symptom. |
| `db.disk-space` | The filesystem under the data directory is below 5% or 512 MiB free - the real "no space left on device", before it happens. |
| `db.collection-scan` | MongoDB's own log (`getLog`) has "Slow query" lines where a query read a whole collection of at least 10,000 documents (COLLSCAN): an index WeKan does not know about is missing. The row names the collection and the fields filtered on - never values - so they can be reported to WeKan. Needs the `getLog` privilege (`clusterMonitor`); without it, and on FerretDB, this check is skipped. |
| `db.collection-scan` | MongoDB's own log (`getLog`) has "Slow query" lines where a query read a whole collection of at least 10,000 documents (COLLSCAN): an index WeKan does not know about is missing. The row names the collection, the fields filtered on, the command (`find`, `distinct`, ...; for a slow `getMore`, the query it continues), the operators used on each field (`archived:$exists`, `$nor[sort:$gte$lte]`) and the MongoDB `queryHash` - never values - so they can be reported to WeKan and found with `explain()`. Needs the `getLog` privilege (`clusterMonitor`); without it, and on FerretDB, this check is skipped. |
| `db.scan-rate` | The database read more than 50,000 documents per second over the 5-minute interval (`serverStatus` `scannedObjects`): queries without a fitting index, or one query asked far too often. |
| `db.index-created` | WeKan found a collection with data but without an index it queries by, and created it. Fixed automatically; nothing to do. |
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-12.78-win64.zip](https://github.com/wekan/wekan/releases/download/v12.78/wekan-12.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-12.83-win64.zip](https://github.com/wekan/wekan/releases/download/v12.83/wekan-12.83-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-12.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-12.83-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+45
View File
@@ -31,6 +31,32 @@ Install WeKan's tools into `.tools` once, from a normal terminal **outside**
the sandbox (`./build.sh`, option 1). Inside the sandbox they are already on
`PATH`.
### Claude Code in the sandbox
Claude Code keeps its login in the macOS Keychain, which the sandbox cannot
reach, so signing in inside the sandbox does not stick: macOS asks for a
keychain to store "user" in, and Claude Code shows its login screen again.
Give it a long-lived token instead, once, from a normal terminal **outside**
the sandbox:
```bash
claude setup-token # sign in in the browser; prints a token valid for a year
docs/Security/Sandboxes/macOS/sandbox.sh --claude-token # copy the token first; saved to .tools/secrets/claude-oauth-token
```
The token is printed over several lines: copy all of them. `--claude-token`
reads it from the clipboard, joins the lines, and refuses a token that is
cut short, which Claude Code would answer with "401 Invalid bearer token".
In zsh, paste the commands without the `#` comments.
Then restart the sandboxed VSCodium. `sandbox.sh` passes the token as
`CLAUDE_CODE_OAUTH_TOKEN`, marks Claude Code's onboarding done in
`.tools/home/.claude.json`, and sets `"claudeCode.disableLoginPrompt": true`
in VSCodium's settings, so neither the extension nor the `claude` command in
the terminal asks for a login. Everything in the sandbox can read the token,
as it could read any login made there; revoke it at claude.ai under
Settings if it leaks, and make a new one when it expires.
## What the sandbox allows and refuses
`sandbox.sh --check` runs these checks inside the sandbox and prints the
@@ -73,6 +99,10 @@ with the checkout's real path:
`.tools/vscodium/user-data` and `.tools/vscodium/extensions`, given with
`--user-data-dir` and `--extensions-dir`. They are not in `~/Library` or
`~/.vscode-oss`, which the sandbox cannot reach.
- **Started in the checkout:** `sandbox.sh` changes to the checkout before it
starts anything. Finder opens the launcher in the home directory, which the
sandbox refuses, and VSCodium then stopped at once with "EPERM:
process.cwd failed with error operation not permitted, uv_cwd".
- **Temporary files:** `TMPDIR`, `TMP` and `TEMP` are `.tools/tmp`, as for every
WeKan script (`releases/tools-env.sh`).
- **A clean environment:** VSCodium starts with `env -i`. That drops
@@ -96,6 +126,21 @@ with the checkout's real path:
reach anything outside the checkout. That is the property this sandbox
exists for. The Flatpak build of VSCodium makes the same trade with
zypak.
- **No Keychain: `--use-mock-keychain`.** The login keychain is in
`~/Library/Keychains`, which the sandbox refuses. Without this switch
VSCodium's secret storage (Electron `safeStorage`) asks for a keychain to
keep "VSCodium Key" in, with the dialog "A keychain cannot be found to store
'VSCodium Key'" (in Finnish "Avainnippua ei löydy"). Never press **Reset To
Defaults** (*Palauta oletuksiin*) there: it would replace the login keychain
with a new, empty one. Press **Cancel** (*Kumoa*) and start VSCodium again
with the updated `sandbox.sh`. With the switch, Chromium uses a built-in
mock keychain with a fixed, publicly known key and does not touch the real
Keychain (Claude Code's own login also lives in the Keychain; see
"Claude Code in the sandbox" above); it is the macOS equivalent of `--password-store=basic`, which
works on Linux only. The price: what extensions store as secrets (sign-ins,
tokens) in `.tools/vscodium/user-data` is only obfuscated, readable by
anyone who can read the checkout. Keep long-lived credentials out of the
sandbox. Opening `~/Library/Keychains` to the sandbox instead would be worse.
- **`sandbox-exec` is marked deprecated** in its man page, but it has been
present and working in every macOS release since 10.5, including macOS 27,
where this was tested. Apple uses the same profiles for its own services.
+84 -7
View File
@@ -3,11 +3,13 @@
# sandbox.sh - install VSCodium and start it in a macOS sandbox that can read
# and write only this WeKan checkout (~/Documents/repos/wekan).
#
# Usage: docs/Security/Sandboxes/macOS/sandbox.sh [--setup-only] [--check]
# Usage: docs/Security/Sandboxes/macOS/sandbox.sh [--setup-only|--check|--claude-token]
#
# (no option) set up what is missing, then start VSCodium in the sandbox
# --setup-only set up, and write the Finder launcher, but do not start it
# --check show what the sandbox allows and refuses, then exit
# (no option) set up what is missing, then start VSCodium in the sandbox
# --setup-only set up, and write the Finder launcher, but do not start it
# --check show what the sandbox allows and refuses, then exit
# --claude-token save a token from `claude setup-token` for Claude Code
# in the sandbox, then set up (see 2b below)
#
# The macOS counterpart of the Flatpak sandbox on Linux
# (docs/Security/Sandboxes/vscodium). The design, what is and is not
@@ -28,7 +30,8 @@ case "${1:-}" in
'') ;;
--setup-only) MODE=setup ;;
--check) MODE=check ;;
*) echo "Usage: $0 [--setup-only] [--check]" >&2; exit 2 ;;
--claude-token) MODE=claude-token ;;
*) echo "Usage: $0 [--setup-only|--check|--claude-token]" >&2; exit 2 ;;
esac
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
@@ -40,6 +43,11 @@ TOOLS="$REPO/.tools"
APP="${VSCODIUM_APP:-/Applications/VSCodium.app}"
PROFILE="$TOOLS/sandbox/vscodium-macos.sb"
LAUNCHER="$TOOLS/sandbox/WeKan VSCodium.command"
# Work from the checkout. Finder starts the launcher in the home directory,
# which the sandbox refuses: VSCodium (Node.js) then fails at its first
# process.cwd() with "EPERM: process.cwd failed with error operation not
# permitted, uv_cwd", and so would any other program started in the sandbox.
cd "$REPO"
mkdir -p "$TOOLS/tmp" "$TOOLS/home" "$TOOLS/sandbox" \
"$TOOLS/vscodium/user-data" "$TOOLS/vscodium/extensions"
@@ -68,6 +76,65 @@ for key in user.name user.email; do
fi
done
# 2b) Claude Code's sign-in. Claude Code keeps its login in the macOS Keychain,
# which the sandbox cannot reach ("A keychain cannot be found to store
# 'user'"), so a login made inside the sandbox is lost at once. Instead a
# long-lived token, made with `claude setup-token` outside the sandbox, is
# kept in .tools/secrets/claude-oauth-token and given to the sandbox as
# CLAUDE_CODE_OAUTH_TOKEN. Save it with: sandbox.sh --claude-token
CLAUDE_TOKEN_FILE="$TOOLS/secrets/claude-oauth-token"
if [ "$MODE" = claude-token ]; then
(umask 077; mkdir -p "$TOOLS/secrets")
# `claude setup-token` prints the token wrapped over several lines, so
# reading one pasted line would keep only the first part of it ("401
# Invalid bearer token"). Take it from the clipboard, or read lines until
# an empty one, and drop the line breaks.
token="$(pbpaste 2>/dev/null | tr -d '[:space:]' || true)"
case "$token" in
sk-ant-*) echo "Token taken from the clipboard." ;;
*)
echo 'Paste the token from `claude setup-token` (not shown), then press Return twice:'
token=""
while IFS= read -rs line && [ -n "$line" ]; do token="$token$line"; done
token="$(printf '%s' "$token" | tr -d '[:space:]')"
;;
esac
case "$token" in
sk-ant-oat*) ;;
*) echo "That is not a token from \`claude setup-token\` (it starts with sk-ant-oat); nothing saved." >&2; exit 1 ;;
esac
if [ "${#token}" -lt 90 ]; then
echo "The token is only ${#token} characters, so part of it is missing; nothing saved." >&2
echo "Copy all of it, every line, and run this again." >&2
exit 1
fi
(umask 077; printf '%s\n' "$token" > "$CLAUDE_TOKEN_FILE")
echo "Saved to $CLAUDE_TOKEN_FILE (readable only by you)."
MODE=setup
fi
claude_env=()
if [ -s "$CLAUDE_TOKEN_FILE" ]; then
claude_env=(CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '[:space:]' < "$CLAUDE_TOKEN_FILE")")
# With a token Claude Code would still show its login screen until its
# onboarding is marked done, and the VS Code extension until its login
# prompt is turned off.
cj="$TOOLS/home/.claude.json"
if [ ! -s "$cj" ]; then
printf '{\n "hasCompletedOnboarding": true\n}\n' > "$cj"
elif [ "$(plutil -extract hasCompletedOnboarding raw -o - "$cj" 2>/dev/null)" != true ]; then
cp "$cj" "$cj.bak"
plutil -replace hasCompletedOnboarding -bool YES "$cj" 2>/dev/null ||
echo "Note: add \"hasCompletedOnboarding\": true to $cj by hand." >&2
fi
vs="$TOOLS/vscodium/user-data/User/settings.json"
mkdir -p "$(dirname "$vs")"
if [ ! -s "$vs" ]; then
printf '{\n "claudeCode.disableLoginPrompt": true\n}\n' > "$vs"
elif ! grep -q '"claudeCode.disableLoginPrompt"' "$vs"; then
echo "Note: add \"claudeCode.disableLoginPrompt\": true to VSCodium's settings ($vs)." >&2
fi
fi
# 3) The sandbox profile (Seatbelt, the language of sandbox-exec), written for
# this checkout's own path. Everything is allowed except the file system
# under /Users and /Volumes, of which only the checkout is opened again.
@@ -113,6 +180,7 @@ sandboxed() {
WEKAN_DIR="$REPO" WEKAN_TOOLS_DIR="$TOOLS" \
npm_config_cache="$TOOLS/npm-cache" PLAYWRIGHT_BROWSERS_PATH="$TOOLS/ms-playwright" \
PATH="$TOOLS/bin:$TOOLS/.meteor:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
${claude_env[@]+"${claude_env[@]}"} \
"$@"
}
@@ -141,8 +209,17 @@ echo "Sandbox ready. Finder launcher: $LAUNCHER"
# initialization failed: Operation not permitted"), and every process
# VSCodium starts is inside this sandbox instead. Its settings and
# extensions are in .tools/vscodium, not in ~/Library and ~/.vscode-oss.
sandboxed "$APP/Contents/MacOS/VSCodium" --no-sandbox \
# --use-mock-keychain: the login keychain is in ~/Library/Keychains, which
# the sandbox refuses, so Electron's safeStorage would ask for a keychain
# to store "VSCodium Key" in ("A keychain cannot be found", with a "Reset
# To Defaults" button that would replace the user's login keychain). With
# it, Chromium's OSCrypt uses a mock keychain with a fixed key and never
# touches the real one, so extension secrets in .tools/vscodium are only
# obfuscated. (--password-store=basic does the same on Linux only.)
# nohup and </dev/null: the launcher's Terminal window may close as soon
# as this script exits, and VSCodium must not go with it.
sandboxed /usr/bin/nohup "$APP/Contents/MacOS/VSCodium" --no-sandbox --use-mock-keychain \
--user-data-dir "$TOOLS/vscodium/user-data" \
--extensions-dir "$TOOLS/vscodium/extensions" \
"$REPO" >"$TOOLS/sandbox/vscodium.log" 2>&1 &
"$REPO" </dev/null >"$TOOLS/sandbox/vscodium.log" 2>&1 &
echo "VSCodium started in the sandbox (log: $TOOLS/sandbox/vscodium.log)."
+91 -19
View File
@@ -50,26 +50,98 @@ function shiftCalendarMonth(date, direction, calendar) {
return candidate < next.end ? candidate : shiftedDay(next.end, -1);
}
function shiftCalendarYear(date, direction, calendar) {
const yearKey = value => {
const parts = nativeCalendarParts(value, calendar);
return `${parts.era || ''}|${parts.year}`;
};
const range = anchor => {
const key = yearKey(anchor);
let start = shiftedDay(anchor, 0);
let end = shiftedDay(anchor, 1);
for (let count = 0; count < 400 && yearKey(shiftedDay(start, -1)) === key; count += 1) start = shiftedDay(start, -1);
for (let count = 0; count < 400 && yearKey(end) === key; count += 1) end = shiftedDay(end, 1);
return { start, end };
};
const current = range(date);
const next = range(direction < 0 ? shiftedDay(current.start, -1) : current.end);
let offset = 0;
const anchor = shiftedDay(date, 0);
for (let day = current.start; day < anchor && offset < 400; day = shiftedDay(day, 1)) offset += 1;
function calendarYearKey(date, calendar) {
const parts = nativeCalendarParts(date, calendar);
return `${parts.era || ''}|${parts.year}`;
}
// The first day (noon) and the day after the last of the calendar year that
// holds `date`.
function calendarYearRange(date, calendar) {
const key = calendarYearKey(date, calendar);
let start = shiftedDay(date, 0);
let end = shiftedDay(date, 1);
for (let count = 0; count < 400 && calendarYearKey(shiftedDay(start, -1), calendar) === key; count += 1) start = shiftedDay(start, -1);
for (let count = 0; count < 400 && calendarYearKey(end, calendar) === key; count += 1) end = shiftedDay(end, 1);
return { start, end };
}
// The same day of the year in the year that holds `target`, or that year's
// last day when it is shorter.
function sameDayInYear(date, target, calendar) {
const current = calendarYearRange(date, calendar);
const next = calendarYearRange(target, calendar);
const offset = Math.round((shiftedDay(date, 0) - current.start) / 86400000);
const candidate = shiftedDay(next.start, offset);
return candidate < next.end ? candidate : shiftedDay(next.end, -1);
}
module.exports = { calendarMonthRange, calendarMonthDays, shiftedDay, shiftCalendarMonth, shiftCalendarYear };
function shiftCalendarYear(date, direction, calendar) {
const current = calendarYearRange(date, calendar);
const next = calendarYearRange(direction < 0 ? shiftedDay(current.start, -1) : current.end, calendar);
return sameDayInYear(date, next.start, calendar);
}
// #6853: the year as a person counts it in this calendar, as a whole number
// that may be zero or negative: 2026, then 1 for the first year of the era,
// then 0 for the year before it (1 BC), -1 for 2 BC - so that any year, ancient
// or far ahead, is one number to type or to step to. null when the calendar or
// the date is unsupported.
const BEFORE_ERA = /^(BC|BCE|BH|ERA0|Before)/i;
function calendarSignedYear(date, calendar) {
const parts = nativeCalendarParts(date, calendar);
const year = parts && Number(parts.year);
if (!Number.isFinite(year)) return null;
return BEFORE_ERA.test(parts.era || '') ? 1 - year : year;
}
// A day in the year `target` (a signed year, see above) of the calendar, the
// same day of the year as `date` has in its own year, or null when no such day
// can be represented (a JavaScript date reaches about 275,000 years either
// side of 1970). The year is not walked to: each step moves by the number of
// years still missing times the mean year, which closes in within a handful of
// steps even for a lunar calendar, whose year is 11 days shorter. That keeps a
// jump of several thousand years as cheap as a jump of one.
const MEAN_YEAR_DAYS = 365.2425;
function dateInCalendarYear(date, target, calendar) {
if (!Number.isInteger(target)) return null;
try {
let guess = shiftedDay(date, 0);
for (let step = 0; step < 40; step += 1) {
const year = calendarSignedYear(guess, calendar);
if (year === null) return null;
if (year === target) return sameDayInYear(date, guess, calendar);
const next = shiftedDay(guess, Math.round((target - year) * MEAN_YEAR_DAYS) || (target > year ? 1 : -1));
if (Number.isNaN(next.getTime())) return null;
guess = next;
}
} catch (error) { /* out of the range a Date can hold */ }
return null;
}
// The months of the calendar year that holds `date`, each with its first day
// (noon) - leap months and a short thirteenth month included.
function calendarYearMonths(date, calendar) {
const year = calendarYearRange(date, calendar);
const current = calendarMonthRange(date, calendar).start.getTime();
const months = [];
for (let start = year.start; start < year.end && months.length < 20;) {
const range = calendarMonthRange(start, calendar);
months.push({ date: shiftedDay(range.start, 0), selected: range.start.getTime() === current });
start = shiftedDay(range.end, 0);
}
return months;
}
// The day of `date` in the month that starts at `monthStart`, or that month's
// last day when it is shorter.
function sameDayInMonth(date, monthStart, calendar) {
const next = calendarMonthRange(monthStart, calendar);
const candidate = shiftedDay(next.start, Number(nativeCalendarParts(date, calendar).day) - 1);
return candidate < next.end ? candidate : shiftedDay(next.end, -1);
}
module.exports = {
calendarMonthRange, calendarMonthDays, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
calendarYearRange, calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInYear, sameDayInMonth,
};
+12
View File
@@ -85,4 +85,16 @@ export function shouldShowAnnouncement({ enabled, version, dismissedVersion }) {
return dismissedVersion !== version;
}
// #6854: who the site announcement is for. A signed-in user on a page with no
// board of its own - Home and every All Boards list: Remaining, Starred,
// Templates, Archive - and a member of the board that is open on a board page.
// It used to ask for membership of the current board on every page, and there
// is none on those, so it showed only on boards. Nobody signed out sees it
// (#1281).
export function showAnnouncementToUser({ userId, board }) {
if (!userId) return false;
if (!board) return true;
return typeof board.hasMember === 'function' ? !!board.hasMember(userId) : false;
}
export default Announcements;
+8 -3
View File
@@ -4155,10 +4155,15 @@ const addCronJob = debounce(
}
}
Meteor.setTimeout(scheduler, hour);
})(() => {
findDueCards(notifydays).catch(error => {
})(async () => {
try {
// Once per day for the whole installation, not once per server.
const { claimDueScanDay } = require('/models/lib/dueScanClaim');
if (!(await claimDueScanDay(Cards.rawDatabase()))) return;
await findDueCards(notifydays);
} catch (error) {
console.error('Due card notification scan failed:', error);
});
}
});
scheduler();
},
+21 -1
View File
@@ -32,4 +32,24 @@ function counterFloor(cardNumber, boardTop) {
return isCardNumber(boardTop) ? Math.max(cardNumber, boardTop) : cardNumber;
}
module.exports = { counterName, isCardNumber, needsNewCardNumber, counterFloor };
// #6815 follow-up: an UPDATE can bring a card into a board's numbering too.
// - A move to another board: the browser computes the destination number
// itself (Boards.getNextCardNumber on the client is the highest number among
// the destination cards it has loaded, plus one - often none of them).
// - A restore from the archive: an archived card may share its number with an
// open card from before the insert check existed.
// - A $set of cardNumber: from the REST API or an import.
// Returns { boardId, cardNumber } to check, or null when the update leaves the
// card's place in the numbering alone.
function numberToCheckOnUpdate(doc, set) {
if (!doc || !set || typeof set !== 'object') return null;
const movesBoard = set.boardId !== undefined && String(set.boardId) !== String(doc.boardId);
const restores = set.archived === false && doc.archived === true;
const setsNumber = set.cardNumber !== undefined;
if (!movesBoard && !restores && !setsNumber) return null;
const cardNumber = setsNumber ? set.cardNumber : doc.cardNumber;
if (!isCardNumber(cardNumber)) return null;
return { boardId: movesBoard ? set.boardId : doc.boardId, cardNumber };
}
module.exports = { counterName, isCardNumber, needsNewCardNumber, counterFloor, numberToCheckOnUpdate };
+74 -3
View File
@@ -211,6 +211,31 @@ const SCAN_RATE_PER_SEC = 50000;
const fieldShape = filter => (filter && typeof filter === 'object' && !Array.isArray(filter)
? Object.keys(filter).sort().join(',') : '');
// The query's shape: each field with the operators used on it, never a value
// ({archived: {$exists: false}} -> "archived:$exists", {a: 'x'} -> "a:=").
// $or / $nor / $and list their branches, so two queries on the same fields
// that need different indexes can be told apart.
function queryShape(filter, depth = 0) {
if (!filter || typeof filter !== 'object' || Array.isArray(filter) || depth > 3) return '';
return Object.keys(filter).sort().map(key => {
const value = filter[key];
if (/^\$(or|nor|and)$/.test(key) && Array.isArray(value)) {
return `${key}[${value.map(branch => queryShape(branch, depth + 1)).join('|')}]`;
}
const ops = value && typeof value === 'object' && !Array.isArray(value)
? Object.keys(value).filter(k => k.startsWith('$')).sort() : [];
return `${key}:${ops.length ? ops.join('') : '='}`;
}).join(',');
}
// The command a slow getMore continues is in originatingCommand: without it a
// cursor that scans in batches showed as "filter on nothing".
const originOf = attr => {
const cmd = (attr && attr.command) || {};
return cmd.getMore !== undefined && attr.originatingCommand ? attr.originatingCommand : cmd;
};
const commandName = cmd => Object.keys(cmd || {}).find(k => !k.startsWith('$') && k !== 'lsid') || '';
function slowQueryFilter(command) {
const cmd = command || {};
const first = list => (Array.isArray(list) && list[0]) || null;
@@ -236,9 +261,12 @@ function collectionScans(logLines, sinceMs = 0) {
const a = entry.msg === 'Slow query' && entry.attr;
if (!a || typeof a.ns !== 'string' || /^(local|config|admin)\./.test(a.ns)) continue;
if (!/COLLSCAN/.test(String(a.planSummary || '')) || !(Number(a.docsExamined) >= SCAN_MIN_DOCS)) continue;
const fields = fieldShape(slowQueryFilter(a.command));
const command = originOf(a);
const filter = slowQueryFilter(command);
const fields = fieldShape(filter);
const key = `${a.ns}|${fields}`;
const g = groups.get(key) || { ns: a.ns, fields, count: 0, maxDocs: 0, maxMs: 0 };
const g = groups.get(key) || { ns: a.ns, fields, count: 0, maxDocs: 0, maxMs: 0,
op: commandName(command), shape: queryShape(filter), queryHash: String(a.queryHash || a.planCacheShapeHash || '') };
g.count += 1;
g.maxDocs = Math.max(g.maxDocs, Number(a.docsExamined) || 0);
g.maxMs = Math.max(g.maxMs, Number(a.durationMillis) || 0);
@@ -249,7 +277,8 @@ function collectionScans(logLines, sinceMs = 0) {
function collectionScanProblem(groups) {
const list = (groups || []).slice(0, 3).map(g => `${g.ns} (filter on ${g.fields || 'nothing'}: `
+ `${g.count}x, up to ${g.maxDocs} documents, ${g.maxMs} ms)`).join('; ');
+ `${g.count}x, up to ${g.maxDocs} documents, ${g.maxMs} ms`
+ `${g.op ? `; ${g.op}` : ''}${g.shape ? ` ${g.shape}` : ''}${g.queryHash ? `, queryHash ${g.queryHash}` : ''})`).join('; ');
return {
type: 'db.collection-scan',
kind: 'index',
@@ -281,6 +310,43 @@ function scanRateProblem(perSecond) {
};
}
// ---- one probe for the whole installation ----------------------------------
// Every WeKan server runs the probe, but they all look at the same database:
// the same "Slow query" lines, the same counters. Each server reported each
// finding itself, so 24 replicas wrote the same collection scan 24 times.
// A probe first claims the period in one shared document; only the server
// that gets it probes the database, and the state that compares one probe
// with the next (log lines seen, counters, uptime) lives in that document, so
// the next probe - on whichever server - continues where this one stopped.
const PROBE_CLAIM_ID = 'database-health-probe';
const SHARED_STATE = ['logSeenMs', 'scanned', 'uptime', 'restarts', 'opLatencies'];
// coll: a mongodb driver Collection. Resolves { claimed, shared }: `shared`
// is the state the previous probe left ({} the very first time).
async function claimProbe(coll, now, periodMs) {
const due = now - Math.floor(periodMs * 0.8); // timers on the servers drift
try {
const r = await coll.findOneAndUpdate(
{ _id: PROBE_CLAIM_ID, $or: [{ claimedMs: { $lt: due } }, { claimedMs: { $exists: false } }] },
{ $set: { claimedMs: now } },
{ upsert: true, returnDocument: 'before' },
);
// driver 6 returns the document, older drivers { value }
const before = r && typeof r === 'object' && 'ok' in r && 'value' in r ? r.value : r;
return { claimed: true, shared: before || {} };
} catch (e) {
if (e && e.code === 11000) return { claimed: false, shared: null }; // claimed elsewhere this period
throw e;
}
}
function sharedState(state) {
const out = {};
for (const k of SHARED_STATE) if (state[k] !== undefined) out[k] = state[k];
return out;
}
module.exports = {
DOCS,
NETWORK_FS_TYPES,
@@ -306,6 +372,11 @@ module.exports = {
SCAN_RATE_PER_SEC,
collectionScans,
collectionScanProblem,
queryShape,
claimProbe,
sharedState,
PROBE_CLAIM_ID,
SHARED_STATE,
scanRate,
scanRateProblem,
};
+32
View File
@@ -0,0 +1,32 @@
'use strict';
// The due-date reminder scan (models/cards.js findDueCards) runs once a day,
// at NOTIFY_DUE_AT_HOUR_OF_DAY, on a timer in every WeKan server process. With
// several servers on one database (a Kubernetes Deployment of 24 replicas)
// every one of them scanned the due cards and inserted its own duenow /
// almostdue / pastdue activity for each card - one reminder per server.
// The first server to claim the day does the scan; the others skip.
//
// The claim is a document whose _id is the local date, inserted into
// COLLECTION. The unique _id makes the insert succeed for exactly one server;
// the others get a duplicate key error (11000). One small document per day.
const COLLECTION = 'dueReminderRuns';
function dayKey(date) {
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
// db: a mongodb driver Db. Resolves true when this server should scan today.
async function claimDueScanDay(db, now = new Date()) {
try {
await db.collection(COLLECTION).insertOne({ _id: dayKey(now), at: now });
return true;
} catch (error) {
if (error && error.code === 11000) return false;
throw error;
}
}
module.exports = { claimDueScanDay, dayKey, COLLECTION };
+9 -7
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v12.78.0",
"version": "v12.83.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v12.78.0",
"version": "v12.83.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -92,6 +92,7 @@
"chai-as-promised": "^8.0.2",
"css-loader": "^7.1.4",
"enhanced-resolve": "5.26.0",
"minimatch": "^11.0.1",
"mongodb": "^7.7.0",
"puppeteer": "^25.15.0",
"sinon": "^22.1.0",
@@ -12728,15 +12729,16 @@
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
+11 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v12.78.0",
"version": "v12.83.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -49,6 +49,7 @@
"chai-as-promised": "^8.0.2",
"css-loader": "^7.1.4",
"enhanced-resolve": "5.26.0",
"minimatch": "^11.0.1",
"mongodb": "^7.7.0",
"puppeteer": "^25.15.0",
"sinon": "^22.1.0",
@@ -131,6 +132,15 @@
"archiver-utils": {
"glob": "$glob"
},
"readdir-glob": {
"minimatch": "^11.0.1"
},
"@typescript-eslint/typescript-estree": {
"minimatch": "^11.0.1"
},
"@babel/cli": {
"minimatch": "^11.0.1"
},
"uuid": "^14.0.0",
"webpack-dev-server": "^5.2.6",
"http-proxy-middleware": "^2.0.10",
@@ -0,0 +1,33 @@
'use strict';
// A WeKan account found by USERNAME or E-MAIL at an LDAP login (or merged by
// the import) may already belong to another directory entry: its
// services.ldap.id is that entry's unique identifier (objectGUID, entryUUID,
// ...). That happens when a directory reuses a login name - someone leaves,
// their account is deleted, and a new colleague later gets the same
// sAMAccountName - or when two entries share an e-mail address. The login
// matched the account by name and logged the NEW person into the OLD person's
// account, with all of their boards.
//
// The account belongs to a different person when it records an identifier of
// the SAME attribute with a DIFFERENT value. These cannot be told apart this
// way, so they keep working as before:
// - an account without an identifier (made before LDAP, or a local account
// being merged);
// - an identifier of another attribute: LDAP_UNIQUE_IDENTIFIER_FIELD was
// changed, for example from the login name to objectGUID. The login then
// records the new identifier, and the account is protected from then on;
// - an identifier WITHOUT its attribute (accounts from before #4654 stored only
// the value). It may be the login name's value while the login now offers an
// objectGUID; refusing it would lock every such user out after that change.
// The login records the attribute, so the next login is compared.
//
// CommonJS so the plain-Node test can require it.
function boundToAnotherEntry(user, uniqueId) {
const bound = user && user.services && user.services.ldap;
if (!bound || !bound.id || !bound.idAttribute || !uniqueId || !uniqueId.value || !uniqueId.attribute) return false;
if (String(bound.idAttribute).toLowerCase() !== String(uniqueId.attribute).toLowerCase()) return false;
return String(bound.id) !== String(uniqueId.value);
}
module.exports = { boundToAnotherEntry };
+4 -1
View File
@@ -544,7 +544,7 @@ export default class LDAP {
return result[0];
}
async getUserByUsername(username) {
async getUserByUsername(username, { unique = false } = {}) {
await this.bindIfNecessary();
const searchOptions = {
@@ -563,6 +563,9 @@ export default class LDAP {
}
if (result.length > 1) {
if (unique) {
throw new Error(`Search by username ${username} returned ${result.length} records; refusing an ambiguous LDAP background-sync update`);
}
Log.error(`Search by username ${username} returned ${result.length} records`);
}
+14 -2
View File
@@ -4,6 +4,7 @@ import { runWithLdapDisconnect } from './connectionGuard';
import { log_debug, log_info, log_warn, log_error } from './logger';
import { isAdminByGroups } from './adminGroups';
import { requireUserCredentials } from './userCredentials';
import { boundToAnotherEntry } from './accountBinding';
// Org/team sync is optional enrichment; failed sync must not block login.
async function syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, userId) {
@@ -148,15 +149,18 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
if (!user) {
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
// `username`, not `_id`: a user's _id is a random id, never the
// username, so this query never found anybody and LDAP_EMAIL_MATCH_REQUIRE
// made every existing user's login try to create the account again.
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
userQuery = {
'_id' : username,
username,
'emails.0.address' : email,
'emails.0.verified' : true
};
} else {
userQuery = {
'_id' : username,
username,
'emails.0.address' : email
};
}
@@ -194,6 +198,14 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
}
// A name or e-mail match that belongs to ANOTHER directory entry is not
// this person's account (accountBinding.js): a reused login name must not
// open the previous owner's boards.
if (user && boundToAnotherEntry(user, Unique_Identifier_Field)) {
log_warn('LDAP login matched an existing account by name or e-mail, but that account belongs to another directory entry; refusing', user._id);
throw new Meteor.Error('LDAP-login-error', 'LDAP authentication failed');
}
// Login user if they exist
if (user) {
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
+35 -1
View File
@@ -11,6 +11,7 @@ import { ALL_LDAP_USERS } from './userSearchValue';
import { getLdapPhotoBuffer } from './ldapPhoto';
import { ldapPresenceUpdate } from './presenceSync';
import { isKnownLdapGroup } from './entryKind';
import { boundToAnotherEntry } from './accountBinding';
import {
ldapTextValue,
ldapTextValues,
@@ -264,6 +265,23 @@ export function getDataToSyncUserData(ldapUser, user) {
export async function syncUserData(user, ldapUser) {
log_info('Syncing user data');
log_debug('user', {'email': user.email, '_id': user._id});
// Record which directory entry this account belongs to. Only a NEW account
// got its identifier (addLdapUser); an existing one kept whatever it had, so
// accounts from before #4654 never got an idAttribute, and an account matched
// by username after LDAP_UNIQUE_IDENTIFIER_FIELD changed never got the new
// identifier - the login matched it by name every time. The login refuses an
// account bound to another entry before it gets here (accountBinding.js).
const uniqueId = getLdapUserUniqueID(ldapUser);
const bound = (user && user.services && user.services.ldap) || {};
if (user && user._id && uniqueId && uniqueId.value && (String(bound.id || '') !== String(uniqueId.value)
|| String(bound.idAttribute || '').toLowerCase() !== String(uniqueId.attribute || '').toLowerCase())) {
log_info('Recording the directory identifier of', user.username, ':', uniqueId.attribute);
await Meteor.users.updateAsync({ _id: user._id }, { $set: {
'services.ldap.id': uniqueId.value,
'services.ldap.idAttribute': uniqueId.attribute,
} });
}
// log_debug('ldapUser', ldapUser.object);
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
@@ -451,6 +469,12 @@ export async function importNewUsers(ldap) {
log_debug('userQuery merge', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
if (user && boundToAnotherEntry(user, uniqueId)) {
// A reused login name: that account is another person's. Leave it,
// and do not create a second account with the same username either.
log_warn('LDAP import: username already belongs to another directory entry; skipping', username);
continue;
}
if (user) {
await syncUserData(user, ldapUser);
}
@@ -552,7 +576,17 @@ async function sync() {
if (user.services && user.services.ldap && user.services.ldap.id) {
ldapUser = await ldap.getUserById(user.services.ldap.id, user.services.ldap.idAttribute);
} else {
ldapUser = await ldap.getUserByUsername(user.username);
// An account without an identifier is matched by its username. Two
// directory entries answering to that name cannot be told apart:
// syncing the first one would bind the account to it for good
// (syncUserData stores its identifier). Skip the account instead -
// neither updated nor disabled.
try {
ldapUser = await ldap.getUserByUsername(user.username, { unique: true });
} catch (ambiguous) {
log_warn(ambiguous && ambiguous.message);
continue;
}
}
if (ldapUser) {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v12.78</title>
<title>WeKan REST API v12.83</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
<body>
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<script type="module">
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.78","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":"v12.83","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: v12.78
version: v12.83
description: |
The REST API allows you to control and extend Wekan with ease.
+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 = 1278,
appVersion = 1283,
# Increment this for every release.
appMarketingVersion = (defaultText = "12.78~2026-11-02"),
appMarketingVersion = (defaultText = "12.83~2026-11-02"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+17 -1
View File
@@ -1,6 +1,8 @@
// Database health probe, the Meteor half of models/lib/databaseHealth.js.
//
// Runs once at startup and then every five minutes, asks MongoDB what it can
// Runs once at startup and then every five minutes - on one server of the
// installation per period, see claimProbe in models/lib/databaseHealth.js -
// asks MongoDB what it can
// answer from WeKan's side of the socket - serverStatus, dbStats,
// getCmdLineOpts - and, when the data directory is a path this process can
// see, asks the kernel what filesystem it is on. Each finding is one row in
@@ -96,6 +98,19 @@ export async function probeDatabaseHealth() {
const dbPath = await dataDirectory(db);
// One server probes the database per period; the others only check what is
// local to them (the data directory below).
const claims = db.collection('databaseHealthProbe');
const { claimed, shared } = await health.claimProbe(claims, Date.now(), PROBE_MS)
// A claim that cannot be made (a database that refuses it) must not stop
// the probe: this server then probes for itself, as before.
.catch(() => ({ claimed: true, shared: {} }));
if (!claimed) {
checkDataDirectory(dbPath);
return;
}
for (const k of health.SHARED_STATE) if (shared[k] !== undefined) state[k] = shared[k];
const status = await serverStatus(db);
if (status) {
const uptime = Number(status.uptime);
@@ -138,6 +153,7 @@ export async function probeDatabaseHealth() {
const space = health.diskSpace(stats);
if (space && space.critical) recordDatabaseHealth(health.diskSpaceProblem(space, dbPath));
await claims.updateOne({ _id: health.PROBE_CLAIM_ID }, { $set: health.sharedState(state) }).catch(() => {});
checkDataDirectory(dbPath);
}
+78 -7
View File
@@ -89,6 +89,13 @@ const MARKER_ID = 'schema-upgrade';
const CHECKLIST_MINICARD_MARKER_ID = 'checklist-minicard-unset';
const COVER_ON_CARD_MARKER_ID = 'cover-on-card-on';
const CUSTOM_FIELDS_MINICARD_MARKER_ID = 'custom-fields-minicard-on';
// One server re-checks at a time (see runSchemaUpgrade).
const CLAIM_ID = 'schema-upgrade-running';
const CLAIM_STALE_MS = 2 * 60 * 60 * 1000;
// A re-check that left work it could not do (a file missing from every old
// location, an attachment whose kind cannot be told) is retried once a day,
// not at every start: each retry scans the large collections again.
const RETRY_UNCLEAN_MS = 24 * 60 * 60 * 1000;
// Meteor-style random document ids (Random.id(): 17 chars, unmistakable set),
// so upgraded documents look exactly like app-created ones.
@@ -1219,8 +1226,17 @@ async function healOne(db, ctx, { coll, doc, versionName, version }) {
* re-check. While the running WeKan version is unchanged, the whole run is
* ONE findOne — a full re-check is mandatory only after a release upgrade
* (or with options.force / WEKAN_FORCE_SCHEMA_UPGRADE=true). A run that
* leaves unresolved work (or a failed step) does NOT record the version, so
* the next boot re-checks.
* leaves unresolved work (or a failed step) does NOT record the version; it
* records `lastAttempt` instead, and the same version re-checks once
* RETRY_UNCLEAN_MS has passed - not at every start of every server.
*
* ONE SERVER AT A TIME: the checks scan whole collections (a findOne for a
* shape that no longer exists reads every document to prove it), so a run
* claims CLAIM_ID first. With several WeKan servers on one database - a
* Kubernetes Deployment of 24 replicas rolling to a new version - only the
* first one re-checks; the others skip, and a later start reads the marker
* that run left. A claim older than CLAIM_STALE_MS (a server killed
* mid-run) is taken over.
*
* @param db mongodb driver Db (MongoDB or FerretDB — same wire protocol)
* @param options {log?, writablePath?, appVersion?, force?}
@@ -1245,16 +1261,61 @@ async function runSchemaUpgrade(db, options = {}) {
if (s && typeof s.productName === 'string' && s.productName.trim()) upgradeState.product = s.productName.trim();
} catch { /* keep default */ }
// Version gate: unchanged release + previous re-check was clean -> one findOne, done.
if (!force && marker.lastCheck && marker.lastCheck.version === appVersion) {
log(`schema already re-checked for version ${appVersion} at ${marker.lastCheck.at}; skipping (set WEKAN_FORCE_SCHEMA_UPGRADE=true to force).`);
const skipAll = reason => {
log(reason);
upgradeState.running = false;
upgradeState.gated = true;
upgradeState.finishedAt = new Date();
for (const s of steps) upgradeState.steps[s.name].status = 'skipped';
return { ran: [], skipped: steps.map(s => s.name), results: {}, gated: true };
}
};
// Version gate: unchanged release + previous re-check was clean -> one findOne, done.
const gateReason = m => {
if (force) return null;
if (m.lastCheck && m.lastCheck.version === appVersion) {
return `schema already re-checked for version ${appVersion} at ${m.lastCheck.at}; skipping (set WEKAN_FORCE_SCHEMA_UPGRADE=true to force).`;
}
const tried = m.lastAttempt;
if (tried && tried.version === appVersion && Date.now() - Number(tried.atMs) < RETRY_UNCLEAN_MS) {
return `schema re-check for version ${appVersion} left work unresolved at ${tried.at}; retrying after ${RETRY_UNCLEAN_MS / 3600000} hours (set WEKAN_FORCE_SCHEMA_UPGRADE=true to force).`;
}
return null;
};
const gated = gateReason(marker);
if (gated) return skipAll(gated);
const claimedAt = await claimRun(markerColl, appVersion);
if (!claimedAt) return skipAll(`another WeKan server is re-checking the schema for version ${appVersion}; skipping.`);
try {
// That other server may have finished between the read above and the claim.
const fresh = (await markerColl.findOne({ _id: MARKER_ID })) || {};
const gatedNow = gateReason(fresh);
if (gatedNow) return skipAll(gatedNow);
return await runSteps(db, ctx, markerColl, appVersion, log);
} finally {
await markerColl.deleteOne({ _id: CLAIM_ID, sinceMs: claimedAt }).catch(() => {});
}
}
// Returns the claim's time, or null when another server holds a live claim.
async function claimRun(markerColl, appVersion) {
const now = Date.now();
const claim = { sinceMs: now, since: new Date(now), version: appVersion };
try {
await markerColl.insertOne({ _id: CLAIM_ID, ...claim });
return now;
} catch (e) {
if (!e || e.code !== 11000) throw e;
}
const r = await markerColl.updateOne(
{ _id: CLAIM_ID, sinceMs: { $lt: now - CLAIM_STALE_MS } },
{ $set: claim },
);
return r && r.modifiedCount ? now : null;
}
async function runSteps(db, ctx, markerColl, appVersion, log) {
const ran = [], skipped = [], results = {};
let clean = true;
@@ -1306,9 +1367,16 @@ async function runSchemaUpgrade(db, options = {}) {
upgradeState.lastCheck = lastCheck;
await markerColl.updateOne(
{ _id: MARKER_ID },
{ $set: { lastCheck } },
{ $set: { lastCheck }, $unset: { lastAttempt: '' } },
{ upsert: true },
).catch(e => log('marker lastCheck update failed: ' + e.message));
} else {
const now = Date.now();
await markerColl.updateOne(
{ _id: MARKER_ID },
{ $set: { lastAttempt: { version: appVersion, at: new Date(now), atMs: now } } },
{ upsert: true },
).catch(e => log('marker lastAttempt update failed: ' + e.message));
}
return { ran, skipped, results, gated: false };
}
@@ -1322,4 +1390,7 @@ module.exports = {
versionNeedsHeal,
MARKER_COLL,
MARKER_ID,
CLAIM_ID,
CLAIM_STALE_MS,
RETRY_UNCLEAN_MS,
};
+10
View File
@@ -568,6 +568,16 @@ export async function scanListSync() {
}
}
// scanListSync runs every 15 minutes. Most installations have no synced list
// at all, yet without this index every pass read the whole lists collection to
// find none (115,000 lists in one report). The partial index holds only synced
// lists, so the pass reads just those. Its own startup, so the jobs below are
// scheduled whatever happens to the index.
Meteor.startup(async () => {
await ensureIndex(Lists, { archived: 1, 'syncSource.type': 1 },
{ partialFilterExpression: { 'syncSource.type': { $exists: true } } });
});
Meteor.startup(async () => {
try {
await ensureIndex(ListSyncCredentials, { listId: 1, _id: 1 });
+4
View File
@@ -805,6 +805,10 @@ Meteor.startup(async () => {
// document-form $elemMatch; FerretDB v1 pushes those through json_each, so
// simple dotted org/team/domain indexes cannot accelerate them.
await ensureIndex(Boards, { archived: 1, type: 1, sort: 1 });
// The daily due-date reminder looks up the few boards with their own
// reminder days (Board Settings / Notifications). A sparse index holds only
// those boards, so the lookup no longer reads every board.
await ensureIndex(Boards, { dueReminderDays: 1 }, { sparse: true });
});
Boards.after.insert(async (userId, doc) => {
+40
View File
@@ -1031,6 +1031,46 @@ Cards.before.insert(async (userId, doc) => {
}
});
// #6815 follow-up: the same check for a card that ENTERS a board's numbering by
// an update - moved from another board, restored from the archive, or given a
// number (models/lib/cardNumberAssign.js numberToCheckOnUpdate). A number
// another card on that board has is replaced by a fresh one from the counter;
// a free one is kept, and the counter is raised past it.
Cards.before.update(async (userId, doc, fieldNames, modifier) => {
const { counterName, isCardNumber, counterFloor, numberToCheckOnUpdate } = require('/models/lib/cardNumberAssign');
const set = modifier && modifier.$set;
const check = numberToCheckOnUpdate(doc, set);
if (!check) return;
const { boardId, cardNumber } = check;
const { Counters } = require('/models/counters');
const counters = Counters.rawCollection();
const counter = counterName(boardId);
const topNumber = async () => {
const top = await Cards.findOneAsync(
{ boardId, _id: { $ne: doc._id } },
{ sort: { cardNumber: -1 }, fields: { cardNumber: 1 } },
);
return top && top.cardNumber;
};
const taken = await Cards.findOneAsync(
{ boardId, cardNumber, _id: { $ne: doc._id } },
{ fields: { _id: 1 } },
);
if (taken) {
const board = await Boards.findOneAsync(boardId);
if (!board) return;
const top = await topNumber();
if (isCardNumber(top)) {
await counters.updateOne({ _id: counter }, { $max: { next_val: top } }, { upsert: true });
}
set.cardNumber = await board.getNextCardNumber();
} else {
const exists = await counters.findOne({ _id: counter }, { projection: { _id: 1 } });
const floor = counterFloor(cardNumber, exists ? null : await topNumber());
await counters.updateOne({ _id: counter }, { $max: { next_val: floor } }, { upsert: true });
}
});
Cards.after.insert(async (userId, doc) => {
// Outside a method (a REST route, a server job) the hook has no user: the
// card's own author is who created it (#6823).
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '12.78'
version: '12.83'
base: core24
confinement: strict
grade: stable
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-12.78-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-12.83-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v12.78/wekan-12.78-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v12.83/wekan-12.83-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v12.78/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v12.83/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+43
View File
@@ -0,0 +1,43 @@
'use strict';
// #6854: the site announcement (Admin Panel -> Announcement) was shown only
// where a board is open, because the banner asked for membership of the current
// board on every page - and Home and All Boards (Remaining, Starred, Templates,
// Archive) have no board. It is for every signed-in user on those pages, and for
// the members of the open board on a board page; nobody signed out sees it
// (#1281).
//
// Run: node tests/announcementAudience.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const source = read('models/announcements.js');
const body = source.slice(source.indexOf('export function showAnnouncementToUser'), source.indexOf('export default Announcements'));
const sandbox = {};
vm.runInNewContext(`${body.replace('export function', 'function')}\nthis.fn = showAnnouncementToUser;`, sandbox);
const show = sandbox.fn;
const member = { hasMember: id => id === 'U' };
assert.equal(show({ userId: 'U', board: null }), true, 'Home and All Boards: no board, signed in');
assert.equal(show({ userId: 'U', board: undefined }), true);
assert.equal(show({ userId: 'U', board: member }), true, 'a member of the open board');
assert.equal(show({ userId: 'V', board: member }), false, 'negative: not a member of the open board');
assert.equal(show({ userId: 'U', board: {} }), false, 'negative: a board that cannot say');
assert.equal(show({ userId: null, board: null }), false, 'negative: signed out (#1281)');
assert.equal(show({ userId: undefined, board: member }), false);
// The banner uses it on every page, and no page-specific membership test is
// left in front of it.
const jade = read('client/components/main/header.jade');
const banner = jade.slice(jade.indexOf('if appIsOffline'), jade.indexOf('template(name="offlineWarning")'));
assert.match(banner, /if announcementForUser\s+if hasAnnouncement/);
assert.doesNotMatch(banner, /isBoardMember/, 'the banner does not wait for a current board');
assert.match(read('client/components/main/header.js'), /showAnnouncementToUser\(\{[\s\S]*Utils\.getCurrentBoard\(\)/);
// The header is part of the default layout, so Home and every All Boards list have it.
assert.match(read('client/components/main/layouts.jade'), /template\(name="defaultLayout"\)[\s\S]*\+header\b/);
console.log('announcementAudience: signed-in users on Home and All Boards, members on a board, nobody signed out');
+83
View File
@@ -0,0 +1,83 @@
'use strict';
// #6853: the date popup has year, month and day drop-downs under the calendar,
// and month + year (never YYYY-MM-DD) above it. The lists come from the
// calendar itself, so they hold for every system WeKan offers.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const months = require('../imports/lib/calendarMonth');
const { nativeCalendarParts } = require('../imports/lib/calendarSystems');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
const noon = (y, m, d) => new Date(y, m, d, 12);
const day = (date, calendar) => Number(nativeCalendarParts(date, calendar).day);
for (const calendar of ['gregory', 'persian', 'hebrew', 'islamic', 'japanese', 'chinese', 'roc', 'buddhist', 'coptic', 'ethiopic']) {
const date = noon(2026, 10, 2);
// Any year, near or far, past or future, by its number: it lands in that
// year, on the same day of the year.
assert.notEqual(months.calendarSignedYear(date, calendar), null, calendar);
for (const year of [1, 0, -3000, 1500, 3000, 5000, 12000, ...(calendar === "gregory" ? [100000, 270000] : [])]) {
const target = months.dateInCalendarYear(date, year, calendar);
// Some calendars have no year before their era that a number can name (ROC,
// Coptic, Ethiopic switch eras there; Japanese eras restart their count):
// for them the year is refused, never guessed.
const eraBound = ['roc', 'coptic', 'ethiopic', 'ethioaa'].includes(calendar) && year <= 1;
if (eraBound || (calendar === 'japanese' && year >= 0 && year < 10)) {
if (target) assert.equal(months.calendarSignedYear(target, calendar), year);
continue;
}
assert.ok(target, `${calendar}: year ${year}`);
assert.equal(months.calendarSignedYear(target, calendar), year, `${calendar}: year ${year}`);
}
// Beyond what a date holds, or not a whole number: refused, not wrong.
for (const bad of [300000, -300000, 1.5, NaN, Infinity]) {
assert.equal(months.dateInCalendarYear(date, bad, calendar), null, `${calendar}: ${bad}`);
}
// The months of the year: one selected, each a different month, in order,
// and a pick keeps the day or falls to the month's last day.
const list = months.calendarYearMonths(date, calendar);
assert.ok(list.length >= 12 && list.length <= 13, `${calendar}: ${list.length} months`);
assert.equal(list.filter(m => m.selected).length, 1);
for (let i = 1; i < list.length; i += 1) assert.ok(list[i].date > list[i - 1].date);
for (const month of list) {
const target = months.sameDayInMonth(date, month.date, calendar);
const range = months.calendarMonthRange(month.date, calendar);
assert.ok(target >= range.start && target < range.end, `${calendar}: stays in the month it was picked from`);
assert.equal(day(target, calendar), Math.min(day(date, calendar), Math.round((range.end - range.start) / 86400000)));
}
}
// Gregorian: every month of 2027 is reachable, 31 January into February ends on
// its last day, and a leap day moved into a common year keeps its day of the year, as the year
// arrows do: 1 March.
{
const list = months.calendarYearMonths(noon(2027, 0, 31), 'gregory');
assert.equal(list.length, 12);
assert.equal(months.sameDayInMonth(noon(2027, 0, 31), list[1].date, 'gregory').getDate(), 28);
const common = months.dateInCalendarYear(noon(2028, 1, 29), 2027, 'gregory');
assert.equal(common.getMonth(), 2);
assert.equal(common.getDate(), 1);
// 3000 BC is year -2999 here: 1 BC is 0.
assert.equal(months.calendarSignedYear(months.dateInCalendarYear(noon(2026, 10, 2), -2999, 'gregory'), 'gregory'), -2999);
}
// Negative: the heading carries no YYYY-MM-DD, the year is never left out, and
// the drop-downs sit under the calendar and are wired to their own events.
{
const jade = read('client/components/forms/calendarDateInput.jade');
const js = read('client/components/forms/calendarDateInput.js');
assert.doesNotMatch(jade, /selected-calendar-date|headingDateText/);
assert.doesNotMatch(js, /headingDateText|inline \? \{\} : \{ year/);
assert.match(jade, /input\.js-calendar-year\(type="number" step="1"/, 'the year is a number field, not a list that ends');
assert.doesNotMatch(jade, /select\.js-calendar-year/);
for (const name of ['js-calendar-year', 'js-calendar-month', 'js-calendar-day-select']) {
if (name !== 'js-calendar-year') assert.match(jade, new RegExp(`select\\.${name}\\(aria-label`), `${name} is a select`);
assert.match(js, new RegExp(`'change \\.${name}'`), `${name} has a handler`);
}
assert.doesNotMatch(read('client/components/forms/calendarDateInput.css'), /selected-calendar-date/);
}
console.log('calendarDropdowns: a year by number (thousands of years either way), month and day lists for ten calendar systems, picks that stay in the named year and month, and no date text in the heading');
+53 -7
View File
@@ -65,14 +65,60 @@ assert.equal(tpl.expanded.get(), false);
assert.equal(helpers.expanded(), false, 'compact inputs retain their toggle behavior');
data.inline = true;
assert.equal(helpers.expanded(), true, 'popup calendar is visible immediately');
assert.equal(helpers.monthLabel(), 'Farvardin');
assert.match(helpers.headingDateText(), /^1405-01-02/);
const selectedBeforeEmpty = tpl.selectedDate.get();
tpl.selectedDate.set(null);
assert.ok(helpers.headingDateText(), 'empty date shows the visible month date in the heading');
tpl.selectedDate.set(selectedBeforeEmpty);
assert.match(helpers.monthLabel(), /^Farvardin 1405/, 'the popup heading names the year too (#6853)');
assert.doesNotMatch(read('client/components/forms/calendarDateInput.js'), /inline \? \{\} : \{ year/, 'no mode of the picker leaves the year out (#6853)');
// #6853: month and year above the calendar, never the YYYY-MM-DD of the day;
// year, month and day drop-downs under it pick any date by a click.
{
const jade = read('client/components/forms/calendarDateInput.jade');
assert.doesNotMatch(jade, /selected-calendar-date|headingDateText/, 'no date text in the heading');
assert.equal(helpers.headingDateText, undefined);
assert.match(jade, /\.selected-calendar-picker[\s\S]*\.selected-calendar-selects[\s\S]*js-calendar-year[\s\S]*js-calendar-month[\s\S]*js-calendar-day-select/,
'the drop-downs come after the calendar, in year, month, day order');
const selectedBefore = tpl.selectedDate.get(); const shownBefore = tpl.shownDate.get();
assert.equal(helpers.yearValue(), 1405, 'the year field shows the shown year');
assert.equal(helpers.monthOptions().length, 12);
assert.equal(helpers.monthOptions().filter(option => option.selected).length, 1);
assert.equal(helpers.dayOptions().length, 31, 'Farvardin has 31 days');
assert.equal(helpers.dayOptions().filter(option => option.selected).length, 1);
assert.equal(helpers.noDaySelected(), false);
const change = (name, value) => events[`change .${name}`]({ currentTarget: { value } }, tpl);
// A year pick keeps the day of the year; a month pick keeps the day of the
// month; a day pick takes that day. Each selects the date and shows its month.
const yearInput = { value: '1410' };
const changeYear = value => { yearInput.value = value; events['change .js-calendar-year']({ currentTarget: yearInput }, tpl); };
changeYear('1410');
assert.match(helpers.monthLabel(), /^Farvardin 1410/);
assert.match(helpers.selectedDateText(), /^1410-01-02/);
// Any year by typing: far back and far ahead, in the calendar in use.
changeYear('4000'); assert.equal(helpers.yearValue(), 4000);
assert.match(helpers.monthLabel(), /^Farvardin 4000/);
changeYear('-2500'); assert.equal(helpers.yearValue(), -2500);
assert.match(helpers.monthLabel(), /2501 AP|BP|Farvardin/);
changeYear('1410');
// Nothing that is not a year changes the date; the field goes back.
const before = iso(tpl.shownDate.get());
for (const bad of ['', 'abc', '1.5', '99999999']) {
changeYear(bad);
assert.equal(iso(tpl.shownDate.get()), before, `"${bad}" is refused`);
assert.equal(yearInput.value, 1410, `"${bad}": the field shows the shown year again`);
}
const month = helpers.monthOptions()[5].value;
change('js-calendar-month', month);
assert.match(helpers.monthLabel(), /^Shahrivar 1410/);
assert.match(helpers.selectedDateText(), /^1410-06-02/);
change('js-calendar-day-select', helpers.dayOptions()[30].value);
assert.match(helpers.selectedDateText(), /^1410-06-31/);
// Shahrivar's 31st into Mehr (30 days) lands on Mehr's last day, not Aban.
change('js-calendar-month', helpers.monthOptions()[7].value);
assert.match(helpers.selectedDateText(), /^1410-08-30/);
change('js-calendar-day-select', '');
assert.match(helpers.selectedDateText(), /^1410-08-30/, 'the empty placeholder changes nothing');
tpl.selectedDate.set(null);
assert.equal(helpers.noDaySelected(), true, 'no day selected shows the placeholder');
tpl.selectedDate.set(selectedBefore); tpl.shownDate.set(shownBefore);
}
assert.doesNotMatch(read('client/components/forms/datepicker.jade'), /label \{\{_ 'date'\}\}/);
assert.match(read('client/components/forms/calendarDateInput.jade'), /span\(aria-live="polite"\)[\s\S]*span.selected-calendar-date/);
assert.equal(helpers.useNativeInput.call(data), false);
// #6838: every week and every cell, empty ones too, has a unique _id that
// names its month - Blaze reused cells by position without one, and a month
+73 -1
View File
@@ -13,7 +13,7 @@ const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { counterName, isCardNumber, needsNewCardNumber, counterFloor } = require('../models/lib/cardNumberAssign.js');
const { counterName, isCardNumber, needsNewCardNumber, counterFloor, numberToCheckOnUpdate } = require('../models/lib/cardNumberAssign.js');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const pending = [];
@@ -92,6 +92,78 @@ test('negative: "no number" is left alone, and an existing counter is only raise
assert.strictEqual(a.store.counter, 50, 'never lowered');
});
// ---- updates: a move to another board, a restore, a number set ------------
test('which updates bring a card into a board\'s numbering', () => {
const doc = { _id: 'c', boardId: 'a', cardNumber: 12, archived: false };
assert.deepStrictEqual(numberToCheckOnUpdate(doc, { boardId: 'b', cardNumber: 1 }), { boardId: 'b', cardNumber: 1 }, 'move: the browser\'s number on the destination');
assert.deepStrictEqual(numberToCheckOnUpdate({ ...doc, archived: true }, { archived: false }), { boardId: 'a', cardNumber: 12 }, 'restore');
assert.deepStrictEqual(numberToCheckOnUpdate(doc, { cardNumber: 40 }), { boardId: 'a', cardNumber: 40 }, 'number set');
assert.strictEqual(numberToCheckOnUpdate(doc, { title: 'x' }), null, 'negative: an ordinary edit');
assert.strictEqual(numberToCheckOnUpdate(doc, { boardId: 'a', listId: 'l2' }), null, 'negative: a move within the board');
assert.strictEqual(numberToCheckOnUpdate(doc, { archived: true }), null, 'negative: archiving');
assert.strictEqual(numberToCheckOnUpdate({ ...doc, cardNumber: 0 }, { archived: false, boardId: 'b' }), null, 'negative: no number');
assert.strictEqual(numberToCheckOnUpdate(doc, undefined), null);
});
function runUpdateHook(cards, counter) {
const src = read('server/models/cards.js');
const start = src.indexOf('Cards.before.update(async (userId, doc, fieldNames, modifier) => {\n const { counterName, isCardNumber, counterFloor, numberToCheckOnUpdate }');
assert.ok(start > 0, 'the update hook exists');
const body = src.slice(start, src.indexOf('Cards.after.insert(', start));
const fn = body.slice(body.indexOf('async (userId, doc, fieldNames, modifier) =>'), body.lastIndexOf('});')) + '}';
const store = { counter };
const Counters = { rawCollection: () => ({
findOne: async () => (store.counter === undefined ? null : { _id: 'c' }),
updateOne: async (q, u) => { store.counter = Math.max(store.counter ?? -Infinity, u.$max.next_val); },
}) };
const others = sel => cards.filter(c => c.boardId === sel.boardId && (!sel._id || c._id !== sel._id.$ne));
const Cards = { findOneAsync: async (sel, opts) => {
const on = others(sel);
if ('cardNumber' in sel) return on.find(c => c.cardNumber === sel.cardNumber) || null;
if (opts && opts.sort) return on.sort((a, b) => b.cardNumber - a.cardNumber)[0] || null;
return null;
} };
const Boards = { findOneAsync: async () => ({ getNextCardNumber: async () => { store.counter += 1; return store.counter; } }) };
const requireStub = name => (name === '/models/counters' ? { Counters } : require(path.join(__dirname, '..', name.replace(/^\//, ''))));
const hook = new Function('Cards', 'Boards', 'require', `return (${fn});`)(Cards, Boards, requireStub);
return { hook, store };
}
test('a card moved from the browser to another board gets a free number there, not the browser\'s', async () => {
// the destination board: cards 1..3 and 200; the browser had none of them loaded
const cards = [1, 2, 3, 200].map(n => ({ _id: `d${n}`, boardId: 'dest', cardNumber: n }));
const { hook, store } = runUpdateHook(cards, 3);
const modifier = { $set: { boardId: 'dest', listId: 'l', cardNumber: 1 } };
await hook('u', { _id: 'moved', boardId: 'src', cardNumber: 57 }, ['boardId'], modifier);
assert.strictEqual(modifier.$set.cardNumber, 201);
assert.strictEqual(store.counter, 201);
});
test('a restored card that shares its number with an open card gets a new one', async () => {
const cards = [{ _id: 'open', boardId: 'b', cardNumber: 9 }, { _id: 'top', boardId: 'b', cardNumber: 50 }];
const { hook } = runUpdateHook(cards, 50);
const modifier = { $set: { archived: false } };
await hook('u', { _id: 'old', boardId: 'b', cardNumber: 9, archived: true }, ['archived'], modifier);
assert.strictEqual(modifier.$set.cardNumber, 51);
});
test('negative: a free number is kept on a move and a restore, and the card itself does not count as taken', async () => {
const cards = [{ _id: 'self', boardId: 'b', cardNumber: 9 }, { _id: 'x', boardId: 'dest', cardNumber: 4 }];
const a = runUpdateHook(cards, 4);
const restore = { $set: { archived: false } };
await a.hook('u', { _id: 'self', boardId: 'b', cardNumber: 9, archived: true }, ['archived'], restore);
assert.strictEqual(restore.$set.cardNumber, undefined, 'its own number is not a duplicate');
const d = runUpdateHook(cards, 4); // the destination board's counter
const move = { $set: { boardId: 'dest', cardNumber: 5 } };
await d.hook('u', { _id: 'self', boardId: 'b', cardNumber: 9 }, ['boardId'], move);
assert.strictEqual(move.$set.cardNumber, 5, 'free on the destination: kept');
assert.strictEqual(d.store.counter, 5, 'and the counter raised past it');
const edit = { $set: { title: 'y' } };
await d.hook('u', { _id: 'self', boardId: 'b', cardNumber: 9 }, ['title'], edit);
assert.strictEqual(d.store.counter, 5, 'an ordinary edit does nothing');
});
(async () => {
let passed = 0;
console.log('cardNumberUnique6815:');
+126
View File
@@ -0,0 +1,126 @@
'use strict';
// With several WeKan servers on one database every server ran the database
// health probe and recorded every finding itself: one collection scan in the
// MongoDB log became 24 identical rows in Admin Panel / Problems on a
// Kubernetes Deployment of 24 replicas. One server probes per period now
// (claimProbe, models/lib/databaseHealth.js), and the state that compares one
// probe with the next is shared, so the next probe - on any server - does not
// report the same log lines again.
// Run: node tests/databaseHealthProbeClaim.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const h = require('../models/lib/databaseHealth.js');
let passed = 0;
async function test(name, fn) { await fn(); passed += 1; console.log(' ok -', name); }
// The findOneAndUpdate the driver does: match -> update and return the old
// document; no match + upsert -> insert, which fails with 11000 when the _id
// exists (another server holds the period).
function fakeColl() {
const docs = new Map();
const match = (doc, f) => {
if (f._id !== undefined && (!doc || doc._id !== f._id)) return false;
if (!f.$or) return true;
return f.$or.some(c => ('claimedMs' in c) && (c.claimedMs.$exists === false
? doc.claimedMs === undefined : doc.claimedMs !== undefined && doc.claimedMs < c.claimedMs.$lt));
};
return {
docs,
async findOneAndUpdate(filter, update, opts) {
await null; // let concurrent callers interleave like real round trips
const doc = docs.get(filter._id);
if (doc && match(doc, filter)) {
const before = JSON.parse(JSON.stringify(doc));
Object.assign(doc, update.$set);
return before;
}
if (!opts.upsert) return null;
if (doc) throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
docs.set(filter._id, { _id: filter._id, ...update.$set });
return null;
},
async updateOne(filter, update) { Object.assign(docs.get(filter._id), update.$set); },
};
}
const PERIOD = 5 * 60 * 1000;
(async () => {
await test('24 servers probing at the same moment: exactly one probes', async () => {
const coll = fakeColl();
const now = Date.now();
const results = await Promise.all(Array.from({ length: 24 }, () => h.claimProbe(coll, now, PERIOD)));
assert.strictEqual(results.filter(r => r.claimed).length, 1);
assert.deepStrictEqual(results.find(r => r.claimed).shared, {}, 'the very first probe starts with no shared state');
});
await test('the next period is claimed by whichever server comes first, with the state the last probe left', async () => {
const coll = fakeColl();
const t0 = Date.now();
await h.claimProbe(coll, t0, PERIOD);
await coll.updateOne({ _id: h.PROBE_CLAIM_ID }, { $set: h.sharedState({ logSeenMs: 1234, restarts: 2, networkFsReported: true }) });
assert.strictEqual((await h.claimProbe(coll, t0 + 60 * 1000, PERIOD)).claimed, false, 'a minute later: still claimed');
const next = await h.claimProbe(coll, t0 + PERIOD, PERIOD);
assert.strictEqual(next.claimed, true);
assert.strictEqual(next.shared.logSeenMs, 1234, 'continues after the log lines already reported');
assert.strictEqual(next.shared.restarts, 2);
});
await test('a slow-query line is reported once, by one server, not once per server', async () => {
const coll = fakeColl();
const line = at => JSON.stringify({ t: { $date: new Date(at).toISOString() }, msg: 'Slow query', attr: {
ns: 'wekan.lists', planSummary: 'COLLSCAN', docsExamined: 115528, durationMillis: 150,
command: { find: 'lists', filter: { archived: false, 'syncSource.type': { $exists: true } } } } });
const t0 = Date.parse('2026-11-02T09:30:00Z');
const log = [line(t0 + 1000)];
let rows = 0;
const probe = async (now, localSeen) => {
const { claimed, shared } = await h.claimProbe(coll, now, PERIOD);
if (!claimed) return;
const seen = shared.logSeenMs !== undefined ? shared.logSeenMs : localSeen;
const { groups, newestMs } = h.collectionScans(log, seen);
rows += groups.length;
await coll.updateOne({ _id: h.PROBE_CLAIM_ID }, { $set: { logSeenMs: newestMs } });
};
await Promise.all(Array.from({ length: 24 }, () => probe(t0 + 2000, t0)));
await probe(t0 + PERIOD + 2000, t0); // another server, next period, same log
assert.strictEqual(rows, 1);
});
await test('negative: any other database error is not swallowed by the claim', async () => {
const coll = { findOneAndUpdate: async () => { throw Object.assign(new Error('down'), { code: 6 }); } };
await assert.rejects(() => h.claimProbe(coll, Date.now(), PERIOD), /down/);
});
await test('an older driver result ({ value }) is read too', async () => {
const coll = { findOneAndUpdate: async () => ({ ok: 1, value: { _id: h.PROBE_CLAIM_ID, logSeenMs: 7 } }) };
assert.strictEqual((await h.claimProbe(coll, Date.now(), PERIOD)).shared.logSeenMs, 7);
});
await test('only state about the database is shared; what is local to a server is not', async () => {
assert.deepStrictEqual(Object.keys(h.sharedState({ logSeenMs: 1, scanned: { value: 2 }, uptime: 3, restarts: 0,
opLatencies: {}, networkFsReported: true })).sort(), ['logSeenMs', 'opLatencies', 'restarts', 'scanned', 'uptime']);
});
await test('the probe claims the period before it reads the database, and saves the shared state after', async () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server/lib/databaseHealth.js'), 'utf8');
const fn = src.slice(src.indexOf('export async function probeDatabaseHealth'));
const claim = fn.indexOf('health.claimProbe(');
assert.ok(claim > 0 && claim < fn.indexOf('serverStatus(db)') && claim < fn.indexOf('globalLog(db)'));
assert.ok(/if \(!claimed\) \{\n\s+checkDataDirectory\(dbPath\);\n\s+return;/.test(fn), 'a server without the claim checks only its own data directory');
assert.ok(fn.indexOf('health.sharedState(state)') > fn.indexOf('globalLog(db)'));
});
await test('negative: a claim that cannot be made does not stop the probe', async () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server/lib/databaseHealth.js'), 'utf8');
const fn = src.slice(src.indexOf('export async function probeDatabaseHealth'));
assert.ok(/\.catch\(\(\) => \(\{ claimed: true, shared: \{\} \}\)\)/.test(fn), 'a refused claim probes for itself, as before');
assert.ok(!/\.catch\(\(\) => \(\{ claimed: false/.test(fn), 'no database error silently turns probing off');
});
console.log(`databaseHealthProbeClaim.test.cjs: ${passed} tests passed`);
})().catch(e => { console.error(e); process.exit(1); });
+18 -1
View File
@@ -44,7 +44,7 @@ test('big collection scans are grouped by collection and filter fields', () => {
assert.strictEqual(newestMs, Date.parse('2026-10-27T18:56:05.000+00:00'));
const p = h.collectionScanProblem(groups);
assert.strictEqual(p.type, 'db.collection-scan');
assert.match(p.detail, /wekan\.cards \(filter on archived,recurrenceInterval: 1x, up to 1108017 documents, 1155 ms\)/);
assert.match(p.detail, /wekan\.cards \(filter on archived,recurrenceInterval: 1x, up to 1108017 documents, 1155 ms; find archived:\$ne,recurrenceInterval:\$exists\)/);
});
test('negative: the values a query filtered on never reach the problem row', () => {
@@ -70,6 +70,23 @@ test('the filter is found in find, findAndModify, aggregate, update and delete c
assert.strictEqual(scan({ count: 'x' }), '');
});
test('the problem row names the command, the operators and the queryHash, never a value', () => {
const groups = h.collectionScans([line('2026-10-29T08:00:00.000+00:00', { ns: 'wekan.cards', planSummary: 'COLLSCAN',
docsExamined: 1108757, durationMillis: 1416, queryHash: '8E2A1F0C',
command: { find: 'cards', filter: { sort: { $type: 'number' }, $nor: [{ sort: { $gte: -1, $lte: 1 } }] }, $db: 'wekan' } })], 0).groups;
const p = h.collectionScanProblem(groups);
assert.match(p.detail, /find \$nor\[sort:\$gte\$lte\],sort:\$type, queryHash 8E2A1F0C\)/);
assert.strictEqual(h.queryShape({ userId: SECRET, archived: { $in: [SECRET] } }), 'archived:$in,userId:=');
assert.ok(!h.queryShape({ $or: [{ a: SECRET }, { b: { $exists: true } }] }).includes(SECRET));
});
test('a slow getMore is reported with the filter of the query it continues', () => {
const groups = h.collectionScans([line('2026-10-29T08:00:00.000+00:00', { ns: 'wekan.swimlanes', planSummary: 'COLLSCAN',
docsExamined: 178606, durationMillis: 146, command: { getMore: 123, collection: 'swimlanes' },
originatingCommand: { find: 'swimlanes', filter: { archived: { $exists: false } } } })], 0).groups;
assert.deepStrictEqual(groups.map(g => [g.fields, g.op, g.shape]), [['archived', 'find', 'archived:$exists']]);
});
test('documents read per second between two probes', () => {
assert.strictEqual(h.scanRate(1000, 301000, 300), 1000);
assert.strictEqual(h.scanRate(null, 5, 300), null);
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// The daily due-date reminder scan runs once per installation, not once per
// WeKan server (models/lib/dueScanClaim.js, models/cards.js addCronJob).
// Run: node tests/dueScanClaim.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { claimDueScanDay, dayKey, COLLECTION } = require('../models/lib/dueScanClaim');
let passed = 0;
async function test(name, fn) { await fn(); passed += 1; console.log(' ok -', name); }
function fakeDb() {
const ids = new Map();
return {
collection(name) {
if (!ids.has(name)) ids.set(name, new Set());
const set = ids.get(name);
return {
async insertOne(doc) {
if (set.has(doc._id)) throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
set.add(doc._id);
return { insertedId: doc._id };
},
};
},
_ids: ids,
};
}
(async () => {
await test('24 servers at 8:00: exactly one scans', async () => {
const db = fakeDb();
const now = new Date(2026, 9, 29, 8, 0, 3);
const results = await Promise.all(Array.from({ length: 24 }, () => claimDueScanDay(db, now)));
assert.strictEqual(results.filter(Boolean).length, 1);
assert.deepStrictEqual([...db._ids.get(COLLECTION)], ['2026-10-29']);
});
await test('the next day is claimed again', async () => {
const db = fakeDb();
assert.strictEqual(await claimDueScanDay(db, new Date(2026, 9, 29, 8)), true);
assert.strictEqual(await claimDueScanDay(db, new Date(2026, 9, 30, 8)), true);
});
await test('dayKey is the local date, zero-padded', async () => {
assert.strictEqual(dayKey(new Date(2026, 0, 5, 23, 59)), '2026-01-05');
});
await test('negative: any other database error is not swallowed', async () => {
const db = { collection: () => ({ insertOne: async () => { throw Object.assign(new Error('down'), { code: 6 }); } }) };
await assert.rejects(() => claimDueScanDay(db), /down/);
});
await test('the scheduler claims the day before it scans', async () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'models', 'cards.js'), 'utf8');
const job = src.slice(src.indexOf('const addCronJob'), src.indexOf('export {', src.indexOf('const addCronJob')));
const claim = job.indexOf('claimDueScanDay(');
const scan = job.indexOf('findDueCards(notifydays)');
assert.ok(claim > 0 && scan > claim, 'claim, then scan');
assert.ok(job.includes('if (!(await claimDueScanDay(Cards.rawDatabase()))) return;'), 'a server that did not claim the day returns');
});
console.log(`dueScanClaim.test.cjs: ${passed} tests passed`);
})().catch(e => { console.error(e); process.exit(1); });
+1 -1
View File
@@ -15,7 +15,7 @@ module.exports = function harness(overrides = {}) {
const context = vm.createContext({process:{env:{}}, Buffer, global,
Log:log, normalizeLdapEncryption:()=>({mode:'off'}),resolveConfigValue:()=>({value:undefined}),
...require(path.join(directory,'groupFilterConfig')), ...require(path.join(directory,'userCredentials')),
...require(path.join(directory,'userSearchValue')),
...require(path.join(directory,'userSearchValue')), ...require(path.join(directory,'accountBinding')),
Accounts:{registerLoginHandler(name,fn){login=fn;}, _runLoginHandlers:async()=>{state.events.push('fallback');return {userId:'fallback'};}},
Meteor:{Error:class extends Error{},users:{findOneAsync:async()=>{state.events.push('lookup');return {_id:'alice-id',username:'alice',authenticationMethod:'ldap'};}}},
slug:v=>v, getLdapUserUniqueID:()=>null, syncUserData:async()=>{},syncUserGroupsToOrgsTeams:async()=>{},
+101
View File
@@ -0,0 +1,101 @@
'use strict';
// An LDAP login that finds a WeKan account by username or e-mail must not log
// into it when the account belongs to another directory entry (a reused login
// name); LDAP_EMAIL_MATCH_REQUIRE looked users up by _id = username and so
// never found one; and the background sync must not bind an account without
// an identifier to the first of several entries with its name.
// (packages/wekan-ldap/server/accountBinding.js, loginHandler.js, sync.js, ldap.js)
// Run: node tests/ldapAccountBinding.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { boundToAnotherEntry } = require('../packages/wekan-ldap/server/accountBinding');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
let passed = 0;
const test = (name, fn) => { fn(); passed += 1; console.log(' ok -', name); };
const guid = (v, attribute = 'objectGUID') => ({ attribute, value: v });
const account = (id, idAttribute = 'objectGUID') => ({ _id: 'u1', username: 'jdevries', services: { ldap: { id, idAttribute } } });
test('a reused login name: the account records another entry\'s identifier -> refused', () => {
assert.strictEqual(boundToAnotherEntry(account('aa11'), guid('bb22')), true);
});
test('negative: the same entry, an account without an identifier, a changed identifier attribute', () => {
assert.strictEqual(boundToAnotherEntry(account('aa11'), guid('aa11')), false, 'same person');
assert.strictEqual(boundToAnotherEntry({ _id: 'u1', username: 'x' }, guid('bb22')), false, 'local account merged into LDAP');
assert.strictEqual(boundToAnotherEntry({ services: { ldap: {} } }, guid('bb22')), false);
assert.strictEqual(boundToAnotherEntry(account('6a64', 'uid'), guid('bb22')), false, 'LDAP_UNIQUE_IDENTIFIER_FIELD changed: cannot compare');
assert.strictEqual(boundToAnotherEntry(account('aa11', 'OBJECTGUID'), guid('aa11')), false, 'attribute names compare case-insensitively');
assert.strictEqual(boundToAnotherEntry(account('aa11'), undefined), false, 'no identifier configured');
assert.strictEqual(boundToAnotherEntry(undefined, guid('aa11')), false);
});
test('negative: an older account without idAttribute is not compared, so switching to objectGUID locks nobody out', () => {
// the login name "jdevries" as hex, the identifier such accounts hold when
// LDAP_UNIQUE_IDENTIFIER_FIELD was unset; the login now offers an objectGUID
const byName = { services: { ldap: { id: Buffer.from('jdevries').toString('hex') } } };
assert.strictEqual(boundToAnotherEntry(byName, guid('9f1c2b7e4a5d6e7f8091a2b3c4d5e6f7')), false);
assert.strictEqual(boundToAnotherEntry({ services: { ldap: { id: 'aa11' } } }, guid('bb22')), false);
assert.strictEqual(boundToAnotherEntry(account('aa11'), { value: 'bb22' }), false, 'no attribute offered either');
});
test('the login refuses such an account before it logs in, syncs or sets a password', () => {
const src = read('packages/wekan-ldap/server/loginHandler.js');
const guard = src.indexOf('if (user && boundToAnotherEntry(user, Unique_Identifier_Field)) {');
assert.ok(guard > 0);
assert.ok(guard < src.indexOf('// Login user if they exist'));
assert.ok(guard > src.indexOf("'emails.0.address' : email"), 'after the username and e-mail lookups');
assert.ok(/boundToAnotherEntry[\s\S]{0,300}throw new Meteor\.Error\('LDAP-login-error', 'LDAP authentication failed'\)/.test(src));
});
test('LDAP_EMAIL_MATCH_REQUIRE looks the user up by username, not by _id', () => {
const src = read('packages/wekan-ldap/server/loginHandler.js');
assert.ok(!src.includes("'_id' : username"), 'a user _id is never the username');
const block = src.slice(src.indexOf("LDAP_EMAIL_MATCH_REQUIRE') === true"), src.indexOf('} else {\n userQuery = {\n username\n'));
assert.strictEqual((block.match(/\n\s+username,\n/g) || []).length, 2);
});
test('the import does not merge a username that belongs to another entry', () => {
const src = read('packages/wekan-ldap/server/sync.js');
const fn = src.slice(src.indexOf('export async function importNewUsers'), src.indexOf('async function callLdapOrgTeamSyncInternal'));
const guard = fn.indexOf('if (user && boundToAnotherEntry(user, uniqueId)) {');
assert.ok(guard > 0 && guard < fn.indexOf('await syncUserData(user, ldapUser);'));
assert.ok(/boundToAnotherEntry\(user, uniqueId\)\) \{[\s\S]{0,300}continue;/.test(fn), 'skipped, not added again');
});
test('background sync: an ambiguous username is skipped - not bound to the first entry, not disabled', () => {
const sync = read('packages/wekan-ldap/server/sync.js');
assert.ok(sync.includes('await ldap.getUserByUsername(user.username, { unique: true });'));
assert.ok(/getUserByUsername\(user\.username, \{ unique: true \}\);\n\s+\} catch \(ambiguous\) \{\n\s+log_warn\([^)]*\);\n\s+continue;/.test(sync));
const ldap = read('packages/wekan-ldap/server/ldap.js');
assert.ok(/async getUserByUsername\(username, \{ unique = false \} = \{\}\)/.test(ldap));
assert.ok(/if \(unique\) \{\n\s+throw new Error\(/.test(ldap));
});
test('a login or sync records the identifier and its attribute on an existing account', () => {
const src = read('packages/wekan-ldap/server/sync.js');
const fn = src.slice(src.indexOf('export async function syncUserData'), src.indexOf('export async function addLdapUser'));
assert.ok(fn.includes("'services.ldap.id': uniqueId.value,") && fn.includes("'services.ldap.idAttribute': uniqueId.attribute,"));
assert.ok(/toLowerCase\(\) !== String\(uniqueId\.attribute \|\| ''\)\.toLowerCase\(\)/.test(fn), 'only when it changed, attribute case-insensitive');
});
test('negative, anywhere in the package: no lookup by _id = username, and no name or e-mail match reaches a login unguarded', () => {
const fs2 = require('node:fs');
const dir = path.join(__dirname, '..', 'packages/wekan-ldap/server');
for (const file of fs2.readdirSync(dir).filter(f => f.endsWith('.js'))) {
const code = fs2.readFileSync(path.join(dir, file), 'utf8');
assert.ok(!/['"]_id['"]\s*:\s*username\b/.test(code), `${file} looks a user up with _id = username`);
}
const login = read('packages/wekan-ldap/server/loginHandler.js');
const guard = login.indexOf('boundToAnotherEntry(user, Unique_Identifier_Field)');
const lookups = [...login.matchAll(/user = await Meteor\.users\.findOneAsync\(/g)].map(m => m.index);
assert.ok(lookups.length >= 3, 'the id, username and e-mail lookups');
assert.ok(guard > Math.max(...lookups), 'the guard comes after every lookup that finds an account');
assert.ok(guard < login.indexOf('// Login user if they exist'), 'and before the login');
});
console.log(`ldapAccountBinding.test.cjs: ${passed} tests passed`);
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// Dependabot alerts #180 and #182, GHSA-g3hv-75wg-m249 (CVE-2026-118764):
// minimatch before 11.0.1 grows its glob parts without bound while it
// preprocesses an attacker's pattern (the "**/../" step clones an entry into
// the same array, inside a loop that runs until nothing changes), then
// compares them pairwise: CPU and memory exhaustion. WeKan reached 10.2.6
// through archiver (readdir-glob), @typescript-eslint/typescript-estree and
// @babel/cli's glob. Fixed by raising all of them to minimatch 11.0.1 or later.
//
// Run: node tests/minimatchGlobparts.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = rel => JSON.parse(fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'));
const lock = read('package-lock.json');
const pkg = read('package.json');
// Every copy of minimatch the lockfile installs, with the range of its consumers.
const copies = Object.entries(lock.packages)
.filter(([key]) => /(^|\/)node_modules\/minimatch$/.test(key))
.map(([key, entry]) => ({ key, version: entry.version }));
assert.ok(copies.length > 0);
// The release line the advisory names (10.x, and every 11.0.0) is gone.
for (const { key, version } of copies) {
assert.doesNotMatch(version, /^10\./, `${key} ${version} is the affected 10.x line`);
assert.notEqual(version, '11.0.0', `${key} is the affected 11.0.0`);
}
// The root copy, the one archiver, typescript-estree and babel resolve to.
const root = lock.packages['node_modules/minimatch'];
assert.ok(root && Number(root.version.split('.')[0]) >= 11, 'root minimatch is 11 or later');
assert.ok(/^11\.(0\.[1-9]|[1-9]|\d\d)/.test(root.version) || Number(root.version.split('.')[0]) > 11, root.version);
// No consumer is held on 10.x by its own range or by an override.
for (const [key, entry] of Object.entries(lock.packages)) {
const range = entry.dependencies && entry.dependencies.minimatch;
if (!range || /^\^?3\./.test(range)) continue;
if (/^\^?10\./.test(range)) {
assert.ok(key.includes('node_modules/'), key);
const overridden = ['readdir-glob', '@typescript-eslint/typescript-estree', '@babel/cli'].some(name => key.includes(`node_modules/${name}`));
assert.ok(overridden, `${key} asks for minimatch ${range} and no override raises it`);
}
}
for (const name of ['readdir-glob', '@typescript-eslint/typescript-estree', '@babel/cli']) {
assert.match(String(pkg.overrides[name] && pkg.overrides[name].minimatch), /^\^11\./, `${name} is overridden to minimatch 11`);
}
assert.match(pkg.devDependencies.minimatch, /^\^11\./, 'the root copy is declared, so every consumer finds it');
console.log('minimatchGlobparts: no minimatch 10.x in the lockfile, archiver, typescript-estree and babel use 11.0.1 or later');
@@ -89,9 +89,13 @@ test('Jalali mouse and keyboard selection saves the same native instant in an En
const popup = page.locator('.js-pop-over');
await expect(popup.locator('.selected-calendar-picker')).toBeVisible();
await expect(popup.locator('.js-calendar-toggle')).toHaveCount(0);
await expect(popup.locator('.selected-calendar-date')).toContainText('1405-01-01');
await expect(popup.locator('.selected-calendar-heading')).toContainText('Farvardin');
await expect(popup.locator('.selected-calendar-heading .selected-calendar-date')).toHaveCount(1);
// #6853: month and year above the calendar, no YYYY-MM-DD; year, month and
// day drop-downs under it.
await expect(popup.locator('.selected-calendar-heading')).not.toContainText('1405-01-01');
await expect(popup.locator('.selected-calendar-selects .js-calendar-year')).toHaveValue('1405');
await expect(popup.locator('.selected-calendar-selects .js-calendar-month option')).toHaveCount(12);
await expect(popup.locator('.selected-calendar-selects .js-calendar-day-select')).toHaveValue('2026-03-21');
await expect(popup.locator('.selected-calendar-heading')).toContainText('Farvardin 1405');
await expect(popup.locator('.fields > .left > label')).toHaveCount(0);
const calendarWidth = await popup.locator('.selected-calendar-picker').evaluate(el => el.getBoundingClientRect().width);
const fieldsWidth = await popup.locator('.fields').evaluate(el => el.getBoundingClientRect().width);
@@ -131,7 +135,14 @@ test('Jalali mouse and keyboard selection saves the same native instant in an En
const nextDay = popup.locator('.js-calendar-day[data-date="2026-03-22"]');
await expect(nextDay).toBeFocused();
await nextDay.press('Enter');
await expect(popup.locator('.selected-calendar-date')).toContainText('1405-01-02');
await expect(popup.locator('.selected-calendar-selects .js-calendar-day-select')).toHaveValue('2026-03-22');
await popup.locator('.js-calendar-year').fill('1410');
await popup.locator('.js-calendar-year').press('Enter');
await expect(popup.locator('.selected-calendar-heading')).toContainText('Farvardin 1410');
await popup.locator('.js-calendar-year').fill('1405');
await popup.locator('.js-calendar-year').press('Enter');
await expect(popup.locator('.selected-calendar-heading')).toContainText('Farvardin 1405');
await expect(popup.locator('.js-calendar-day-select')).toHaveValue('2026-03-22');
await expect(popup.locator('.selected-calendar-picker')).toBeVisible();
await popup.locator('.js-calendar-hour').selectOption('15');
await popup.locator('.js-calendar-minute').selectOption('30');
+81 -5
View File
@@ -33,6 +33,9 @@ const {
randomId,
versionNeedsHeal,
MARKER_ID,
CLAIM_ID,
CLAIM_STALE_MS,
RETRY_UNCLEAN_MS,
steps,
} = require('../server/lib/schemaUpgradeSteps');
@@ -68,8 +71,9 @@ function matchCond(value, cond) {
return value.some(el => matches(el, cond.$elemMatch));
}
// numeric type/range (nonfinite-sort-repair). Multiple ops AND together.
if ('$type' in cond || '$gte' in cond || '$lte' in cond) {
if ('$type' in cond || '$gte' in cond || '$lte' in cond || '$lt' in cond) {
let ok = true;
if ('$lt' in cond) ok = ok && (typeof value === 'number' && value < cond.$lt);
if ('$type' in cond) ok = ok && (cond.$type === 'number' ? typeof value === 'number' : false);
if ('$gte' in cond) ok = ok && (typeof value === 'number' && value >= cond.$gte);
if ('$lte' in cond) ok = ok && (typeof value === 'number' && value <= cond.$lte);
@@ -162,7 +166,14 @@ function fakeDb(initial = {}) {
if (i >= 0) { docs().splice(i, 1); return { deletedCount: 1 }; }
return { deletedCount: 0 };
},
async insertOne(doc) { ops.insertOne++; docs().push(JSON.parse(JSON.stringify(doc))); return { insertedId: doc._id }; },
async insertOne(doc) {
ops.insertOne++;
if (doc._id !== undefined && docs().some(d => String(d._id) === String(doc._id))) {
throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
}
docs().push(JSON.parse(JSON.stringify(doc)));
return { insertedId: doc._id };
},
async insertMany(list) { ops.insertMany++; for (const d of list) docs().push(JSON.parse(JSON.stringify(d))); return { insertedCount: list.length }; },
async updateOne(filter, update, opts = {}) {
ops.updateOne++;
@@ -1040,11 +1051,76 @@ await test('force option overrides the version gate', async () => {
assert.strictEqual(db._dump('boards')[0].permission, 'public');
});
await test('negative: unresolved work prevents the version stamp, so the SAME version re-checks next boot', async () => {
await test('negative: unresolved work prevents the version stamp; the SAME version re-checks once a day, not at every start', async () => {
const db = fakeDb({ avatars: [{ _id: 'av1', name: 'gone.png', versions: { original: { storage: 'fs', path: '/nonexistent/gone.png' } } }] });
await runSchemaUpgrade(db, opts); // unresolved heal
const res2 = await runSchemaUpgrade(db, opts); // same version
assert.strictEqual(res2.gated, false, 'must re-check because the previous run was not clean');
const marker = await db.collection('_wekan_migration').findOne({ _id: MARKER_ID });
assert.ok(!marker.lastCheck, 'version NOT stamped');
assert.strictEqual(marker.lastAttempt.version, opts.appVersion, 'the unclean attempt is recorded');
const before = { ...db._ops };
const res2 = await runSchemaUpgrade(db, opts); // restart a minute later
assert.strictEqual(res2.gated, true, 'a restart within the retry period does not scan again');
assert.ok(db._ops.findOne - before.findOne <= 2 && db._ops.distinct === before.distinct && db._ops.find === before.find,
'gated retry costs the marker read only');
// a day later
await db.collection('_wekan_migration').updateOne({ _id: MARKER_ID },
{ $set: { 'lastAttempt.atMs': Date.now() - RETRY_UNCLEAN_MS - 1000 } });
const res3 = await runSchemaUpgrade(db, opts);
assert.strictEqual(res3.gated, false, 'retried after the retry period');
});
await test('a clean run after an unclean one stamps the version and clears lastAttempt', async () => {
const db = fakeDb({ avatars: [{ _id: 'av1', name: 'gone.png', versions: { original: { storage: 'fs', path: '/nonexistent/gone.png' } } }] });
await runSchemaUpgrade(db, opts);
db._dump('avatars').length = 0;
await runSchemaUpgrade(db, { ...opts, force: true });
const marker = await db.collection('_wekan_migration').findOne({ _id: MARKER_ID });
assert.strictEqual(marker.lastCheck.version, opts.appVersion);
assert.ok(!marker.lastAttempt, 'lastAttempt cleared');
});
await test('one server at a time: while another server holds the claim, a start skips every scan', async () => {
const db = fakeDb({ boards: [{ _id: 'b1', permission: 'PUBLIC' }] });
await db.collection('_wekan_migration').insertOne({ _id: CLAIM_ID, sinceMs: Date.now(), version: opts.appVersion });
const before = { ...db._ops };
const res = await runSchemaUpgrade(db, opts);
assert.strictEqual(res.gated, true);
assert.strictEqual(res.ran.length, 0);
assert.strictEqual(db._dump('boards')[0].permission, 'PUBLIC', 'nothing touched: the claim holder does the work');
assert.strictEqual(db._ops.distinct, before.distinct);
assert.strictEqual(db._ops.find, before.find);
const claim = await db.collection('_wekan_migration').findOne({ _id: CLAIM_ID });
assert.ok(claim, "the other server's claim is left alone");
});
await test('a stale claim (server killed mid-run) is taken over, and released afterwards', async () => {
const db = fakeDb({ boards: [{ _id: 'b1', permission: 'PUBLIC' }] });
await db.collection('_wekan_migration').insertOne({ _id: CLAIM_ID, sinceMs: Date.now() - CLAIM_STALE_MS - 1000, version: opts.appVersion });
const res = await runSchemaUpgrade(db, opts);
assert.strictEqual(res.gated, false);
assert.strictEqual(db._dump('boards')[0].permission, 'public');
assert.strictEqual(await db.collection('_wekan_migration').findOne({ _id: CLAIM_ID }), null, 'claim released');
});
await test('a server that claims just after another finished reads the fresh marker and does not scan again', async () => {
const db = fakeDb({});
await runSchemaUpgrade(db, opts); // server A, clean
const stale = {}; // server B read the marker before A finished
const real = db.collection;
let first = true;
db.collection = name => {
const c = real(name);
if (name !== '_wekan_migration') return c;
return { ...c, findOne: async (f, o) => {
if (first && f && f._id === MARKER_ID) { first = false; return stale; }
return c.findOne(f, o);
} };
};
const before = { ...db._ops };
const res = await runSchemaUpgrade(db, opts);
assert.strictEqual(res.gated, true);
assert.strictEqual(db._ops.distinct, before.distinct, 'no step ran');
assert.strictEqual(await real('_wekan_migration').findOne({ _id: CLAIM_ID }), null, 'claim released');
});
// ── orchestrator semantics ───────────────────────────────────────────────────