mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 12:45:03 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
029ecb6b39 | ||
|
|
49c309b324 | ||
|
|
e09267cc90 | ||
|
|
b46f155fa3 | ||
|
|
0c0288a6c5 | ||
|
|
c61a1b7f41 | ||
|
|
9ce35869a7 | ||
|
|
2017d07e37 | ||
|
|
9ea5c81932 | ||
|
|
45ccdd9c3c | ||
|
|
5a21b074d4 | ||
|
|
e9c5888485 | ||
|
|
09c9d72398 | ||
|
|
4f3d45f3de | ||
|
|
3aaa46fdae | ||
|
|
59f1b705e0 | ||
|
|
22e5df55d7 | ||
|
|
016b4bf3bd | ||
|
|
4d44349ab6 | ||
|
|
d3edc56170 | ||
|
|
5134e3d2a8 | ||
|
|
f686b76023 | ||
|
|
cd2adcad02 | ||
|
|
0766a65fef | ||
|
|
58ad81e301 | ||
|
|
2912f4b080 | ||
|
|
97d1eca09d | ||
|
|
95b75177b8 | ||
|
|
880ec2d707 | ||
|
|
dbc7cccea3 | ||
|
|
d8b3579117 | ||
|
|
c7e53e9f38 | ||
|
|
6012521ccc | ||
|
|
0195a5b3d4 | ||
|
|
b12e761afb | ||
|
|
0c0d666ffa | ||
|
|
fc2ad0a48b | ||
|
|
dad03959d9 | ||
|
|
ac960e7d81 | ||
|
|
3213d7fa1d | ||
|
|
4757676abf | ||
|
|
efaf94242e | ||
|
|
d87baa0244 | ||
|
|
006d24502c | ||
|
|
8f4efbca57 | ||
|
|
dde09dead5 | ||
|
|
3621413d6f |
No files matched your search
@@ -5070,13 +5070,29 @@ jobs:
|
||||
[ -e "$1" ] || return 1
|
||||
case "$logs_before" in *" $1 "*) [ "$1" -nt "$attempt_marker" ] ;; *) return 0 ;; esac
|
||||
}
|
||||
# snapcraft's execution logs. v12.96 s390x: every attempt printed only
|
||||
# "Could not find repository at path ...", because snapcraft reports
|
||||
# the LAST exception; the cause before it - "HTTP Error 503: Service
|
||||
# Unavailable" / "Error: Timeout" creating that repository - was in
|
||||
# the "Full execution log" alone. Unread, three Launchpad outages
|
||||
# counted as three WeKan failures and the arch was not published.
|
||||
snapcraft_log_dir="$HOME/.local/state/snapcraft/log"
|
||||
infra_failure() { # did THIS attempt fail on Launchpad, not on WeKan?
|
||||
grep -qsE 'HTTP Error 5[0-9][0-9]|Error: Timeout|Service Unavailable' "$remote_log" && return 0
|
||||
# The same v12.96 s390x run's last attempt: the repository the
|
||||
# timed-out requests had half-created refused our push with
|
||||
# "You do not have permission to create refs/heads/main" - the
|
||||
# same credentials pushed every other architecture in that run.
|
||||
grep -qsE 'remote: You do not have permission to create refs/' "$remote_log" && return 0
|
||||
local log
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
this_attempts_log "$log" || continue
|
||||
grep -qsE 'RPC failed;|GnuTLS recv error|fatal: early EOF|Gateway error \[IP: ' "$log" && return 0
|
||||
done
|
||||
for log in "$snapcraft_log_dir"/*.log; do
|
||||
this_attempts_log "$log" || continue
|
||||
grep -qsE 'HTTP Error 5[0-9][0-9]|Error: Timeout|Service Unavailable' "$log" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# Set once an attempt ends with Launchpad reporting the build as
|
||||
@@ -5099,7 +5115,7 @@ jobs:
|
||||
# earlier attempts' logs stay in the directory for the diagnostics
|
||||
# below, but must not be printed again as if they were this one's.
|
||||
touch "$attempt_marker"
|
||||
logs_before=" $(ls snapcraft-wekan-*.txt 2>/dev/null | tr '\n' ' ')"
|
||||
logs_before=" $(ls snapcraft-wekan-*.txt "$snapcraft_log_dir"/*.log 2>/dev/null | tr '\n' ' ')"
|
||||
if [ "$fresh_next" = 1 ]; then
|
||||
echo "The previous attempt's Launchpad build finished without a snap; submitting a new build instead of recovering it."
|
||||
run_remote_build && rc=0 || rc=$?
|
||||
|
||||
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
|
||||
@@ -124,6 +124,397 @@ its reason.
|
||||
|
||||
</details>
|
||||
|
||||
# v12.97 2026-11-06 WeKan ® release
|
||||
|
||||
**In short:** `build.sh` and the `releases/` scripts now work on **Arch Linux**
|
||||
and **Omarchy**, and the release builds no longer lose the **OpenBSD arm64
|
||||
bundle** to a smoke test that left WeKan running, or the **s390x snap** to
|
||||
Launchpad outages that were counted as build failures.
|
||||
|
||||
This release fixes the following release build bugs:
|
||||
|
||||
**Release builds** - bundles and snaps that built correctly but failed the job.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b46f155fa3">The BSD smoke test stops WeKan and FerretDB and removes its files before returning</a>. Thanks to xet7.</summary>
|
||||
|
||||
v12.96 openbsd-arm64: `releases/bsd/smoke.sh` returned OK with WeKan and
|
||||
FerretDB still running, because `start-wekan.sh` runs its TERM trap only after
|
||||
the foreground node exits. FerretDB then removed `wekan.sqlite-wal` and `-shm`
|
||||
while the VM's copyback rsync walked the workspace, and rsync exit 24 failed a
|
||||
bundle that had built and started. The smoke test now stops both, waits until
|
||||
they are gone and deletes its work directory. `tests/bsdBundles.test.cjs`
|
||||
runs it against a launcher of `start-wekan.sh`'s shape and fails if a process
|
||||
or a smoke-test file is left behind.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b46f155fa3">Launchpad outages seen only in snapcraft's execution log earn snap retries</a>. Thanks to xet7.</summary>
|
||||
|
||||
v12.96 s390x: all three attempts met Launchpad's `HTTP Error 503` /
|
||||
`Error: Timeout`, but snapcraft printed only its last exception, "Could not
|
||||
find repository at path ...", so the outage was counted as three WeKan
|
||||
failures. `infra_failure()` now reads this attempt's snapcraft execution logs
|
||||
too, and counts the "You do not have permission to create refs/" push refusal
|
||||
from the half-created repository. `tests/snapLaunchpadInfraRetry.test.cjs`
|
||||
pins both, and its negatives an earlier attempt's log and one with no outage.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following developer-tooling fix:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/c61a1b7f41">build.sh and releases scripts work on Arch Linux and Omarchy</a>. Thanks to xet7.</summary>
|
||||
|
||||
Omarchy is Arch with Hyprland: pacman, the Arch repositories and yay for the
|
||||
AUR. `releases/ensure-tools.sh` names it and the other Arch derivatives
|
||||
explicitly. Packages are installed with `pacman -S --needed` instead of the
|
||||
unsupported `-Sy` partial upgrade; a full `-Syu` runs only on a never-synced
|
||||
pacman or with `WEKAN_PACMAN_SYU=1`, and a failed install says to update first
|
||||
(`omarchy-update` on Omarchy). 7zip replaces Arch's retired p7zip, snapcraft
|
||||
gets snapd from the AUR with yay or paru, the SSH server menu entries use
|
||||
`sshd.service` outside Debian/Ubuntu, and Playwright's browser libraries come
|
||||
from pacman (or dnf) instead of the apt-only `--with-deps`, with WebKit in the
|
||||
Playwright Docker image on Arch. `tests/archOmarchyScripts.test.cjs` drives
|
||||
the helpers offline, and its negative test fails if any shell script does a
|
||||
`pacman -Sy` partial upgrade or installs p7zip.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.96 2026-11-05 WeKan ® release
|
||||
|
||||
**In short:** The release **bundle starts again**: v12.95's build stopped on
|
||||
boot because the **nodemailer** 10 raised inside Meteor's email package no
|
||||
longer had the files that package was built against.
|
||||
|
||||
This release fixes the following bug:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/5a21b074d4">The bundle starts again, with nodemailer 10 inside Meteor's email package</a>. Thanks to xet7.</summary>
|
||||
|
||||
v12.95 raised the nodemailer in `meteor/email` from 8.0.3 to 10.3.1, and
|
||||
nodemailer 10 moved `lib/` to `dist/cjs/`. `packages/email.js`, compiled
|
||||
against 8.0.3, links `lib/nodemailer.js` and `lib/well-known/index.js` and
|
||||
`Npm.require`s `lib/mail-composer` by path, so build-amd64 failed its smoke
|
||||
boot. `releases/bundle-npm-security-bumps.json` now shims nodemailer as it
|
||||
already shims uuid 11: `lib/nodemailer.js` and every `lib/<name>` directory the
|
||||
exports map lists are written back, forwarding to `dist/cjs`, with a
|
||||
`lib/package.json` marking them CommonJS because nodemailer is `"type":
|
||||
"module"`. A fresh local bundle taken through build-amd64's install, bump, trim
|
||||
and prune steps passes `releases/bundle-smoke-boot.sh`.
|
||||
`tests/bundleNpmSecurityBumps.test.cjs` requires the recorded paths through the
|
||||
shim, checks every exported `lib/<name>`, and fails without the CommonJS marker.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.95 2026-11-05 WeKan ® release
|
||||
|
||||
**In short:** **Continuous backup** now runs on one server at a time, with a
|
||||
lease, when WeKan has several servers. Missing-board **exports** and REST
|
||||
updates of missing **integrations**, **users**, **organizations** and **teams**
|
||||
answer 404 instead of 200, the **minicard** shows a picked Requested by and
|
||||
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
|
||||
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
|
||||
**postcss-selector-parser** are raised past the advisories a Grype scan found.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Exports** - what the export routes answer when something is wrong.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/0c0d666ffa">Missing-board exports return 404, and an over-long export token returns 400</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
The board Excel export answered a missing board with HTTP 200 and only the text
|
||||
`Board not found` (PR #6869). The two PDF export routes had the same fault, and
|
||||
the Excel and PDF routes answered an over-long auth token with 200 as well,
|
||||
while `export.js` already used 404 and 400. All of them now choose their status
|
||||
the same way.
|
||||
|
||||
`tests/exportErrorStatuses.test.cjs` scans every `models/export*.js` route for a
|
||||
text answer without a status, so the fault cannot return anywhere there; the
|
||||
PR's route and browser tests stay.
|
||||
|
||||
</details>
|
||||
|
||||
**Continuous backup** - which server streams it.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/880ec2d707">Only one server streams it at a time, holding a lease</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
With several WeKan servers on one database, every server applied the continuous
|
||||
backup setting, so
|
||||
each of them tailed the oplog and wrote its own archives into the same target
|
||||
directory
|
||||
(\#6861). The leader now holds a lease, one `continuousBackupLease` document
|
||||
with its id and an
|
||||
expiry. Taking it is one atomic operation, the leader renews it every 15 seconds
|
||||
and it lapses after
|
||||
45, so another server takes over within a minute when the leader stops, and a
|
||||
server that loses the
|
||||
lease stops its stream. Startup, saving the settings and a change saved on
|
||||
another server converge
|
||||
through that loop; a restore or a fetch from the cloud takes the lease and waits
|
||||
one renewal so it
|
||||
is the only writer. One server is always its own leader.
|
||||
|
||||
Checked against the real driver on FerretDB (SQLite): 24 clients at once give
|
||||
exactly one leader, and
|
||||
renewal, expiry, the old leader losing the lease and release behave.
|
||||
`tests/continuousBackupLease.test.cjs`
|
||||
pins the same and the wiring.
|
||||
|
||||
</details>
|
||||
|
||||
**Attachments** - what a card offers when the administrator blocked uploads.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d8b3579117">A card no longer offers the attachment "+" when uploads are blocked for the whole site</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
Admin Panel / Attachments / Transfer limits already has "Attachments upload" and
|
||||
"API upload" set to
|
||||
Blocked, and the server refuses on every path: the upload pipeline,
|
||||
`api.attachment.upload` and
|
||||
`.copy`, and the `/api/attachment` upload, upload-background and copy routes.
|
||||
The card still showed
|
||||
the "+" button and accepted a drop or paste for an upload that would then be
|
||||
refused. It now asks
|
||||
the server one boolean, at most once a minute, and hides both. No second setting
|
||||
was added
|
||||
(\#6870 proposed a `disableAttachments`), so nothing can disagree with what the
|
||||
server enforces.
|
||||
|
||||
`tests/attachmentUploadPolicy.test.cjs` pins the client, the method, the server
|
||||
enforcement it relies
|
||||
on, and that no `disableAttachments` setting exists.
|
||||
|
||||
The method was not imported at first, so the call failed and the "+" stayed;
|
||||
[a follow-up](https://github.com/wekan/wekan/commit/3aaa46fdae) loads it, and
|
||||
`tests/serverModulesLoaded.test.cjs` now passes.
|
||||
|
||||
</details>
|
||||
|
||||
**REST API** - what the integration, user, organization and team update routes
|
||||
answer for something that is not there.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/6012521ccc">An integration update for a missing or wrong-board integration returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT /api/boards/:boardId/integrations/:intId` answered 200 with the requested
|
||||
id when no
|
||||
integration matched that id and board (PR #6871). The two activities routes
|
||||
(`POST` and `DELETE`
|
||||
`.../activities`) updated nothing and answered 200 with `data: null`. All three
|
||||
now look the
|
||||
integration up on that board after board-admin authorization and answer the JSON
|
||||
404 the `GET`
|
||||
route already uses, before validating or writing anything.
|
||||
|
||||
`tests/restIntegrationWriteRoutes404.test.cjs` pins, for the whole file, that
|
||||
every handler that
|
||||
updates an integration looks it up first and only after authorization; the PR's
|
||||
route and browser
|
||||
tests stay.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d3edc56170">An update of a missing user returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT /api/users/:userId` answered 200 with no data when no user had that id,
|
||||
for every action (PR #6873). After the admin check it now answers a JSON 404
|
||||
`User not found` before taking ownership, disabling or enabling a login.
|
||||
`tests/restUserUpdate.test.cjs` runs the handler for every action against a
|
||||
missing user, and checks that authorization still comes first, that
|
||||
self-disable stays a no-op and that a database failure stays a safe 500;
|
||||
`tests/playwright/specs/rest-user-update.e2e.js` drives it.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/4d44349ab6">A feature update of a missing organization or team returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT /api/admin/orgs/:orgId/features` and `PUT
|
||||
/api/admin/teams/:teamId/features`
|
||||
answered 200 with `data: undefined` when there was no such organization or team
|
||||
(PR #6874). They now answer a JSON 404 `Organization not found` / `Team not
|
||||
found`. `tests/restPeopleFeatures.test.cjs` runs both handlers for missing and
|
||||
existing records, empty bodies, denied authorization and database failures;
|
||||
`tests/playwright/specs/rest-people-features.e2e.js` checks the Admin Panel /
|
||||
People feature checkboxes.
|
||||
|
||||
</details>
|
||||
|
||||
**The minicard** - who requested and who assigned a card.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/22e5df55d7">Requested by and Assigned by show when a member was picked, with one icon per field everywhere</a>. Thanks to mueschel and xet7.</summary>
|
||||
|
||||
The opened card's + button picks board members into `requesters` /
|
||||
`assigners`, and the free-text name is optional, but the minicard drew only the
|
||||
free text: a requester picked the usual way never showed although Show on
|
||||
Minicard was on (#6872). The minicard now shows the picked members as avatars
|
||||
beside any typed name. Card Settings also had the icons swapped - a head with a
|
||||
plus for Requested by and a cart for Assigned by - and now uses the cart and
|
||||
user-plus that the opened card and the minicard use.
|
||||
`tests/minicardRequestedAssignedBy6872.test.cjs` pins the avatars, the
|
||||
either-or condition and one icon per field, and fails on a minicard block gated
|
||||
on the free text alone;
|
||||
`tests/playwright/specs/minicard-requested-by-6872.e2e.js`
|
||||
drives it.
|
||||
|
||||
</details>
|
||||
|
||||
and updates the following dependencies:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/dad03959d9">nodemailer 10.3.1, body-parser 1.20.9 and 2.4.0, qs 6.16.1, uuid 11.1.1 and postcss-selector-parser 6.1.5 in the bundle</a>. Thanks to the Grype scan reporter and xet7.</summary>
|
||||
|
||||
A Grype scan of the image found High and Medium advisories in packages that
|
||||
Meteor's own packages carry inside `programs/server/npm/node_modules/meteor/`,
|
||||
where `package.json` and its `overrides` cannot reach.
|
||||
`releases/bump-bundle-npm-deps.mjs` raises them after `meteor build`.
|
||||
|
||||
- **nodemailer 8.0.11 to 10.3.1** (GHSA-2x7j-588g-ccc2, GHSA-v53p-9fqp-m79j,
|
||||
GHSA-p6gq-j5cr-w38f and the rest fixed in 10.1.4 or later). It was listed as
|
||||
not fixable because the fix is a new major. `meteor/email` only calls
|
||||
`createTransport`, the `stream` plugin hook, `nodemailer/lib/well-known` and
|
||||
`nodemailer/lib/mail-composer`, and all four were run against 10.3.1 through
|
||||
the bundle's own package specifiers.
|
||||
- **body-parser 1.20.6 and 1.20.8 to 1.20.9, and 2.3.0 to 2.4.0**
|
||||
(GHSA-27r3-jxmh-p828). A minimum may now be written `body-parser@2`, which
|
||||
applies to copies of that major only, so each major is raised inside itself.
|
||||
- **qs to 6.16.1** (GHSA-6pq8-jhm2-2vcw) and **postcss-selector-parser 6.1.4 to
|
||||
6.1.5** (GHSA-rj75-hqrm-r3gf).
|
||||
- **uuid 8.3.2 and 9.0.1 to 11.1.1** (GHSA-w5hq-g745-h8pq). uuid 11 moved its
|
||||
entry point from `dist/index.js` to `dist/cjs/index.js`, which killed the
|
||||
server on boot the one time it was tried. The bumper now writes the old
|
||||
`dist/index.js` back as `export * from './esm/index.js'`: it is an ES module
|
||||
because uuid is `"type": "module"`, and Node 26 `require()`s it.
|
||||
|
||||
glob 14 and minimatch 11 were already in `package.json`; the scanned image
|
||||
predates them. The runtime image installs no compiler (`gcc-14-base` is Debian's
|
||||
one-file package), so the multi-stage build the report asks for is already how
|
||||
it is built. Run against a copy of the published image's bundle, the bumper
|
||||
raised all of the above and every raised copy loaded.
|
||||
|
||||
**This release's bundle did not start.** "Every raised copy loaded" was checked
|
||||
by loading each package, not by booting the bundle: nodemailer 10 moved `lib/`
|
||||
to `dist/cjs/`, Meteor's `email` package still links the `lib/` paths of 8.0.3,
|
||||
and build-amd64 failed its smoke boot with "Cannot find module
|
||||
.../meteor/email/node_modules/nodemailer/lib/nodemailer.js". The fix is in the
|
||||
next release.
|
||||
`tests/bundleNpmSecurityBumps.test.cjs` pins the minimums, the per-major
|
||||
rule and the shim, and runs the bumper with `--dry-run` on a bundle of
|
||||
body-parser, uuid and nodemailer copies at, below and above their minimums.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following documentation change:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/016b4bf3bd">Only maintainer mode commits directly and updates changelogs, and a pull request has one feature or fix</a>. Thanks to xet7.</summary>
|
||||
|
||||
CLAUDE.md and AGENTS.md now say that a pull request contributor, to wekan/wekan
|
||||
or to the maintainer's repositories under `.tools/`, sends each feature or fix
|
||||
as its own pull request with only related changes in it, and changes no
|
||||
changelog. The maintainer, or an agent working as the maintainer, writes the
|
||||
entry after merging, when there is a commit to link.
|
||||
`tests/changelogFormat.test.cjs` checks both files.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.94 2026-11-04 WeKan ® release
|
||||
|
||||
**In short:** On an iPad the **opened card covers the header bar** instead of
|
||||
sitting
|
||||
behind it, and the speed test measures where the time goes when a WeKan board is
|
||||
opened, on FerretDB and on MongoDB.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Opened cards** - where a card opens.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/efaf94242e">On a phone in desktop mode the opened card starts below the header bar</a>. Thanks to xet7.</summary>
|
||||
|
||||
The header wraps to several rows on a phone, far taller than the 8px the
|
||||
floating card docks at, so the card's top row was among the header's rows and
|
||||
was only seen by dragging it down. Below 801px a card without an inline top,
|
||||
popup cards included, now starts at `--wekan-header-height` + 8px. A dragged
|
||||
place is an inline top, so it is kept when the finger is lifted.
|
||||
`tests/cardBelowHeaderOnPhone.test.cjs` pins the rule and that it never
|
||||
matches a dragged card.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/006d24502c">The opened card covers the header bar on an iPad instead of sitting behind it</a>. Thanks to xet7.</summary>
|
||||
|
||||
On iPad Chrome (and Safari) the opened card's top row - the collapse caret, the
|
||||
card
|
||||
number, the menu, maximize and X - was behind the header bar, although the
|
||||
card's
|
||||
`z-index` is 2001 and the header's 1000. iOS WebKit makes every scrolling
|
||||
element
|
||||
(`#content`, the board wrapper) a stacking context of its own, so the card's
|
||||
`z-index`
|
||||
is compared only inside it, and the header bar, itself a stacking context, is
|
||||
painted
|
||||
over all of it. While a card is open the header bar is now at the level of the
|
||||
content,
|
||||
which comes after it in the page and paints over it; with no card open it keeps
|
||||
its
|
||||
own level. A browser spec makes the same trap with `isolation: isolate` on
|
||||
`#content`
|
||||
and the board wrapper and shows the card over the header, open and collapsed,
|
||||
with its
|
||||
close button reachable, in Chromium and WebKit; without the rule it fails as the
|
||||
screenshot shows. `tests/cardOverHeaderStacking.test.cjs` pins the rule and its
|
||||
selector. An iPad is the final check.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following documentation change:
|
||||
|
||||
**Database speed test** - how fast the databases answer WeKan's queries.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/3621413d6f">The speed test measures where the time goes when a WeKan board is opened, on FerretDB and on MongoDB</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
`docs/Databases/FerretDB/1/SpeedTest/app` opens a board of 200, 2,000 and 5,000
|
||||
cards in
|
||||
Chromium on WeKan with FerretDB (SQLite) and with MongoDB 8, and profiles the
|
||||
Meteor
|
||||
server (Node inspector) and FerretDB (pprof). Two runs each. The Meteor server
|
||||
uses
|
||||
0.1-0.5 s of CPU, the same on both, and its DDP server is 1% of that; the
|
||||
browser gets
|
||||
the same ~210 KB at every size. A 5,000-card board takes FerretDB 400-600 ms
|
||||
longer to
|
||||
open (first card +42-48%), a small board only 70-100 ms, and FerretDB spends
|
||||
about 4 s
|
||||
of CPU on it, 39% in the SQLite engine and 34% filtering in Go what it read. So
|
||||
a
|
||||
backend rewritten in another language would save part of a fraction of a second,
|
||||
while
|
||||
the part that scales with the board is the database layer. The README says what
|
||||
was not
|
||||
measured: a production build, the browser's own time, and which query sends the
|
||||
`aggregate`. `tests/boardOpenProfile.test.cjs` pins the analysis of the profile
|
||||
and what
|
||||
the README claims.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.93 2026-11-04 WeKan ® release
|
||||
|
||||
**In short:** The database **speed test** has a new small-scale result, ordered
|
||||
|
||||
+38
-32
@@ -5,6 +5,44 @@ each one changed and the project status are in
|
||||
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
|
||||
make changes there.
|
||||
|
||||
# v12.97 2026-11-06 WeKan ® release
|
||||
|
||||
`build.sh` and the `releases/` scripts now work on **Arch Linux**
|
||||
and **Omarchy**, and the release builds no longer lose the **OpenBSD arm64
|
||||
bundle** to a smoke test that left WeKan running, or the **s390x snap** to
|
||||
Launchpad outages that were counted as build failures.
|
||||
|
||||
[All changes in v12.97](CHANGELOG-big.md#v1297-2026-11-06-wekan--release)
|
||||
|
||||
# v12.96 2026-11-05 WeKan ® release
|
||||
|
||||
The release **bundle starts again**: v12.95's build stopped on
|
||||
boot because the **nodemailer** 10 raised inside Meteor's email package no
|
||||
longer had the files that package was built against.
|
||||
|
||||
[All changes in v12.96](CHANGELOG-big.md#v1296-2026-11-05-wekan--release)
|
||||
|
||||
# v12.95 2026-11-05 WeKan ® release
|
||||
|
||||
**Continuous backup** now runs on one server at a time, with a
|
||||
lease, when WeKan has several servers. Missing-board **exports** and REST
|
||||
updates of missing **integrations**, **users**, **organizations** and **teams**
|
||||
answer 404 instead of 200, the **minicard** shows a picked Requested by and
|
||||
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
|
||||
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
|
||||
**postcss-selector-parser** are raised past the advisories a Grype scan found.
|
||||
|
||||
[All changes in v12.95](CHANGELOG-big.md#v1295-2026-11-05-wekan--release)
|
||||
|
||||
# v12.94 2026-11-04 WeKan ® release
|
||||
|
||||
On an iPad the **opened card covers the header bar** instead of
|
||||
sitting
|
||||
behind it, and the speed test measures where the time goes when a WeKan board is
|
||||
opened, on FerretDB and on MongoDB.
|
||||
|
||||
[All changes in v12.94](CHANGELOG-big.md#v1294-2026-11-04-wekan--release)
|
||||
|
||||
# v12.93 2026-11-04 WeKan ® release
|
||||
|
||||
The database **speed test** has a new small-scale result, ordered
|
||||
@@ -13,35 +51,3 @@ more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
|
||||
and CockroachDB were looked at and cannot be used, for reasons the README gives.
|
||||
|
||||
[All changes in v12.93](CHANGELOG-big.md#v1293-2026-11-04-wekan--release)
|
||||
|
||||
# v12.92 2026-11-03 WeKan ® release
|
||||
|
||||
A missing **REST checklist** answers a JSON 404 instead of an
|
||||
empty 500.
|
||||
|
||||
[All changes in v12.92](CHANGELOG-big.md#v1292-2026-11-03-wekan--release)
|
||||
|
||||
# v12.91 2026-11-03 WeKan ® release
|
||||
|
||||
A **GitHub CodeQL** finding in a test is fixed, and the guard
|
||||
against
|
||||
replacing text with itself now also sees patterns with a `^` or `$` anchor.
|
||||
|
||||
[All changes in v12.91](CHANGELOG-big.md#v1291-2026-11-03-wekan--release)
|
||||
|
||||
# v12.90 2026-11-03 WeKan ® release
|
||||
|
||||
The login page can have its own **background color**, Member
|
||||
Settings
|
||||
has a per-member switch, **Show focus outline**, to hide the blue focus ring,
|
||||
and
|
||||
**Escape closes the add-list composer**.
|
||||
|
||||
[All changes in v12.90](CHANGELOG-big.md#v1290-2026-11-03-wekan--release)
|
||||
|
||||
# v12.89 2026-11-03 WeKan ® release
|
||||
|
||||
Keyboard **shortcuts run once on a non-Latin keyboard**: `d` no
|
||||
longer opens the Due date popup and closes it again on a Farsi layout.
|
||||
|
||||
[All changes in v12.89](CHANGELOG-big.md#v1289-2026-11-03-wekan--release)
|
||||
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.93
|
||||
ARG VERSION=12.97
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
@@ -151,7 +151,7 @@ ENV \
|
||||
NODE_VERSION=v26.13.0 \
|
||||
METEOR_RELEASE=METEOR@3.6-rc.1 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=12.2.1 \
|
||||
NPM_VERSION=12.2.2 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.93.0"
|
||||
appVersion: "v12.97.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/bash
|
||||
if [ -n "${ZSH_VERSION:-}" ]; then exec /bin/bash "$0" "$@"; fi
|
||||
|
||||
echo "Recommended for development: Newest Debian or Ubuntu amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
|
||||
echo "Recommended for development: Newest Debian, Ubuntu, Fedora or Arch/Omarchy amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
|
||||
echo "Note1: If you use other locale than en_US.UTF-8 , you need to additionally install en_US.UTF-8"
|
||||
echo " with 'sudo dpkg-reconfigure locales' , so that MongoDB works correctly."
|
||||
echo " so that MongoDB works correctly: Debian/Ubuntu 'sudo dpkg-reconfigure locales',"
|
||||
echo " Arch/Omarchy: uncomment en_US.UTF-8 in /etc/locale.gen and run 'sudo locale-gen'."
|
||||
echo " You can still use any other locale as your main locale."
|
||||
echo "Note2: Console output is also logged to the operation-specific path printed below."
|
||||
echo "Note3: All logs use .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/."
|
||||
@@ -605,6 +606,14 @@ function browser_needs_docker(){
|
||||
if [ "$PLATFORM_OS" = "linux" ] && [ "$PLATFORM_ARCH" = "arm64" ]; then
|
||||
return 0
|
||||
fi
|
||||
# Playwright builds WebKit for Debian/Ubuntu only. On Arch and Omarchy its
|
||||
# WebKit is linked against other ICU/libjpeg/libwebp versions than the
|
||||
# rolling release has and does not launch, while Chromium and Firefox do.
|
||||
# So WebKit alone goes to the Docker image there, when Docker is installed.
|
||||
if [ "$browser" = webkit ] && [ "$PLATFORM_OS" = "linux" ] && \
|
||||
[ "$(_et_linux_family)" = arch ] && docker_available; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -825,8 +834,17 @@ function install_playwright_browsers(){
|
||||
browser_needs_docker "$b" || nativeList="$nativeList $b"
|
||||
done
|
||||
if [ -n "$nativeList" ]; then
|
||||
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
|
||||
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
|
||||
if [ "$PLATFORM_OS" = "linux" ] && [ "$(_et_linux_family)" != debian ]; then
|
||||
# `--with-deps` runs apt-get, which only Debian/Ubuntu have. Elsewhere
|
||||
# install the libraries the browsers load with the native package
|
||||
# manager, then the browsers themselves without --with-deps.
|
||||
echo "Installing native Playwright browsers:$nativeList (system deps from the distribution's packages; may use sudo)."
|
||||
ensure_playwright_system_deps
|
||||
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install $nativeList )
|
||||
else
|
||||
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
|
||||
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
|
||||
fi
|
||||
else
|
||||
echo "All browsers are configured to run via Docker; skipping native browser install."
|
||||
fi
|
||||
@@ -2732,8 +2750,8 @@ RELEASE_SCRIPTS=( "Release|Release All: audit, commit, push and build with Actio
|
||||
"Git and repo|Migrate a MongoDB database to FerretDB (--help first)|releases/migrate-mongodb-to-ferretdb.mjs|Arguments, e.g. --help||"
|
||||
"Git and repo|Export local Git history to a new Fossil repository|releases/fossil.sh|||fossil-export"
|
||||
"Git and repo|Open the local Fossil repository UI|releases/fossil-ui.sh|||fossil-ui"
|
||||
"Server and VM|Enable and start the SSH server|!sudo systemctl enable ssh && sudo systemctl start ssh||linux|ssh-start"
|
||||
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable ssh && sudo systemctl stop ssh||linux|ssh-stop"
|
||||
"Server and VM|Enable and start the SSH server|!sudo systemctl enable \"\$(_et_ssh_unit)\" && sudo systemctl start \"\$(_et_ssh_unit)\"||linux|ssh-start"
|
||||
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable \"\$(_et_ssh_unit)\" && sudo systemctl stop \"\$(_et_ssh_unit)\"||linux|ssh-stop"
|
||||
"Server and VM|Enable the ufw firewall|!sudo ufw enable||linux|ufw-enable"
|
||||
"Server and VM|Disable the ufw firewall|!sudo ufw disable||linux|ufw-disable"
|
||||
"Server and VM|Remove the Multipass VM|!multipass stop --all && multipass delete --all && multipass purge && sudo snap remove multipass||linux|multipass-remove"
|
||||
@@ -2999,7 +3017,8 @@ for _once in 1; do
|
||||
sudo apk add --no-cache bash build-base git curl wget p7zip zip unzip file npm
|
||||
;;
|
||||
arch)
|
||||
sudo pacman -Sy --needed --noconfirm base-devel git curl wget p7zip zip unzip file npm
|
||||
# Arch and Omarchy: no `pacman -Sy` partial upgrade (see _et_pacman).
|
||||
_et_pacman base-devel git curl wget 7zip zip unzip file npm
|
||||
;;
|
||||
fedora)
|
||||
sudo dnf group install -y development-tools
|
||||
|
||||
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
|
||||
.attachment-gallery
|
||||
|
||||
//- #6796: a small "+" like every other section's, not a full-width box.
|
||||
if canModifyCard
|
||||
if canAddAttachment
|
||||
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
|
||||
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.attachmentGallery.helpers({
|
||||
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
|
||||
canAddAttachment() {
|
||||
return Utils.canModifyCard() && !attachmentUploadsBlocked();
|
||||
},
|
||||
// #6796: the small file button instead of the preview tile
|
||||
// (models/lib/attachmentTile.cjs).
|
||||
attachmentTileCompact(file) {
|
||||
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
|
||||
if (attachmentUploadsBlocked()) return [];
|
||||
|
||||
// Check if board allows attachments
|
||||
const board = card.board();
|
||||
if (!board || !board.allowsAttachments) {
|
||||
|
||||
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
|
||||
the board controls open), far taller than the 8px the floating window docks
|
||||
at, so the card's own top row (caret, number, menu, X) sat among the header's
|
||||
rows and was only seen by dragging the card down. Below 801px the card starts
|
||||
BELOW the header, at the height Utils publishes as --wekan-header-height.
|
||||
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
|
||||
place a finger dragged it to is an inline top and is kept when the finger is
|
||||
lifted - this rule never competes with it. */
|
||||
@media screen and (max-width: 800px) {
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
|
||||
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
|
||||
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
|
||||
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
|
||||
with viewport insets), never laid out inside — or measured from — the
|
||||
scrolled swimlane/board container. Historically the maximized pane was an
|
||||
|
||||
@@ -419,18 +419,27 @@ template(name="minicard")
|
||||
.minicard-location
|
||||
i.fa.fa-map-marker
|
||||
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
|
||||
//- #6872: the + button on the opened card picks board members into
|
||||
requesters / assigners; the free-text name is optional. Show both,
|
||||
as the opened card does, or a picked member never reached here.
|
||||
if $eq field "requestedBy"
|
||||
unless $eq currentBoard.allowsRequestedByOnMinicard false
|
||||
if getRequestedBy
|
||||
if hasRequestedByOnMinicard
|
||||
.minicard-requested-by(title="{{_ 'requested-by'}}")
|
||||
i.fa.fa-shopping-cart
|
||||
| {{ getRequestedBy }}
|
||||
each userId in getRequesters
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getRequestedBy
|
||||
span {{ getRequestedBy }}
|
||||
if $eq field "assignedBy"
|
||||
unless $eq currentBoard.allowsAssignedByOnMinicard false
|
||||
if getAssignedBy
|
||||
if hasAssignedByOnMinicard
|
||||
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
|
||||
i.fa.fa-user-plus
|
||||
| {{ getAssignedBy }}
|
||||
each userId in getAssigners
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getAssignedBy
|
||||
span {{ getAssignedBy }}
|
||||
if $eq field "flowtime"
|
||||
if currentBoard.allowsFlowtimeOnMinicard
|
||||
if isFlowActive
|
||||
|
||||
@@ -261,6 +261,13 @@ Template.minicard.helpers({
|
||||
isWatching() {
|
||||
return this.getRealCard().findWatcher(Meteor.userId());
|
||||
},
|
||||
// #6872: a picked member (requesters / assigners) or a free-text name.
|
||||
hasRequestedByOnMinicard() {
|
||||
return !!this.getRequestedBy() || (this.getRequesters() || []).length > 0;
|
||||
},
|
||||
hasAssignedByOnMinicard() {
|
||||
return !!this.getAssignedBy() || (this.getAssigners() || []).length > 0;
|
||||
},
|
||||
|
||||
showMembers() {
|
||||
const board = minicardBoardOf(this);
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
background: #2573a7;
|
||||
z-index: 17;
|
||||
}
|
||||
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
|
||||
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
|
||||
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
|
||||
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
|
||||
flex item of body and so a stacking context itself) is painted over all of it: the
|
||||
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
|
||||
BEHIND the header bar. While a card is open the header drops to the level of the
|
||||
content, which comes after it in the page and so paints over it; a browser without that
|
||||
trap shows no difference. */
|
||||
body:has(.card-details) #header-quick-access {
|
||||
z-index: 0;
|
||||
}
|
||||
#header #header-main-bar {
|
||||
/* Flex row (was float-based) so the right button group — which ends with the
|
||||
sidebar hamburger — is always pinned to the right edge at every window width,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
|
||||
// #6870: whether the administrator blocked attachment uploads for the whole site
|
||||
// (models/lib/attachmentUploadPolicy.js). Asked of the server once a minute at most; until it
|
||||
// answers - or if it cannot - uploads are shown as before, because the server refuses them either
|
||||
// way and a wrong guess here only costs a button.
|
||||
const blocked = new ReactiveVar(false);
|
||||
let askedAt = 0;
|
||||
|
||||
export function refreshUploadPolicy() {
|
||||
if (Date.now() - askedAt < 60 * 1000) return;
|
||||
askedAt = Date.now();
|
||||
Meteor.callAsync('attachmentUploadPolicy')
|
||||
.then(policy => blocked.set(!!(policy && policy.uploadsBlocked === true)))
|
||||
.catch(() => { askedAt = 0; });
|
||||
}
|
||||
|
||||
// reactive
|
||||
export function attachmentUploadsBlocked() {
|
||||
refreshUploadPolicy();
|
||||
return blocked.get();
|
||||
}
|
||||
@@ -20,17 +20,29 @@ only what changes: documents, files and log lines. It writes them seconds after
|
||||
they happen, to a directory that can be another disk, an NFS share or an
|
||||
[rclone mount](Rclone/Rclone.md).
|
||||
|
||||
## One server only, for now
|
||||
## Several servers: one of them streams
|
||||
|
||||
Continuous backup is for installations with **one WeKan server**. When it is
|
||||
switched on in the Admin Panel, every WeKan server that shares the database
|
||||
applies the setting, so with several servers (for example the replicas of a
|
||||
Kubernetes Deployment) each of them tails the oplog and writes its own archives
|
||||
into the same target directory: the same backup stream, many times, into one
|
||||
place. It has no leader: one server should run the stream and another take it
|
||||
over when that one stops (a lease), and that is not built yet
|
||||
([#6861](https://github.com/wekan/wekan/issues/6861)). With several servers, use
|
||||
the database's own backup instead.
|
||||
When WeKan runs as several servers on one database (for example the replicas of a Kubernetes
|
||||
Deployment), **one of them runs the stream** and the others stand by
|
||||
([#6861](https://github.com/wekan/wekan/issues/6861)). They coordinate through a *lease*: one
|
||||
document, `continuousBackupLease`, holding the leader's id and an expiry.
|
||||
|
||||
- Every server with the feature switched on tries to take the lease. Taking it is one atomic
|
||||
database operation, so two servers can never both lead.
|
||||
- The leader renews it every 15 seconds. It lapses 45 seconds after the last renewal, so when the
|
||||
leader stops (a pod restart, a rollout, a crash) another server takes over within about a minute
|
||||
and continues from the position the stream saved in the target.
|
||||
- A server that finds it has lost the lease stops its stream, so two never write one target.
|
||||
- Saving the settings, switching the feature off and on, and a change saved on another server all
|
||||
converge through the same loop: the leader follows the change on its next tick.
|
||||
- A restore, and *fetch from the cloud*, must be the only writer: they take the lease from whoever
|
||||
holds it and wait one renewal, so the former leader has stopped before anything is written. The
|
||||
stream resumes through the loop afterwards.
|
||||
|
||||
An installation with one server is always its own leader, so nothing changes for it. The target
|
||||
directory is still one place: with several servers it must be on storage they all reach (a shared
|
||||
volume, NFS, an [rclone mount](Rclone/Rclone.md)), or the server that takes over starts a new
|
||||
generation instead of continuing.
|
||||
|
||||
## Licences: only copyfree code by default
|
||||
|
||||
|
||||
@@ -410,6 +410,16 @@ FerretDB's builds do not use) and SingleStore (not open source, and its image
|
||||
needs a licence key). A MongoDB-protocol database needs no FerretDB and joins the
|
||||
MongoDB rows; FerretDB 2 with DocumentDB is already there.
|
||||
|
||||
## Opening a board in WeKan
|
||||
|
||||
The same databases, one level up: [app/](app/) opens a WeKan board of 200, 2,000 and
|
||||
5,000 cards on FerretDB (SQLite) and on MongoDB 8 and profiles both the Meteor server
|
||||
and FerretDB. The Meteor server's CPU is the same on both (0.1-0.5 s); on a 5,000-card
|
||||
board FerretDB (SQLite) takes **400-600 ms longer** (to the first card +42-48%), a
|
||||
small board only 70-100 ms, and it uses about 4 s of CPU to open the 5,000-card board,
|
||||
a third of it filtering in Go what it read; the browser receives the same ~210 KB at
|
||||
every size.
|
||||
|
||||
## Go databases linked into FerretDB
|
||||
|
||||
Could a database implemented in Go be linked into the FerretDB binary beside its
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Opening a WeKan board: where the time goes
|
||||
|
||||
The [speed test](../) asks the database alone. This asks what a person waits for: the
|
||||
whole of opening a board in WeKan, with WeKan on FerretDB (SQLite) and on MongoDB 8,
|
||||
and how much of it is the database. [board-open.cjs](board-open.cjs) seeds a board of
|
||||
N cards, logs in, opens it in Chromium and records
|
||||
|
||||
- the wall clock to the first minicard and until the DDP WebSocket has been quiet for
|
||||
1.5 s (**settled**), and what crossed the WebSocket;
|
||||
- the Meteor server's CPU, by who spends it (a Node inspector profile of the server);
|
||||
- FerretDB's CPU (its pprof), against what it spends with nobody in the browser.
|
||||
|
||||
```sh
|
||||
# WeKan on FerretDB: MONGO_URL=mongodb://127.0.0.1:27040/wekan?directConnection=true,
|
||||
# DEFAULT_METEOR_REACTIVITY_ORDER=polling (as WeKan is deployed), meteor run --inspect=9229,
|
||||
# and FerretDB with --debug-addr=127.0.0.1:8098
|
||||
WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
|
||||
node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000,5000 \
|
||||
--inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
|
||||
```
|
||||
|
||||
Run on 2026-11-03/04, Apple M2 Max, **the development build of Meteor** (so the browser's
|
||||
own part is larger than in production), one user, `polling` reactivity, FerretDB v1.105.0
|
||||
with the SQLite membership-test change, MongoDB 8.0 in Docker. Each board is opened
|
||||
twice (the second is warm), and the whole measurement was run **twice** (run 1 / run 2
|
||||
below), because one run is not enough: the same board's "settled" time moved by up to
|
||||
300 ms between runs.
|
||||
|
||||
## Result: the database is not what you wait for on a small board, and is on a big one
|
||||
|
||||
Milliseconds, first open of a board with this many cards (run 1 / run 2). WeKan loads
|
||||
cards lazily, so the browser gets **the same ~210 KB and draws 70-120 minicards at every
|
||||
size**.
|
||||
|
||||
| Cards | FerretDB (SQLite): first card | settled | MongoDB 8: first card | settled |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| 200 | 750 / 764 | 966 / 880 | 752 / 698 | 865 / 813 |
|
||||
| 2,000 | 931 / 1,031 | 1,763 / 1,555 | 829 / 856 | 1,457 / 1,582 |
|
||||
| 5,000 | 1,331 / 1,334 | 2,166 / 2,065 | 938 / 899 | 1,562 / 1,622 |
|
||||
|
||||
What is the same in both runs:
|
||||
|
||||
- **5,000 cards: FerretDB is 400-600 ms slower**, to the first card by 42-48% and until
|
||||
settled by 27-39%.
|
||||
- **2,000 cards: within the noise** for "settled" (-30 to +300 ms), and 100-175 ms
|
||||
slower to the first card.
|
||||
- **200 cards: 70-100 ms**, which is 9-12%.
|
||||
|
||||
The warm second open of run 2: 5,000 cards 1,321 against 865 ms to the first card
|
||||
and 1,946 against 1,594 ms settled; 2,000 cards 904 against 824 and 1,628 against
|
||||
1,446; 200 cards 721 against 689 and 934 against 805.
|
||||
|
||||
The Meteor server's CPU is the same on both databases within the noise (first open, run
|
||||
1 / run 2: 102 / 119, 276 / 421 and 470 / 517 ms on FerretDB; 178 / 93, 245 / 342 and
|
||||
521 / 483 ms on MongoDB), so the difference is **time spent waiting for the database**,
|
||||
and it grows with the size of the board while the data the browser receives does not.
|
||||
|
||||
For a board of a few hundred cards, 0.7-0.75 s to the first card is nearly the same on
|
||||
both: that is the application (the development bundle, Blaze, the DDP round trips), not
|
||||
the database.
|
||||
|
||||
## Where the Meteor server spends its CPU (5,000 cards, first open, run 2)
|
||||
|
||||
| | FerretDB (517 ms) | MongoDB 8 (483 ms) |
|
||||
| --- | ---: | ---: |
|
||||
| Garbage collector | 31% | 24% |
|
||||
| MongoDB driver (wire, decode, encode) | 19% | 22% |
|
||||
| Meteor mongo (observers, polling, diff) | 12% | 13% |
|
||||
| Node internals | 11% | 12% |
|
||||
| WeKan code | 11% | 10% |
|
||||
| Other Meteor packages and dependencies | 8% | 9% |
|
||||
| Native, EJSON, DDP server (publish, mergebox) | 8% | 10% |
|
||||
|
||||
The DDP server itself, publish and mergebox, is 1%. The largest single functions are
|
||||
the garbage collector, `CollectionDocument` (WeKan's model class), `replaceTypes`
|
||||
(Meteor's BSON-to-EJSON step) and the BSON decoder.
|
||||
|
||||
## Where FerretDB spends its CPU
|
||||
|
||||
With nobody in the browser FerretDB uses 110-510 ms of CPU in the 14 s window the
|
||||
profile covers. Opening a board adds, net: about nothing at 200 cards, **about 1.6 s at
|
||||
2,000 and about 3.9-4.1 s at 5,000** - eight times the Meteor server's CPU for the same
|
||||
open. For the 5,000-card board, of 4.41 s (run 2):
|
||||
|
||||
- the SQLite engine, 1.72 s (39%), a good part of it moving through the pages of the
|
||||
cards table (`btreeInitPage` 18% by itself);
|
||||
- 34% is `filterIterator.Next`: documents read from SQLite and filtered again in Go;
|
||||
- by MongoDB command: `find` 0.79 s, `getMore` 0.74 s, `aggregate` 0.68 s (a `$group`
|
||||
done in Go over the documents it read);
|
||||
- the Go garbage collector, 0.57 s (13%).
|
||||
|
||||
Which WeKan query sends the `aggregate` during the open was **not identified**; the
|
||||
debug log of a 2,000-card open contains none, so the 5,000-card profile may have
|
||||
included the All Boards page's card counters (`getAllBoardsTileData`, a `$group` over
|
||||
the user's boards) that the login lands on first. Treat that line as a lead.
|
||||
|
||||
## What this does and does not say
|
||||
|
||||
- It says the database layer is the part of opening a board that **scales with the board
|
||||
size**, and that on FerretDB (SQLite) a 5,000-card board takes 400-600 ms longer than on
|
||||
MongoDB, with the same data sent to the browser.
|
||||
- It says the **Meteor server is not the bottleneck**: 0.1-0.5 s of CPU, the same on both
|
||||
databases, and the DDP server is about 1% of it. A backend rewritten in another language
|
||||
could save a part of that, and would not change the database or the browser.
|
||||
- It does not separate the browser's own time from the wait for the server: the first
|
||||
card at 0.7 s on a small board is mostly the development bundle and rendering.
|
||||
- Single machine, the development build, one user, two runs, two opens per board, `settled`
|
||||
meaning 1.5 s without a DDP frame. A production build and a loaded server would differ.
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
// Where the time goes when a WeKan board is opened, with WeKan running on a database
|
||||
// (FerretDB on SQLite, or MongoDB): the browser's wall clock, what crosses the DDP
|
||||
// WebSocket, the Meteor server's CPU by who spends it (Node inspector profile), and
|
||||
// FerretDB's CPU (its pprof). The speed test asks the database alone; this asks whether
|
||||
// the database is what a person waits for.
|
||||
//
|
||||
// # WeKan on FerretDB: meteor run --port 3310 --inspect=9229 with MONGO_URL set,
|
||||
// # FerretDB with --debug-addr=127.0.0.1:8098
|
||||
// WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
|
||||
// node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000 \
|
||||
// --inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
|
||||
//
|
||||
// Dev servers: the Meteor build is the development one, so the client's own time is
|
||||
// larger than in production; the comparison between databases is what it is for.
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const ROOT = path.join(__dirname, '../../../../../..');
|
||||
const pw = require(path.join(ROOT, 'tests/playwright/node_modules/playwright-core'));
|
||||
const { MongoClient } = require(path.join(ROOT, 'tests/playwright/node_modules/mongodb'));
|
||||
const WebSocket = require(path.join(ROOT, 'node_modules/ws'));
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const BASE = process.env.WEKAN_BASE_URL || 'http://localhost:3310';
|
||||
process.env.WEKAN_BASE_URL = BASE;
|
||||
const MONGO = process.env.WEKAN_MONGO_URL || 'mongodb://127.0.0.1:27040/wekan';
|
||||
process.env.WEKAN_MONGO_URL = MONGO;
|
||||
const db = require(path.join(ROOT, 'tests/playwright/helpers/db.js'));
|
||||
const { loginWithToken } = require(path.join(ROOT, 'tests/playwright/helpers/auth.js'));
|
||||
|
||||
const SIZES = arg('sizes', '200,2000').split(',').map(Number);
|
||||
const INSPECT = arg('inspect', '');
|
||||
const PPROF = arg('ferret-pprof', '');
|
||||
const LABEL = arg('label', 'run');
|
||||
const GO = arg('go', path.join(ROOT, '.tools/go/bin/go'));
|
||||
const FERRET_BIN = arg('ferret-bin', path.join(ROOT, '.tools/FerretDB/bin/ferretdb'));
|
||||
const QUIET_MS = 1500;
|
||||
|
||||
// ── seeding ─────────────────────────────────────────────────────────────────
|
||||
async function seedBoard(user, size) {
|
||||
const board = db.seedBoard({ ownerId: user.id, title: `Open ${size}`, listCount: 6, cardTitlesPerList: [['Template']] });
|
||||
const template = db.findOne('cards', { boardId: board.boardId, title: 'Template' });
|
||||
const client = await new MongoClient(MONGO).connect();
|
||||
try {
|
||||
const cards = client.db().collection('cards');
|
||||
const docs = [];
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
docs.push({
|
||||
...template,
|
||||
_id: `card${board.boardId}x${i}`,
|
||||
title: `Card ${i} ${['plan', 'build', 'review', 'ship'][i % 4]} the thing`,
|
||||
listId: board.listIds[i % board.listIds.length],
|
||||
sort: Math.floor(i / board.listIds.length) * 100,
|
||||
cardNumber: i + 2,
|
||||
createdAt: new Date(), modifiedAt: new Date(), dateLastActivity: new Date(),
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < docs.length; i += 1000) await cards.insertMany(docs.slice(i, i + 1000));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
// ── the Meteor server's CPU, from the Node inspector ────────────────────────
|
||||
async function inspector() {
|
||||
const list = await (await fetch(`http://127.0.0.1:${INSPECT}/json`)).json();
|
||||
const ws = new WebSocket(list[0].webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.on('message', (m) => {
|
||||
const msg = JSON.parse(m);
|
||||
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
|
||||
});
|
||||
const send = (method, params = {}) => new Promise((resolve) => { id += 1; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); });
|
||||
await send('Profiler.enable');
|
||||
await send('Profiler.setSamplingInterval', { interval: 250 });
|
||||
return {
|
||||
start: () => send('Profiler.start'),
|
||||
stop: async () => (await send('Profiler.stop')).result.profile,
|
||||
close: () => ws.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const { analyse } = require('./profileAnalysis.cjs');
|
||||
|
||||
// ── FerretDB's CPU, from its pprof ──────────────────────────────────────────
|
||||
async function ferretProfile(seconds) {
|
||||
if (!PPROF) return null;
|
||||
const res = await fetch(`${PPROF}/debug/pprof/profile?seconds=${seconds}`);
|
||||
const file = path.join(process.env.TMPDIR || '/tmp', `ferret-open-${Date.now()}.prof`);
|
||||
require('fs').writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
||||
const env = { ...process.env, HOME: path.join(ROOT, '.tools') };
|
||||
const top = execFileSync(GO, ['tool', 'pprof', '-top', '-nodecount=80', FERRET_BIN, file], { encoding: 'utf8', env });
|
||||
const total = /Total samples = ([\d.]+)(m?s)/.exec(top);
|
||||
const cpuMs = total ? Number(total[1]) * (total[2] === 's' ? 1000 : 1) : null;
|
||||
// A line reads: flat flat% sum% cum cum% name - SQLite's share is the cumulative time of its VM.
|
||||
const line = top.split('\n').find((l) => /_sqlite3VdbeExec/.test(l));
|
||||
const sqlite = line ? /([\d.]+)(m?s)\s+[\d.]+%\s+[\d.]+%\s+([\d.]+)(m?s)\s+[\d.]+%/.exec(line) : null;
|
||||
// The cumulative top, for the first open: who in FerretDB spends it.
|
||||
const cum = execFileSync(GO, ['tool', 'pprof', '-top', '-cum', '-nodecount=34', FERRET_BIN, file], { encoding: 'utf8', env })
|
||||
.split('\n').filter((l) => /^\s+[\d.]+m?s/.test(l)).map((l) => l.trim().replace(/\s+/g, ' ')).slice(0, 34);
|
||||
return { cum, cpuMs, sqliteMs: sqlite ? Number(sqlite[3]) * (sqlite[4] === 's' ? 1000 : 1) : null };
|
||||
}
|
||||
|
||||
// ── one board open ──────────────────────────────────────────────────────────
|
||||
async function openBoard(user, board, size) {
|
||||
const browser = await pw.chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => { try { localStorage.setItem('wekan-mobile-mode', 'false'); } catch (e) { /* none */ } });
|
||||
let frames = 0; let bytes = 0; let last = Date.now();
|
||||
page.on('websocket', (ws) => ws.on('framereceived', (e) => { frames += 1; bytes += e.payload.length; last = Date.now(); }));
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const insp = INSPECT ? await inspector() : null;
|
||||
frames = 0; bytes = 0;
|
||||
const ferret = PPROF ? ferretProfile(14) : Promise.resolve(null);
|
||||
if (insp) await insp.start();
|
||||
const t0 = Date.now();
|
||||
await page.goto(`${BASE}/b/${board.boardId}/${board.slug}`, { waitUntil: 'commit' });
|
||||
let tFirst = null;
|
||||
await page.waitForSelector('.minicard', { timeout: 60000 }).then(() => { tFirst = Date.now() - t0; });
|
||||
// quiet: no DDP frame for QUIET_MS
|
||||
for (;;) {
|
||||
if (Date.now() - last > QUIET_MS && Date.now() - t0 > (tFirst || 0)) break;
|
||||
if (Date.now() - t0 > 60000) break;
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const tQuiet = Date.now() - t0 - QUIET_MS;
|
||||
const profile = insp ? await insp.stop() : null;
|
||||
const minicards = await page.locator('.minicard').count();
|
||||
const ferretResult = await ferret;
|
||||
if (insp) insp.close();
|
||||
await browser.close();
|
||||
return { size, tFirst, tQuiet, frames, kb: Math.round(bytes / 1024), minicards, server: profile ? analyse(profile) : null, ferret: ferretResult };
|
||||
}
|
||||
|
||||
const f = (v) => (v === null || v === undefined ? '-' : Math.round(v).toLocaleString('en-US'));
|
||||
|
||||
async function main() {
|
||||
const user = db.seedUser();
|
||||
const results = [];
|
||||
// What FerretDB spends in the same 14 s with nobody in the browser: the baseline.
|
||||
const idle = PPROF ? await ferretProfile(14) : null;
|
||||
if (idle) console.error(`FerretDB idle for 14 s: ${f(idle.cpuMs)} ms of CPU`);
|
||||
for (const size of SIZES) {
|
||||
console.error(`seeding a board of ${size} cards...`);
|
||||
const board = await seedBoard(user, size);
|
||||
// The first open of a board is the one a person waits for; a second shows a warm server.
|
||||
for (const pass of ['first open', 'second open']) {
|
||||
console.error(`opening ${size} (${pass})...`);
|
||||
const r = await openBoard(user, board, size);
|
||||
r.pass = pass;
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
console.log(`\n## ${LABEL}: opening a board\n`);
|
||||
if (idle) console.log(`FerretDB with nobody in the browser: ${f(idle.cpuMs)} ms of CPU in the same 14 s window (subtract it).\n`);
|
||||
console.log('| Cards | Open | First card (ms) | Settled (ms) | DDP frames | DDP KB | Minicards drawn | Meteor server busy (ms) | FerretDB CPU (ms) | of it SQLite (ms) |');
|
||||
console.log('| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
|
||||
for (const r of results) {
|
||||
console.log(`| ${r.size} | ${r.pass} | ${f(r.tFirst)} | ${f(r.tQuiet)} | ${f(r.frames)} | ${f(r.kb)} | ${f(r.minicards)} | ${f(r.server && r.server.busyMs)} | ${f(r.ferret && r.ferret.cpuMs)} | ${f(r.ferret && r.ferret.sqliteMs)} |`);
|
||||
}
|
||||
for (const r of results.filter((x) => x.pass === 'first open' && x.server)) {
|
||||
console.log(`\n### The Meteor server, ${r.size} cards, first open: ${f(r.server.busyMs)} ms of CPU\n`);
|
||||
for (const [c, ms] of r.server.categories) console.log(`- ${c}: ${f(ms)} ms (${Math.round((100 * ms) / r.server.busyMs)}%)`);
|
||||
if (r.ferret && r.ferret.cum) {
|
||||
console.log(`\n### FerretDB, ${r.size} cards, first open: cumulative CPU by function (flat flat% sum% cum cum% name)\n`);
|
||||
console.log('```');
|
||||
r.ferret.cum.forEach((l) => console.log(l));
|
||||
console.log('```');
|
||||
}
|
||||
console.log('\nTop functions:');
|
||||
for (const [fn, ms] of r.server.topFunctions) console.log(`- ${f(ms)} ms ${fn}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
// A Node inspector CPU profile of the Meteor server, by who spends the time. Apart from
|
||||
// the browser and the database so a test can drive it with a made-up profile.
|
||||
|
||||
function categorise(node) {
|
||||
const { functionName, url } = node.callFrame;
|
||||
if (functionName === '(idle)') return 'idle';
|
||||
if (functionName === '(garbage collector)') return 'garbage collector';
|
||||
if (functionName === '(program)') return 'native (program)';
|
||||
if (/\/mongodb\/|npm-mongo|\/bson\//.test(url)) return 'MongoDB driver (wire, decode, encode)';
|
||||
if (/packages\/ddp-server|packages\/ddp-common|packages\/ddp\b/.test(url)) return 'Meteor DDP (publish, mergebox, sessions)';
|
||||
if (/packages\/mongo|minimongo|diff-sequence/.test(url)) return 'Meteor mongo (observe, polling, diff)';
|
||||
if (/packages\/ejson/.test(url)) return 'EJSON';
|
||||
// Other Meteor packages and dependencies are not WeKan's: their paths contain /server/ too.
|
||||
if (/programs\/server\/packages|node_modules/.test(url)) return 'other packages';
|
||||
if (/programs\/server\/app|\/_build\/|\/models\/|\/server\/|\/imports\/|wekan/.test(url)) return 'WeKan code';
|
||||
if (/^node:/.test(url) || url === '') return 'node internals';
|
||||
return 'other packages';
|
||||
}
|
||||
|
||||
function analyse(profile) {
|
||||
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
|
||||
const cats = new Map();
|
||||
const fns = new Map();
|
||||
let total = 0;
|
||||
profile.samples.forEach((sid, i) => {
|
||||
const dt = (profile.timeDeltas[i + 1] ?? profile.timeDeltas[i] ?? 0) / 1000; // ms
|
||||
const node = byId.get(sid);
|
||||
const cat = categorise(node);
|
||||
cats.set(cat, (cats.get(cat) || 0) + dt);
|
||||
total += dt;
|
||||
if (cat !== 'idle') {
|
||||
const key = `${node.callFrame.functionName || '(anonymous)'} ${node.callFrame.url.split('/').slice(-2).join('/')}`;
|
||||
fns.set(key, (fns.get(key) || 0) + dt);
|
||||
}
|
||||
});
|
||||
const idle = cats.get('idle') || 0;
|
||||
return {
|
||||
totalMs: total, busyMs: total - idle,
|
||||
categories: [...cats.entries()].filter(([c]) => c !== 'idle').sort((a, b) => b[1] - a[1]),
|
||||
topFunctions: [...fns.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { categorise, analyse };
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-12.93-win64.zip](https://github.com/wekan/wekan/releases/download/v12.93/wekan-12.93-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-12.97-win64.zip](https://github.com/wekan/wekan/releases/download/v12.97/wekan-12.97-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
|
||||
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
|
||||
|
||||
@@ -22,7 +22,7 @@ Right click and download files 1-4:
|
||||
|
||||
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
|
||||
|
||||
7. Unzip `wekan-12.93-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-12.97-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
@@ -76,6 +76,7 @@ runOnServer(function() {
|
||||
// First check if board exists and is public to avoid unnecessary authentication
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Board not found');
|
||||
return;
|
||||
}
|
||||
@@ -96,6 +97,7 @@ runOnServer(function() {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
||||
}
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ runOnServer(function() {
|
||||
// First check if board exists and is public to avoid unnecessary authentication
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Board not found');
|
||||
return;
|
||||
}
|
||||
@@ -129,6 +130,7 @@ runOnServer(function() {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
||||
}
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Invalid token');
|
||||
return;
|
||||
}
|
||||
@@ -199,6 +201,7 @@ runOnServer(function() {
|
||||
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Board not found');
|
||||
return;
|
||||
}
|
||||
@@ -218,6 +221,7 @@ runOnServer(function() {
|
||||
const loginToken = req.query && req.query.authToken;
|
||||
if (loginToken) {
|
||||
if (loginToken.length > 10000) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
// Whether this installation lets people upload attachments (#6870).
|
||||
//
|
||||
// The switch already exists: Admin Panel / Attachments / Transfer limits has "Attachments upload"
|
||||
// and "API upload" set to Blocked (limitSettings.attachmentsUploadBlocked / apiUploadBlocked).
|
||||
// The server refuses an upload on every path when it is on - the upload pipeline
|
||||
// (models/attachments.server.js) and the API methods and routes (server/attachmentApi.js,
|
||||
// server/routes/attachmentApi.js). What was missing is that the card still offered the "+" button
|
||||
// and a drop zone for an upload that would be refused. This is the one decision the server
|
||||
// method that tells the client and the tests share, so there is no second setting that could
|
||||
// disagree with the one the server enforces.
|
||||
//
|
||||
// Only the boolean leaves the server: the rest of the limits (sizes, storage paths) are for
|
||||
// administrators.
|
||||
|
||||
function uploadPolicy(limitSettings) {
|
||||
const limits = limitSettings && typeof limitSettings === 'object' ? limitSettings : {};
|
||||
return {
|
||||
// anything but a real `true` keeps uploads as they were
|
||||
uploadsBlocked: limits.attachmentsUploadBlocked === true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { uploadPolicy };
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
// Which server streams the continuous backup (#6861, item 3).
|
||||
//
|
||||
// The continuous backup tails the oplog and writes archives into one target directory. With several
|
||||
// WeKan servers on one database - the replicas of a Kubernetes Deployment - every one of them applied
|
||||
// the setting, so the same stream was written many times into one place. It needs a leader: one
|
||||
// server runs the stream, and another takes over when it stops.
|
||||
//
|
||||
// The leader holds a LEASE: one document, `continuousBackupLease/continuous-backup`, with the
|
||||
// holder's id and an expiry. Every decision is ONE database operation, so there is no gap between
|
||||
// "is it free" and "it is mine" for a second server to slip through (the pattern of
|
||||
// models/lib/dailyClaim.js and dueScanClaim.js):
|
||||
// - acquireLease: an insert while there is no lease at all (a duplicate key says someone has it),
|
||||
// else an update whose filter matches only a lease that is unheld, already ours, or expired;
|
||||
// - the holder renews it every RENEW_MS, and it lapses TTL_MS after the last renewal, so a leader
|
||||
// that crashed, was rolled out or lost its database for a minute is replaced by the next server
|
||||
// to look - without waiting for anybody to notice.
|
||||
// No upsert on purpose: an upsert whose filter does not match has to insert, and what a database
|
||||
// does then (a duplicate key, or a second document) is the very thing the lease must not depend on.
|
||||
//
|
||||
// `collection` is a MongoDB driver collection (insertOne, updateOne).
|
||||
|
||||
const LEASE_ID = 'continuous-backup';
|
||||
const RENEW_MS = 15 * 1000;
|
||||
const TTL_MS = 45 * 1000;
|
||||
|
||||
const isDuplicateKey = error => !!error && (error.code === 11000 || /E11000|duplicate key/i.test(String(error.message || '')));
|
||||
|
||||
// Resolves true when `holder` leads from now until the lease expires.
|
||||
async function acquireLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
|
||||
const expiresAt = new Date(now.getTime() + ttlMs);
|
||||
try {
|
||||
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isDuplicateKey(error)) throw error;
|
||||
}
|
||||
const result = await collection.updateOne(
|
||||
{ _id: LEASE_ID, $or: [{ holder }, { holder: null }, { expiresAt: { $lte: now } }] },
|
||||
{ $set: { holder, expiresAt } },
|
||||
);
|
||||
return (result.matchedCount || 0) > 0 || (result.modifiedCount || 0) > 0;
|
||||
}
|
||||
|
||||
// Gives the lease up when this server is the holder (a clean stop, or the feature switched off), so
|
||||
// the next server does not have to wait for it to expire.
|
||||
async function releaseLease(collection, holder) {
|
||||
await collection.updateOne({ _id: LEASE_ID, holder }, { $set: { holder: null, expiresAt: new Date(0) } });
|
||||
}
|
||||
|
||||
// Takes the lease whoever holds it. For a restore, which must be the only writer: the former leader
|
||||
// finds at its next renewal that it lost the lease and stops, so the caller then waits RENEW_MS
|
||||
// before it writes anything (see waitMs).
|
||||
async function takeoverLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
|
||||
const expiresAt = new Date(now.getTime() + ttlMs);
|
||||
try {
|
||||
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
|
||||
return { waitMs: 0 };
|
||||
} catch (error) {
|
||||
if (!isDuplicateKey(error)) throw error;
|
||||
}
|
||||
const result = await collection.updateOne({ _id: LEASE_ID }, { $set: { holder, expiresAt } });
|
||||
// taking a lease this server already held needs no wait: nobody else was streaming
|
||||
return { waitMs: RENEW_MS, matched: (result.matchedCount || 0) > 0 };
|
||||
}
|
||||
|
||||
module.exports = { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease };
|
||||
@@ -100,10 +100,10 @@ const CARD_SETTINGS_ROWS = [
|
||||
{ key: 'creator', icons: ['fa-user'], label: ['creator'],
|
||||
card: { toggle: 'js-field-has-creator', field: 'allowsCreator' },
|
||||
minicard: { toggle: 'js-field-has-creator-on-minicard', field: 'allowsCreatorOnMinicard' } },
|
||||
{ key: 'requestedBy', icons: ['fa-user', 'fa-plus'], label: ['requested-by'],
|
||||
{ key: 'requestedBy', icons: ['fa-shopping-cart'], label: ['requested-by'],
|
||||
card: { toggle: 'js-field-has-requested-by', field: 'allowsRequestedBy' },
|
||||
minicard: { toggle: 'js-board-card-flag', field: 'allowsRequestedByOnMinicard', flag: true, default: true } },
|
||||
{ key: 'assignedBy', icons: ['fa-shopping-cart'], label: ['assigned-by'],
|
||||
{ key: 'assignedBy', icons: ['fa-user-plus'], label: ['assigned-by'],
|
||||
card: { toggle: 'js-field-has-assigned-by', field: 'allowsAssignedBy' },
|
||||
minicard: { toggle: 'js-board-card-flag', field: 'allowsAssignedByOnMinicard', flag: true, default: true } },
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.93.0",
|
||||
"version": "v12.97.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v12.93.0",
|
||||
"version": "v12.97.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.93.0",
|
||||
"version": "v12.97.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v12.93</title>
|
||||
<title>WeKan REST API v12.97</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
|
||||
|
||||
<body>
|
||||
|
||||
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
|
||||
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
|
||||
<script type="module">
|
||||
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
|
||||
|
||||
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.93","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
|
||||
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.97","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
|
||||
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
|
||||
|
||||
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v12.93
|
||||
version: v12.97
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
|
||||
+23
-1
@@ -17,7 +17,29 @@ log="$work/start-wekan.log"
|
||||
exec ./start-wekan.sh
|
||||
) > "$log" 2>&1 &
|
||||
pid=$!
|
||||
stop() { kill "$pid" 2>/dev/null || true; pkill -f "$bundle/ferretdb" 2>/dev/null || true; }
|
||||
# Stop the whole stack, WAIT for it to be gone, and then remove its files.
|
||||
# start-wekan.sh runs WeKan in the foreground, and bash runs a TERM trap only
|
||||
# after the foreground command returns - so killing the script alone left
|
||||
# node and FerretDB running after this test said OK. v12.96 openbsd-arm64:
|
||||
# FerretDB closed its database while the VM's copyback rsync was walking the
|
||||
# workspace, wekan.sqlite-wal and -shm vanished under it, and rsync's exit 24
|
||||
# failed a bundle that had built and started fine. Nothing may still be
|
||||
# running, or still be on disk under .tools/tmp, when this script returns.
|
||||
running() { kill -0 "$pid" 2>/dev/null || pgrep -f "$bundle/(ferretdb|main\.js)" >/dev/null 2>&1; }
|
||||
stop() {
|
||||
kill "$pid" 2>/dev/null || true
|
||||
pkill -f "$bundle/main\.js" 2>/dev/null || true
|
||||
pkill -f "$bundle/ferretdb" 2>/dev/null || true
|
||||
local n=0
|
||||
while running && [ "$n" -lt 30 ]; do sleep 1; n=$((n + 1)); done
|
||||
if running; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
pkill -9 -f "$bundle/(ferretdb|main\.js)" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap stop EXIT
|
||||
for i in $(seq 1 120); do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
|
||||
@@ -155,14 +155,20 @@ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-bundle-bump-'));
|
||||
const bumped = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const [name, minimum] of Object.entries(manifest.minimums)) {
|
||||
const outdated = copiesOf(name).filter(c => compare(c.version, minimum) < 0);
|
||||
for (const [key, minimum] of Object.entries(manifest.minimums)) {
|
||||
// "body-parser@2" limits the minimum to copies of that major; "@babel/runtime"
|
||||
// is a scoped name, not a major, so only an @ after the first character counts.
|
||||
const at = key.lastIndexOf('@');
|
||||
const name = at > 0 ? key.slice(0, at) : key;
|
||||
const major = at > 0 ? key.slice(at + 1) : null;
|
||||
const outdated = copiesOf(name).filter(c =>
|
||||
(major === null || c.version.split('.')[0] === major) && compare(c.version, minimum) < 0);
|
||||
if (!outdated.length) {
|
||||
skipped.push(name);
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = path.join(tmpRoot, name.replace('/', '+'));
|
||||
const prefix = path.join(tmpRoot, key.replace(/[\/@]/g, '+') + '-' + minimum);
|
||||
if (!dryRun) {
|
||||
fs.mkdirSync(prefix, { recursive: true });
|
||||
// A prefix with no package.json is what we want: npm installs the one
|
||||
@@ -180,6 +186,15 @@ for (const [name, minimum] of Object.entries(manifest.minimums)) {
|
||||
|
||||
fs.rmSync(copy.dir, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(prefix, 'node_modules', name), copy.dir, { recursive: true });
|
||||
// The entry file the bundle recorded for the OLD version, written back so it
|
||||
// resolves again (uuid 11 moved dist/index.js to dist/cjs/index.js).
|
||||
for (const [rel, body] of Object.entries((manifest.shims || {})[name] || {})) {
|
||||
const file = path.join(copy.dir, rel);
|
||||
if (fs.existsSync(file)) continue;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body);
|
||||
console.log(` + ${rel} (shim for the entry point ${name} ${copy.version} was built against)`);
|
||||
}
|
||||
|
||||
// Whatever the new version needs that this node_modules does not have. An
|
||||
// existing sibling is left as it is - it is some other package's choice.
|
||||
|
||||
@@ -7,10 +7,25 @@
|
||||
"be raised is the bundle, after `meteor build` has produced it.",
|
||||
"",
|
||||
"Every entry is a version that FIXES a published advisory in the version the",
|
||||
"bundle would otherwise ship, and stays INSIDE THE SAME MAJOR. That is not",
|
||||
"caution for its own sake: a Meteor bundle records the file a package resolves",
|
||||
"to when it is built, so a major that moves its entry point cannot be swapped",
|
||||
"in afterwards - see uuid under notFixable, which is what taught us that.",
|
||||
"bundle would otherwise ship. A minimum stays inside the same major WHEN a fix",
|
||||
"exists there. When the fix is only in a newer major it is still taken, but only",
|
||||
"after the new major was run the way the Meteor package uses it: nodemailer 10",
|
||||
"against the calls meteor/email makes, uuid 11 through a shim (see \"shims\").",
|
||||
"",
|
||||
"A key may be \"name@MAJOR\" (body-parser@2): the minimum then applies only to",
|
||||
"copies of that major, so body-parser 1.x and 2.x are each raised inside their",
|
||||
"own major and neither is dragged across it.",
|
||||
"",
|
||||
"A bundle records the file a package resolves to when it is built, so a major",
|
||||
"that moved its entry point cannot just be swapped in: uuid 11 moved it from",
|
||||
"dist/index.js to dist/cjs/index.js and the server died on boot (\"Cannot find",
|
||||
"module .../uuid/dist/index.js\"). \"shims\" writes the old entry file back,",
|
||||
"re-exporting the new one. nodemailer 10 moved lib/ to dist/cjs/, while",
|
||||
"meteor/email, built against 8.0.3, links lib/nodemailer.js and",
|
||||
"lib/well-known/index.js and Npm.requires lib/mail-composer by path: v12.95's",
|
||||
"bundle failed its smoke boot on the first. nodemailer is \"type\": \"module\",",
|
||||
"so its shim marks lib/ as CommonJS and forwards lib/nodemailer.js and every",
|
||||
"lib/<name> directory its exports map lists to dist/cjs.",
|
||||
"",
|
||||
"Applied by releases/bump-bundle-npm-deps.mjs, pinned by",
|
||||
"tests/bundleNpmSecurityBumps.test.cjs. A package NOT listed here is a package",
|
||||
@@ -21,7 +36,8 @@
|
||||
"minimums": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"baseline-browser-mapping": "2.11.0",
|
||||
"body-parser": "1.20.6",
|
||||
"body-parser": "1.20.9",
|
||||
"body-parser@2": "2.4.0",
|
||||
"browserslist": "4.28.7",
|
||||
"colord": "2.9.4",
|
||||
"compression": "1.8.2",
|
||||
@@ -30,43 +46,54 @@
|
||||
"lodash": "4.18.1",
|
||||
"lodash.template": "4.18.1",
|
||||
"nanoid": "3.3.18",
|
||||
"nodemailer": "8.0.11",
|
||||
"nodemailer": "10.3.1",
|
||||
"on-headers": "1.1.0",
|
||||
"openpgp": "5.11.3",
|
||||
"picomatch": "4.0.5",
|
||||
"postcss": "8.5.26",
|
||||
"qs": "6.16.0",
|
||||
"postcss-selector-parser": "6.1.5",
|
||||
"qs": "6.16.1",
|
||||
"source-map-js": "1.2.2",
|
||||
"svgo": "2.8.4",
|
||||
"tmp": "0.2.7",
|
||||
"underscore": "1.13.8"
|
||||
"underscore": "1.13.8",
|
||||
"uuid": "11.1.1"
|
||||
},
|
||||
"notFixable": {
|
||||
"nodemailer": [
|
||||
"8.0.11 is the newest 8.x. CVE-2026-82659 (fixed in 9.0.1), GHSA-2x7j-588g-ccc2,",
|
||||
"GHSA-cc9r-2j5m-2m83 and GHSA-wmmp-3585-3rmp (9.1.0), GHSA-8m3c-c648-2xjj (9.1.1),",
|
||||
"GHSA-6vj9-mwq6-2f5v and GHSA-8vvx-rff5-p5rq (10.0.2) and GHSA-v53p-9fqp-m79j",
|
||||
"(10.0.6) are fixed only in majors the Meteor email package is not written",
|
||||
"against. They move when meteor/email moves."
|
||||
],
|
||||
"postcss-selector-parser": [
|
||||
"CVE-2026-104844 in the 6.1.4 meteor/minifier-css brings (through cssnano) is",
|
||||
"fixed only in 7.1.6, a new major its dependents are not written against.",
|
||||
"minifier-css runs at build time; it moves when Meteor's minifier-css moves."
|
||||
],
|
||||
"uuid": [
|
||||
"CVE-2026-41907 affects the 8.3.2 in meteor/ddp-server and",
|
||||
"meteor/inter-process-messaging and the 9.0.1 ldapts brings to wekan-ldap,",
|
||||
"and the lowest fixed release is 11.1.1 - a major that MOVED its entry",
|
||||
"point from dist/index.js to dist/cjs/index.js. The bundle resolves the old",
|
||||
"path, so putting 11.1.1 in its place stops the server before it starts:",
|
||||
"",
|
||||
" error on boot.js Error: Cannot find module",
|
||||
" \"/node_modules/meteor/inter-process-messaging/node_modules/uuid/dist/index.js\"",
|
||||
"",
|
||||
"It was tried, it failed exactly there, and it is left at the version the",
|
||||
"packages were built against. It moves when those packages move: Meteor's",
|
||||
"for ddp-server and inter-process-messaging, ldapts' for wekan-ldap."
|
||||
"6.1.5 fixes GHSA-rj75-hqrm-r3gf and is taken. CVE-2026-104844 in the copy",
|
||||
"meteor/minifier-css brings (through cssnano) is fixed only in 7.1.6, a new major",
|
||||
"its dependents are not written against. minifier-css runs at build time; it",
|
||||
"moves when Meteor's minifier-css moves."
|
||||
]
|
||||
},
|
||||
"shims": {
|
||||
"nodemailer": {
|
||||
"lib/package.json": "{ \"type\": \"commonjs\" }\n",
|
||||
"lib/nodemailer.js": "module.exports = require('../dist/cjs/nodemailer.js');\n",
|
||||
"lib/addressparser/index.js": "module.exports = require('../../dist/cjs/addressparser/index.js');\n",
|
||||
"lib/base64/index.js": "module.exports = require('../../dist/cjs/base64/index.js');\n",
|
||||
"lib/dkim/index.js": "module.exports = require('../../dist/cjs/dkim/index.js');\n",
|
||||
"lib/fetch/index.js": "module.exports = require('../../dist/cjs/fetch/index.js');\n",
|
||||
"lib/json-transport/index.js": "module.exports = require('../../dist/cjs/json-transport/index.js');\n",
|
||||
"lib/mail-composer/index.js": "module.exports = require('../../dist/cjs/mail-composer/index.js');\n",
|
||||
"lib/mailer/index.js": "module.exports = require('../../dist/cjs/mailer/index.js');\n",
|
||||
"lib/mime-funcs/index.js": "module.exports = require('../../dist/cjs/mime-funcs/index.js');\n",
|
||||
"lib/mime-node/index.js": "module.exports = require('../../dist/cjs/mime-node/index.js');\n",
|
||||
"lib/punycode/index.js": "module.exports = require('../../dist/cjs/punycode/index.js');\n",
|
||||
"lib/qp/index.js": "module.exports = require('../../dist/cjs/qp/index.js');\n",
|
||||
"lib/sendmail-transport/index.js": "module.exports = require('../../dist/cjs/sendmail-transport/index.js');\n",
|
||||
"lib/ses-transport/index.js": "module.exports = require('../../dist/cjs/ses-transport/index.js');\n",
|
||||
"lib/shared/index.js": "module.exports = require('../../dist/cjs/shared/index.js');\n",
|
||||
"lib/smtp-connection/index.js": "module.exports = require('../../dist/cjs/smtp-connection/index.js');\n",
|
||||
"lib/smtp-pool/index.js": "module.exports = require('../../dist/cjs/smtp-pool/index.js');\n",
|
||||
"lib/smtp-transport/index.js": "module.exports = require('../../dist/cjs/smtp-transport/index.js');\n",
|
||||
"lib/stream-transport/index.js": "module.exports = require('../../dist/cjs/stream-transport/index.js');\n",
|
||||
"lib/well-known/index.js": "module.exports = require('../../dist/cjs/well-known/index.js');\n",
|
||||
"lib/xoauth2/index.js": "module.exports = require('../../dist/cjs/xoauth2/index.js');\n"
|
||||
},
|
||||
"uuid": {
|
||||
"dist/index.js": "export * from './esm/index.js';\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ ensure_tools git
|
||||
if ! command -v awk >/dev/null 2>&1; then
|
||||
case "$(_et_linux_family)" in
|
||||
alpine) sudo apk add --no-cache gawk ;;
|
||||
arch) sudo pacman -Sy --needed --noconfirm gawk ;;
|
||||
arch) _et_pacman gawk ;;
|
||||
fedora|rhel) pm=dnf; command -v dnf >/dev/null 2>&1 || pm=yum; sudo "$pm" install -y gawk ;;
|
||||
*) ensure_tools awk ;;
|
||||
esac
|
||||
|
||||
+103
-7
@@ -2,7 +2,8 @@
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/tools-env.sh" # temporary files and downloads go to .tools/tmp
|
||||
#
|
||||
# Shared helper: install required CLI tools for the current platform if missing.
|
||||
# Supports Debian/Ubuntu, Fedora, RHEL/Oracle Linux, Alpine, Arch, and macOS.
|
||||
# Supports Debian/Ubuntu, Fedora, RHEL/Oracle Linux, Alpine, Arch (and the
|
||||
# Arch-based Omarchy, EndeavourOS, CachyOS, Manjaro ...), and macOS.
|
||||
# The package-manager commands are architecture independent.
|
||||
#
|
||||
# Usage (source it, then call ensure_tools with the tools you need):
|
||||
@@ -27,7 +28,10 @@ _et_linux_family() {
|
||||
. "$os_release"
|
||||
case "${ID:-} ${ID_LIKE:-}" in
|
||||
*alpine*) echo alpine; return ;;
|
||||
*arch*) echo arch; return ;;
|
||||
# Omarchy (https://omarchy.org) is Arch with Hyprland on top: pacman,
|
||||
# the Arch repositories and yay for the AUR. Named explicitly so it is
|
||||
# recognised whether its os-release says ID=omarchy, ID=arch or ID_LIKE=arch.
|
||||
*omarchy*|*arch*|*manjaro*|*endeavouros*|*cachyos*) echo arch; return ;;
|
||||
*ol*|*oracle*|*rhel*|*centos*|*rocky*|*almalinux*) echo rhel; return ;;
|
||||
*fedora*) echo fedora; return ;;
|
||||
*debian*|*ubuntu*) echo debian; return ;;
|
||||
@@ -43,6 +47,68 @@ _et_linux_family() {
|
||||
|
||||
_et_have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# Omarchy is detected by its own tooling, for the messages that name its
|
||||
# update command; everything else treats it as the Arch family it is.
|
||||
_et_is_omarchy() {
|
||||
_et_have omarchy-update || [ -d "${HOME:-/nonexistent}/.local/share/omarchy" ]
|
||||
}
|
||||
|
||||
# Install Arch packages WITHOUT a partial upgrade. `pacman -Sy <pkg>` refreshes
|
||||
# the package lists but upgrades nothing else, which Arch does not support: the
|
||||
# new package can be linked against libraries newer than the installed ones
|
||||
# (https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported).
|
||||
# So: `-S --needed` against the lists the system already has. A container that
|
||||
# has never synced (archlinux:latest ships with no sync databases) gets a full
|
||||
# `-Syu`, which is the supported way to sync, and so does WEKAN_PACMAN_SYU=1.
|
||||
_et_pacman() {
|
||||
local sync_dir="${WEKAN_PACMAN_SYNC_DIR:-/var/lib/pacman/sync}"
|
||||
if [ "${WEKAN_PACMAN_SYU:-0}" = 1 ] || ! ls "$sync_dir"/*.db >/dev/null 2>&1; then
|
||||
sudo pacman -Syu --needed --noconfirm "$@"
|
||||
return
|
||||
fi
|
||||
if ! sudo pacman -S --needed --noconfirm "$@"; then
|
||||
echo "pacman could not install: $*" >&2
|
||||
if _et_is_omarchy; then
|
||||
echo " The package lists are probably older than the mirrors. Update first with omarchy-update" >&2
|
||||
else
|
||||
echo " The package lists are probably older than the mirrors. Update first with sudo pacman -Syu" >&2
|
||||
fi
|
||||
echo " (or rerun with WEKAN_PACMAN_SYU=1 to do that full upgrade here), then run this again." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Install from the AUR with the helper that is there: Omarchy ships yay, other
|
||||
# Arch installs often have paru. Neither runs as root, so no sudo here.
|
||||
_et_aur() {
|
||||
local helper
|
||||
for helper in yay paru; do
|
||||
if _et_have "$helper"; then
|
||||
"$helper" -S --needed --noconfirm "$@"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo "No AUR helper (yay or paru) found; install $* from https://aur.archlinux.org manually." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# The OpenSSH server unit is ssh.service on Debian/Ubuntu and sshd.service on
|
||||
# Arch/Omarchy, Fedora, RHEL and Alpine.
|
||||
_et_ssh_unit() {
|
||||
if [ "$(_et_linux_family)" = debian ]; then echo ssh; else echo sshd; fi
|
||||
}
|
||||
|
||||
# How to make en_US.UTF-8 available, per distribution, for the build notes.
|
||||
_et_locale_hint() {
|
||||
case "$(_et_linux_family)" in
|
||||
debian) echo "run sudo dpkg-reconfigure locales." ;;
|
||||
fedora|rhel) echo "install glibc-langpack-en and enable en_US.UTF-8." ;;
|
||||
arch) echo "uncomment en_US.UTF-8 in /etc/locale.gen and run sudo locale-gen." ;;
|
||||
alpine) echo "Alpine uses musl; install musl-locales if the build requires locale data." ;;
|
||||
*) echo "generate the en_US.UTF-8 locale with your distribution's tools." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Ensure Homebrew exists and is on PATH (Apple Silicon: /opt/homebrew, Intel: /usr/local).
|
||||
_et_brew_ensure() {
|
||||
if ! _et_have brew; then
|
||||
@@ -129,11 +195,19 @@ ensure_tools() {
|
||||
g++) package=gcc ;;
|
||||
awk) package=gawk ;;
|
||||
pip3|python3-pip) package=python-pip ;;
|
||||
7zip) package=p7zip ;;
|
||||
snapcraft) echo "Install snapd from AUR, enable snapd.socket, then install snapcraft." >&2; return 1 ;;
|
||||
# Arch replaced p7zip with 7zip (7-Zip itself) in its repositories.
|
||||
7zip) package=7zip ;;
|
||||
snapcraft)
|
||||
# snapd is in the AUR, not the Arch repositories.
|
||||
_et_aur snapd || return 1
|
||||
sudo systemctl enable --now snapd.socket
|
||||
sudo ln -sfn /var/lib/snapd/snap /snap
|
||||
sudo snap install snapcraft --classic
|
||||
continue
|
||||
;;
|
||||
*) package="$tool" ;;
|
||||
esac
|
||||
sudo pacman -Sy --needed --noconfirm "$package"
|
||||
_et_pacman "$package"
|
||||
;;
|
||||
debian)
|
||||
case "$tool" in
|
||||
@@ -220,7 +294,7 @@ ensure_build_toolchain() {
|
||||
local pm
|
||||
case "$(_et_os):$(_et_linux_family)" in
|
||||
linux:alpine) sudo apk add --no-cache bash build-base python3 curl wget p7zip zip unzip ;;
|
||||
linux:arch) sudo pacman -Sy --needed --noconfirm base-devel python curl wget p7zip zip unzip ;;
|
||||
linux:arch) _et_pacman base-devel python curl wget 7zip zip unzip ;;
|
||||
linux:fedora) sudo dnf group install -y development-tools; sudo dnf install -y gcc gcc-c++ make python3 curl wget 7zip zip unzip ;;
|
||||
linux:rhel) pm=dnf; _et_have dnf || pm=yum; sudo "$pm" groupinstall -y "Development Tools"; sudo "$pm" install -y gcc gcc-c++ make python3 curl wget p7zip zip unzip ;;
|
||||
linux:debian) sudo apt-get update; sudo apt-get install -y libatomic1 build-essential g++ make python3 curl wget p7zip-full zip unzip ;;
|
||||
@@ -234,7 +308,7 @@ ensure_archive_tools() {
|
||||
local pm
|
||||
case "$(_et_os):$(_et_linux_family)" in
|
||||
linux:alpine) sudo apk add --no-cache curl wget p7zip zip unzip ;;
|
||||
linux:arch) sudo pacman -Sy --needed --noconfirm curl wget p7zip zip unzip ;;
|
||||
linux:arch) _et_pacman curl wget 7zip zip unzip ;;
|
||||
linux:fedora) sudo dnf install -y curl wget 7zip zip unzip ;;
|
||||
linux:rhel) pm=dnf; _et_have dnf || pm=yum; sudo "$pm" install -y curl wget p7zip zip unzip ;;
|
||||
linux:debian) sudo apt-get update; sudo apt-get install -y curl wget p7zip-full zip unzip ;;
|
||||
@@ -242,3 +316,25 @@ ensure_archive_tools() {
|
||||
*) echo "Unsupported platform: install curl, wget, 7zip, zip and unzip." >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The shared libraries Playwright's Chromium and Firefox load, for the
|
||||
# distributions where `playwright install --with-deps` cannot install them
|
||||
# (it runs apt-get). Debian/Ubuntu keep using --with-deps.
|
||||
ensure_playwright_system_deps() {
|
||||
local pm
|
||||
case "$(_et_os):$(_et_linux_family)" in
|
||||
linux:arch)
|
||||
# Arch and Omarchy. A desktop Omarchy already has nearly all of these;
|
||||
# --needed makes that a no-op rather than a reinstall.
|
||||
_et_pacman nss at-spi2-core libcups libdrm libxkbcommon libxcomposite \
|
||||
libxdamage libxrandr libxfixes mesa pango cairo alsa-lib gtk3 dbus-glib ;;
|
||||
linux:fedora|linux:rhel)
|
||||
pm=dnf; _et_have dnf || pm=yum
|
||||
sudo "$pm" install -y nss atk at-spi2-atk cups-libs libdrm libxkbcommon \
|
||||
libXcomposite libXdamage libXrandr libXfixes mesa-libgbm pango cairo \
|
||||
alsa-lib gtk3 dbus-glib ;;
|
||||
linux:alpine)
|
||||
echo "Playwright publishes no musl browsers; use WEKAN_PLAYWRIGHT_DOCKER=1 on Alpine." >&2 ;;
|
||||
*) : ;;
|
||||
esac
|
||||
}
|
||||
@@ -20,12 +20,7 @@ if [ ! -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
|
||||
fi
|
||||
|
||||
echo "Note: If you use other locale than en_US.UTF-8 , you need to additionally install en_US.UTF-8"
|
||||
case "$(_et_linux_family)" in
|
||||
fedora|rhel) echo " install glibc-langpack-en and enable en_US.UTF-8." ;;
|
||||
debian) echo " run sudo dpkg-reconfigure locales." ;;
|
||||
arch) echo " enable en_US.UTF-8 in /etc/locale.gen and run locale-gen." ;;
|
||||
alpine) echo " Alpine uses musl; install musl-locales if the build requires locale data." ;;
|
||||
esac
|
||||
[ "$(_et_os)" = linux ] && echo " $(_et_locale_hint)"
|
||||
echo " You can still use any other locale as your main locale."
|
||||
|
||||
echo "Building Wekan."
|
||||
|
||||
@@ -89,8 +89,9 @@ if [[ "$OSTYPE" == "linux-gnu" ]]; then
|
||||
}
|
||||
|
||||
# Snapcraft is available natively on Debian, Fedora and RHEL families.
|
||||
# Alpine has no snapd package; Arch requires snapd from AUR, so the shared
|
||||
# helper reports the required manual prerequisite instead of running apt.
|
||||
# Alpine has no snapd package. Arch and Omarchy get snapd from the AUR with
|
||||
# yay or paru (Omarchy ships yay); the shared helper does that, or says what
|
||||
# to install when there is no AUR helper.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
. "$SCRIPT_DIR/ensure-tools.sh"
|
||||
ensure_tools snapcraft
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 1293,
|
||||
appVersion = 1297,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "12.93~2026-11-03"),
|
||||
appMarketingVersion = (defaultText = "12.97~2026-11-06"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { check, Match } from 'meteor/check';
|
||||
import { Mongo, MongoInternals } from 'meteor/mongo';
|
||||
import { Random } from 'meteor/random';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import path from 'path';
|
||||
@@ -31,10 +32,18 @@ function cloudRemote(provider) {
|
||||
return adapter ? adapterRemote(adapter.storage, adapter.bucketName) : null;
|
||||
}
|
||||
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
|
||||
const { RENEW_MS, acquireLease, releaseLease, takeoverLease } = require('/models/lib/backupLease');
|
||||
|
||||
export const ContinuousBackupSettings = new Mongo.Collection('continuousBackupSettings');
|
||||
ContinuousBackupSettings.deny({ insert: () => true, update: () => true, remove: () => true });
|
||||
|
||||
// #6861: with several servers on one database only one streams (models/lib/backupLease.js).
|
||||
export const ContinuousBackupLease = new Mongo.Collection('continuousBackupLease');
|
||||
ContinuousBackupLease.deny({ insert: () => true, update: () => true, remove: () => true });
|
||||
const leaseCollection = () => ContinuousBackupLease.rawCollection();
|
||||
// This server's name in the lease.
|
||||
const serverId = Random.id();
|
||||
|
||||
const writablePath = () => path.resolve(process.env.WRITABLE_PATH || process.cwd());
|
||||
const filesRoot = () => filesRootFrom(writablePath());
|
||||
// The FerretDB SQLite directory, from the environment the startup scripts
|
||||
@@ -78,6 +87,55 @@ async function requireSiteAdmin(userId) {
|
||||
}
|
||||
|
||||
let restoring = false;
|
||||
// The leader loop. Every server with the feature switched on runs it: the one holding the lease
|
||||
// streams, the others stand by and one of them takes over within about a minute when the leader
|
||||
// stops renewing (a pod restart, a rollout, a crash). A single server is always its own leader.
|
||||
let leading = false;
|
||||
let appliedStamp = null;
|
||||
let leaseTimer = null;
|
||||
const stampOf = settings => String(settings.updatedAt ? new Date(settings.updatedAt).getTime() : 0);
|
||||
|
||||
async function evaluateLease() {
|
||||
if (restoring) return;
|
||||
try {
|
||||
const saved = await ContinuousBackupSettings.findOneAsync('settings');
|
||||
const settings = await savedSettings();
|
||||
if (!settings.enabled) {
|
||||
if (backupManager().status().running) await backupManager().stop();
|
||||
if (leading) await releaseLease(leaseCollection(), serverId);
|
||||
leading = false; appliedStamp = null;
|
||||
return;
|
||||
}
|
||||
leading = await acquireLease(leaseCollection(), serverId);
|
||||
if (!leading) {
|
||||
// someone else streams: stop ours, if it was running, so two never write one target
|
||||
if (backupManager().status().running) await backupManager().stop();
|
||||
appliedStamp = null;
|
||||
return;
|
||||
}
|
||||
const stamp = stampOf(saved || {});
|
||||
// start the stream, or follow a settings change another server saved
|
||||
if (!backupManager().status().running || stamp !== appliedStamp) {
|
||||
await backupManager().apply(settings);
|
||||
appliedStamp = stamp;
|
||||
}
|
||||
} catch (error) { reportProblem('lease', error); }
|
||||
}
|
||||
|
||||
function startLeaseLoop() {
|
||||
if (leaseTimer) return;
|
||||
leaseTimer = Meteor.setInterval(() => { evaluateLease(); }, RENEW_MS);
|
||||
}
|
||||
|
||||
// A restore, and a fetch from the cloud, must be the only writer into the target: take the lease
|
||||
// from whoever has it and wait one renewal, so the former leader has found out and stopped.
|
||||
async function becomeOnlyWriter() {
|
||||
const wasLeader = leading;
|
||||
const { waitMs } = await takeoverLease(leaseCollection(), serverId);
|
||||
leading = true;
|
||||
if (!wasLeader) await new Promise(resolve => Meteor.setTimeout(resolve, waitMs));
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
async 'continuousBackup.getSettings'() {
|
||||
await requireSiteAdmin(this.userId);
|
||||
@@ -104,12 +162,14 @@ Meteor.methods({
|
||||
}
|
||||
}
|
||||
await ContinuousBackupSettings.upsertAsync('settings', { $set: { ...settings, updatedAt: new Date(), updatedBy: this.userId } });
|
||||
await backupManager().apply(settings);
|
||||
return backupManager().status();
|
||||
// Not apply(): this server may not be the leader. It converges through the lease like every
|
||||
// other server; the leader follows the change on its next tick.
|
||||
await evaluateLease();
|
||||
return { ...backupManager().status(), leading };
|
||||
},
|
||||
async 'continuousBackup.status'() {
|
||||
await requireSiteAdmin(this.userId);
|
||||
return { ...backupManager().status(), restoring };
|
||||
return { ...backupManager().status(), restoring, leading };
|
||||
},
|
||||
// A lost or empty target filled again from the cloud copy, so its restore
|
||||
// points can be restored. Files already here are kept.
|
||||
@@ -121,13 +181,15 @@ Meteor.methods({
|
||||
const remote = cloudRemote(settings.upload);
|
||||
if (!remote) throw new Meteor.Error('continuous-backup-no-upload', `The ${settings.upload} storage is not configured`);
|
||||
restoring = true;
|
||||
await backupManager().stop();
|
||||
try {
|
||||
await becomeOnlyWriter();
|
||||
await backupManager().stop();
|
||||
fs.mkdirSync(settings.target, { recursive: true, mode: 0o700 });
|
||||
return await fetchFromCloud({ target: settings.target, remote, prefix: settings.uploadPrefix });
|
||||
} finally {
|
||||
restoring = false;
|
||||
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
|
||||
appliedStamp = null;
|
||||
if (settings.enabled) await evaluateLease();
|
||||
}
|
||||
},
|
||||
async 'continuousBackup.restorePoints'() {
|
||||
@@ -164,6 +226,7 @@ Meteor.methods({
|
||||
// The stream pauses while the restore reads its target, and resumes
|
||||
// from its saved position after it: the restore's writes are changes
|
||||
// like any other, and are recorded then.
|
||||
await becomeOnlyWriter();
|
||||
await backupManager().stop();
|
||||
try {
|
||||
if (request.what === 'database') {
|
||||
@@ -175,7 +238,9 @@ Meteor.methods({
|
||||
.map(source => [`${source.area}\0${source.key}`, { path: source.path, file: !!source.file }]));
|
||||
return await restoreFiles({ target, name: request.generation, until: request.until, roots, mode: request.mode, cipher });
|
||||
} finally {
|
||||
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
|
||||
appliedStamp = null;
|
||||
restoring = false;
|
||||
if (settings.enabled) await evaluateLease();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'continuous-backup-restore-refused' || error.code === 'continuous-backup-key') {
|
||||
@@ -189,7 +254,7 @@ Meteor.methods({
|
||||
|
||||
Meteor.startup(async () => {
|
||||
try {
|
||||
const settings = await savedSettings();
|
||||
if (settings.enabled) await backupManager().apply(settings);
|
||||
await evaluateLease();
|
||||
startLeaseLoop();
|
||||
} catch (error) { reportProblem('startup', error); }
|
||||
});
|
||||
@@ -251,6 +251,8 @@ import '/server/lib/utils';
|
||||
// ----------------------------------------------------------------------------
|
||||
// 10. Server — methods
|
||||
// ----------------------------------------------------------------------------
|
||||
// #6870: the card's attachment "+" asks this whether uploads are blocked.
|
||||
import '/server/methods/attachmentUploadPolicy';
|
||||
import '/server/methods/backup';
|
||||
// Admin Panel / Attachments / Continuous backup (docs/Backup/Continuous-Backup.md).
|
||||
import '/server/continuousBackup';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
|
||||
const { uploadPolicy } = require('/models/lib/attachmentUploadPolicy');
|
||||
|
||||
// #6870: tells a signed-in client whether uploads are blocked site-wide, so a card does not offer a
|
||||
// "+" button and a drop zone for an upload the server will refuse. The refusal itself is enforced
|
||||
// on the server whatever the client does (models/attachments.server.js, server/attachmentApi.js);
|
||||
// this only decides what is shown. Only the boolean is returned.
|
||||
Meteor.methods({
|
||||
async attachmentUploadPolicy() {
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
let limitSettings;
|
||||
try {
|
||||
limitSettings = (await AttachmentStorageSettings.findOneAsync({}))?.limitSettings;
|
||||
} catch (error) {
|
||||
// an unreadable setting leaves uploads as they were, as the server's own check does
|
||||
limitSettings = undefined;
|
||||
}
|
||||
return uploadPolicy(limitSettings);
|
||||
},
|
||||
});
|
||||
@@ -110,11 +110,29 @@ WebApp.handlers.post('/api/boards/:boardId/integrations', async function(req, re
|
||||
}
|
||||
});
|
||||
|
||||
// An integration that is not on THIS board (a wrong id, or the id of another board's
|
||||
// integration) answers 404, as GET does (#6862). The write routes used to answer 200 -
|
||||
// PUT with the requested id, the activities routes with `data: null` - for an update
|
||||
// that changed nothing, so a client could not tell it from a success. Checked after
|
||||
// board-admin authorization, so it tells nobody without that right anything.
|
||||
async function integrationOr404(res, boardId, intId) {
|
||||
const integration = await ReactiveCache.getIntegration(
|
||||
{ _id: intId, boardId },
|
||||
{ fields: { _id: 1 } },
|
||||
);
|
||||
if (!integration) {
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Integration not found' } });
|
||||
return null;
|
||||
}
|
||||
return integration;
|
||||
}
|
||||
|
||||
WebApp.handlers.put('/api/boards/:boardId/integrations/:intId', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramIntId = req.params.intId;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
|
||||
|
||||
// #3695: how this webhook delivers - { webhook: { text, fields, grouping,
|
||||
// schedule, ... } }, or null to inherit the board / Admin Panel default
|
||||
@@ -201,6 +219,7 @@ WebApp.handlers.delete(
|
||||
const paramIntId = req.params.intId;
|
||||
const newActivities = req.body.activities;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
|
||||
|
||||
await Integrations.direct.updateAsync(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
@@ -228,6 +247,7 @@ WebApp.handlers.post(
|
||||
const paramIntId = req.params.intId;
|
||||
const newActivities = req.body.activities;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
|
||||
|
||||
await Integrations.direct.updateAsync(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
|
||||
@@ -397,6 +397,10 @@ WebApp.handlers.put('/api/admin/orgs/:orgId/features', async function(req, res)
|
||||
await Org.updateAsync(orgId, { $set });
|
||||
}
|
||||
const updated = await Org.findOneAsync(orgId);
|
||||
if (!updated) {
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Organization not found' } });
|
||||
return;
|
||||
}
|
||||
sendJsonResult(res, { code: 200, data: updated });
|
||||
} catch (error) {
|
||||
sendJsonResult(res, publicErrorData(error));
|
||||
|
||||
@@ -360,6 +360,10 @@ WebApp.handlers.put('/api/admin/teams/:teamId/features', async function(req, res
|
||||
await Team.updateAsync(teamId, { $set });
|
||||
}
|
||||
const updated = await Team.findOneAsync(teamId);
|
||||
if (!updated) {
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Team not found' } });
|
||||
return;
|
||||
}
|
||||
sendJsonResult(res, { code: 200, data: updated });
|
||||
} catch (error) {
|
||||
sendJsonResult(res, publicErrorData(error));
|
||||
|
||||
+22
-20
@@ -2714,28 +2714,30 @@ WebApp.handlers.put('/api/users/:userId', async function(req, res) {
|
||||
const id = req.params.userId;
|
||||
const action = req.body.action;
|
||||
let data = await ReactiveCache.getUser({ _id: id });
|
||||
if (data !== undefined) {
|
||||
if (action === 'takeOwnership') {
|
||||
const boards = await ReactiveCache.getBoards(
|
||||
{ 'members.userId': id, 'members.isAdmin': true },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
data = [];
|
||||
for (const board of boards) {
|
||||
if (board.hasMember(req.userId)) {
|
||||
await board.removeMember(req.userId);
|
||||
}
|
||||
board.changeOwnership(id, req.userId);
|
||||
data.push({ _id: board._id, title: board.title });
|
||||
if (!data) {
|
||||
sendJsonResult(res, { code: 404, data: { error: 'User not found' } });
|
||||
return;
|
||||
}
|
||||
if (action === 'takeOwnership') {
|
||||
const boards = await ReactiveCache.getBoards(
|
||||
{ 'members.userId': id, 'members.isAdmin': true },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
data = [];
|
||||
for (const board of boards) {
|
||||
if (board.hasMember(req.userId)) {
|
||||
await board.removeMember(req.userId);
|
||||
}
|
||||
} else {
|
||||
if (action === 'disableLogin' && id !== req.userId) {
|
||||
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: true, 'services.resume.loginTokens': [] } });
|
||||
} else if (action === 'enableLogin') {
|
||||
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: false } });
|
||||
}
|
||||
data = withoutSecrets(await ReactiveCache.getUser(id));
|
||||
board.changeOwnership(id, req.userId);
|
||||
data.push({ _id: board._id, title: board.title });
|
||||
}
|
||||
} else {
|
||||
if (action === 'disableLogin' && id !== req.userId) {
|
||||
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: true, 'services.resume.loginTokens': [] } });
|
||||
} else if (action === 'enableLogin') {
|
||||
await Users.updateAsync({ _id: id }, { $set: { loginDisabled: false } });
|
||||
}
|
||||
data = withoutSecrets(await ReactiveCache.getUser(id));
|
||||
}
|
||||
sendJsonResult(res, { code: 200, data });
|
||||
} catch (error) {
|
||||
|
||||
+4
-4
@@ -19,7 +19,7 @@
|
||||
# base: core24 (not core26) · no build-base: devel · grade: stable
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: wekan
|
||||
version: '12.93'
|
||||
version: '12.97'
|
||||
base: core24
|
||||
confinement: strict
|
||||
grade: stable
|
||||
@@ -703,18 +703,18 @@ parts:
|
||||
armhf) WEKAN_ARCH="armhf" ;;
|
||||
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
|
||||
esac
|
||||
WEKAN_ZIP="wekan-12.93-${WEKAN_ARCH}.zip"
|
||||
WEKAN_ZIP="wekan-12.97-${WEKAN_ARCH}.zip"
|
||||
echo "Cleaning environment first"
|
||||
rm -rf .build
|
||||
mkdir .build
|
||||
cd .build
|
||||
#wget https://github.com/wekan/wekan/releases/download/v12.93/wekan-12.93-amd64.zip
|
||||
#wget https://github.com/wekan/wekan/releases/download/v12.97/wekan-12.97-amd64.zip
|
||||
# Retry: a release asset can 404 for a short while right after it is
|
||||
# uploaded (CDN/propagation lag), and a plain wget treated that as
|
||||
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
|
||||
# missing bundle still fails after the tries are spent.
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
|
||||
"https://github.com/wekan/wekan/releases/download/v12.93/${WEKAN_ZIP}"
|
||||
"https://github.com/wekan/wekan/releases/download/v12.97/${WEKAN_ZIP}"
|
||||
unzip "${WEKAN_ZIP}"
|
||||
python3 ../releases/check-telemetry.py --bundle bundle
|
||||
rm "${WEKAN_ZIP}"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'use strict';
|
||||
// build.sh and releases/*.sh on Arch Linux and Omarchy (https://omarchy.org),
|
||||
// the Arch-based Hyprland desktop: pacman without partial upgrades, Arch
|
||||
// package names, snapd from the AUR, the sshd unit, Playwright without the
|
||||
// apt-only --with-deps. Everything runs offline: sudo, yay and pacman are
|
||||
// shell functions that print what they would have run.
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const TMP = path.join(ROOT, '.tools', 'tmp');
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
function run(body, env = {}) {
|
||||
const shell = `
|
||||
. releases/ensure-tools.sh
|
||||
_et_os() { echo linux; }
|
||||
_et_linux_family() { echo "\${TEST_FAMILY:-arch}"; }
|
||||
_et_have() { [[ "|\${TEST_HAVE:-pacman}|" == *"|$1|"* ]]; }
|
||||
sudo() { echo "SUDO $*"; [ -z "\${TEST_SUDO_FAIL:-}" ]; }
|
||||
yay() { echo "YAY $*"; }
|
||||
paru() { echo "PARU $*"; }
|
||||
${body}
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', shell], {
|
||||
cwd: ROOT, encoding: 'utf8',
|
||||
env: { ...process.env, TMPDIR: TMP, ...env },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function withSyncDir(hasDb, fn) {
|
||||
const dir = fs.mkdtempSync(path.join(TMP, 'pacman-sync-'));
|
||||
if (hasDb) fs.writeFileSync(path.join(dir, 'core.db'), '');
|
||||
try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
console.log('archOmarchyScripts:');
|
||||
|
||||
test('pacman installs with -S --needed, never a -Sy partial upgrade', () => {
|
||||
withSyncDir(true, (dir) => {
|
||||
const r = run('ensure_tools gh 7zip awk python3', { WEKAN_PACMAN_SYNC_DIR: dir });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
for (const pkg of ['github-cli', '7zip', 'gawk', 'python']) {
|
||||
assert.match(r.stdout, new RegExp(`SUDO pacman -S --needed --noconfirm ${pkg}\\n`), r.stdout);
|
||||
}
|
||||
assert.doesNotMatch(r.stdout, /pacman -Sy /);
|
||||
assert.doesNotMatch(r.stdout, /p7zip/, 'Arch replaced p7zip with 7zip');
|
||||
});
|
||||
});
|
||||
|
||||
test('a never-synced pacman (fresh container) does a full -Syu, not -Sy', () => {
|
||||
withSyncDir(false, (dir) => {
|
||||
const r = run('_et_pacman jq', { WEKAN_PACMAN_SYNC_DIR: dir });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /SUDO pacman -Syu --needed --noconfirm jq\n/);
|
||||
});
|
||||
});
|
||||
|
||||
test('WEKAN_PACMAN_SYU=1 opts in to the full upgrade', () => {
|
||||
withSyncDir(true, (dir) => {
|
||||
const r = run('_et_pacman jq', { WEKAN_PACMAN_SYNC_DIR: dir, WEKAN_PACMAN_SYU: '1' });
|
||||
assert.match(r.stdout, /SUDO pacman -Syu --needed --noconfirm jq\n/);
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed install fails, and names omarchy-update on Omarchy', () => {
|
||||
withSyncDir(true, (dir) => {
|
||||
const r = run('_et_pacman jq', {
|
||||
WEKAN_PACMAN_SYNC_DIR: dir, TEST_SUDO_FAIL: '1', TEST_HAVE: 'pacman|omarchy-update',
|
||||
});
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stderr, /omarchy-update/);
|
||||
assert.doesNotMatch(r.stdout, /pacman -Sy /, 'no automatic partial upgrade as a retry');
|
||||
});
|
||||
withSyncDir(true, (dir) => {
|
||||
const r = run('_et_pacman jq', {
|
||||
WEKAN_PACMAN_SYNC_DIR: dir, TEST_SUDO_FAIL: '1', HOME: path.join(TMP, 'no-such-home'),
|
||||
});
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stderr, /sudo pacman -Syu/);
|
||||
assert.doesNotMatch(r.stderr, /omarchy-update/);
|
||||
});
|
||||
});
|
||||
|
||||
test('snapcraft on Arch/Omarchy: snapd from the AUR with yay, then the snap', () => {
|
||||
const r = run('ensure_tools snapcraft', { TEST_HAVE: 'pacman|yay' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /YAY -S --needed --noconfirm snapd\n/);
|
||||
assert.match(r.stdout, /SUDO systemctl enable --now snapd.socket\n/);
|
||||
assert.match(r.stdout, /SUDO snap install snapcraft --classic\n/);
|
||||
assert.doesNotMatch(r.stdout, /SUDO yay|SUDO paru/, 'AUR helpers must not run as root');
|
||||
});
|
||||
|
||||
test('snapcraft without an AUR helper fails with instructions instead of guessing', () => {
|
||||
const r = run('ensure_tools snapcraft');
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stderr, /aur\.archlinux\.org/);
|
||||
assert.doesNotMatch(r.stdout, /snap install/);
|
||||
});
|
||||
|
||||
test('the SSH server unit is sshd on Arch and ssh on Debian', () => {
|
||||
assert.equal(run('_et_ssh_unit').stdout.trim(), 'sshd');
|
||||
assert.equal(run('_et_ssh_unit', { TEST_FAMILY: 'debian' }).stdout.trim(), 'ssh');
|
||||
});
|
||||
|
||||
test('Playwright system libraries come from pacman on Arch', () => {
|
||||
withSyncDir(true, (dir) => {
|
||||
const r = run('ensure_playwright_system_deps', { WEKAN_PACMAN_SYNC_DIR: dir });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /SUDO pacman -S --needed --noconfirm nss .*gtk3/);
|
||||
});
|
||||
});
|
||||
|
||||
test('the build toolchain and archive tools use 7zip and no partial upgrade', () => {
|
||||
withSyncDir(true, (dir) => {
|
||||
const r = run('ensure_build_toolchain; ensure_archive_tools', { WEKAN_PACMAN_SYNC_DIR: dir });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /pacman -S --needed --noconfirm base-devel python curl wget 7zip zip unzip\n/);
|
||||
assert.doesNotMatch(r.stdout, /p7zip|pacman -Sy /);
|
||||
});
|
||||
});
|
||||
|
||||
test('build.sh: Arch install, SSH menu and Playwright are distribution-aware', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'build.sh'), 'utf8');
|
||||
assert.match(src, /_et_pacman base-devel git curl wget 7zip zip unzip file npm/);
|
||||
assert.match(src, /systemctl enable \\"\\\$\(_et_ssh_unit\)\\"/);
|
||||
assert.doesNotMatch(src, /systemctl (enable|start|disable|stop) ssh\b/);
|
||||
// --with-deps is apt-only: it must be behind the Debian check.
|
||||
const withDeps = src.indexOf('npx playwright install --with-deps');
|
||||
const guard = src.lastIndexOf('"$(_et_linux_family)" != debian', withDeps);
|
||||
assert.ok(withDeps > 0 && guard > 0 && withDeps - guard < 800, 'with-deps is guarded');
|
||||
assert.match(src, /Arch\/Omarchy/);
|
||||
});
|
||||
|
||||
// Negative test: the fault cannot live anywhere else. No tracked shell script
|
||||
// does a `pacman -Sy <package>` partial upgrade (-Syu is the supported form),
|
||||
// and none installs Arch's retired p7zip package.
|
||||
test('no shell script anywhere does a pacman -Sy partial upgrade or installs p7zip on Arch', () => {
|
||||
const files = spawnSync('git', ['ls-files', '*.sh'], { cwd: ROOT, encoding: 'utf8' })
|
||||
.stdout.split('\n').filter(Boolean);
|
||||
assert.ok(files.length > 50);
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(path.join(ROOT, file), 'utf8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s*#/.test(line)) return;
|
||||
if (/pacman\s+-Sy(?!u)\w*\s/.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
|
||||
if (/pacman\b.*\bp7zip\b/.test(line) || /_et_pacman\b.*\bp7zip\b/.test(line)) {
|
||||
offenders.push(`${file}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
// #6870: attachments off for the whole installation. The switch exists already - Admin Panel /
|
||||
// Attachments / Transfer limits, "Attachments upload" and "API upload" set to Blocked - and the
|
||||
// server refuses on every path. What the card lacked was to STOP OFFERING the upload. This pins
|
||||
// the client side, and the server enforcement it relies on, so the two cannot drift apart.
|
||||
//
|
||||
// Run: node tests/attachmentUploadPolicy.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { uploadPolicy } = require('../models/lib/attachmentUploadPolicy');
|
||||
|
||||
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
|
||||
test('only a real true blocks uploads', () => {
|
||||
assert.deepEqual(uploadPolicy({ attachmentsUploadBlocked: true }), { uploadsBlocked: true });
|
||||
for (const v of [undefined, null, {}, [], 'x', { attachmentsUploadBlocked: false },
|
||||
{ attachmentsUploadBlocked: 'true' }, { attachmentsUploadBlocked: 1 }, { apiUploadBlocked: true }]) {
|
||||
assert.deepEqual(uploadPolicy(v), { uploadsBlocked: false }, JSON.stringify(v));
|
||||
}
|
||||
});
|
||||
|
||||
test('negative: the answer carries only the boolean, nothing about limits or storage', () => {
|
||||
const answer = uploadPolicy({ attachmentsUploadBlocked: true, attachmentsUploadMaxBytes: 5, apiUploadMaxBytes: 7, secret: 'x' });
|
||||
assert.deepEqual(Object.keys(answer), ['uploadsBlocked']);
|
||||
});
|
||||
|
||||
test('the method answers signed-in users only, and falls back to "not blocked" on a read error', () => {
|
||||
const src = read('server/methods/attachmentUploadPolicy.js');
|
||||
assert.match(src, /async attachmentUploadPolicy\(\) \{\s*if \(!this\.userId\) throw new Meteor\.Error\('error-notAuthorized'\);/);
|
||||
assert.match(src, /catch \(error\) \{[^}]*limitSettings = undefined;/);
|
||||
});
|
||||
|
||||
test('the card offers the "+" only when uploads are not blocked', () => {
|
||||
const jade = read('client/components/cards/attachments.jade');
|
||||
assert.match(jade, /if canAddAttachment\s*\n\s*a\.card-label\.add-label\.add-attachment\.js-add-attachment/);
|
||||
assert.doesNotMatch(jade, /if canModifyCard\s*\n\s*a\.card-label\.add-label\.add-attachment/, 'the old condition is gone');
|
||||
const js = read('client/components/cards/attachments.js');
|
||||
assert.match(js, /canAddAttachment\(\) \{\s*return Utils\.canModifyCard\(\) && !attachmentUploadsBlocked\(\);/);
|
||||
// and a drop or paste does nothing either
|
||||
assert.match(js, /if \(attachmentUploadsBlocked\(\)\) return \[\];\s*\n\s*\/\/ Check if board allows attachments/);
|
||||
});
|
||||
|
||||
test('the client asks once a minute at most and shows uploads until the server answers', () => {
|
||||
const src = read('client/lib/attachmentUploadPolicy.js');
|
||||
assert.match(src, /new ReactiveVar\(false\)/);
|
||||
assert.match(src, /Date\.now\(\) - askedAt < 60 \* 1000/);
|
||||
assert.match(src, /catch\(\(\) => \{ askedAt = 0; \}\)/);
|
||||
});
|
||||
|
||||
test('the server refuses on every upload path whatever the client shows', () => {
|
||||
const pipeline = read('models/attachments.server.js');
|
||||
assert.match(pipeline, /uploadsBlocked = settings\?\.limitSettings\?\.attachmentsUploadBlocked === true/);
|
||||
const methods = read('server/attachmentApi.js');
|
||||
const routes = read('server/routes/attachmentApi.js');
|
||||
// api.attachment.upload and api.attachment.copy, the HTTP upload, upload-background and copy
|
||||
assert.equal((methods.match(/if \(apiUploadBlocked\)/g) || []).length >= 2, true, 'upload and copy methods');
|
||||
assert.equal((routes.match(/if \(apiUploadBlocked\)/g) || []).length >= 3, true, 'upload, background and copy routes');
|
||||
});
|
||||
|
||||
test('negative: there is no second "disable attachments" setting to disagree with the server', () => {
|
||||
for (const f of ['models/settings.js', 'server/publications/settings.js', 'client/components/settings/settingBody.jade']) {
|
||||
assert.doesNotMatch(read(f), /disableAttachments/, f);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { test } = require('node:test');
|
||||
const exportFields = require('../models/lib/exportFields');
|
||||
|
||||
function fixture({ board, disabled = false, lookupError } = {}) {
|
||||
let handler;
|
||||
const calls = [];
|
||||
class Exporter {
|
||||
constructor() { calls.push('exporter'); }
|
||||
async build(res) { calls.push('build'); res.end('workbook'); }
|
||||
}
|
||||
const context = {
|
||||
runOnServer: fn => fn(),
|
||||
ReactiveCache: { async getBoard(id) {
|
||||
calls.push(['board', id]);
|
||||
if (lookupError) throw lookupError;
|
||||
return board;
|
||||
} },
|
||||
Meteor: { settings: { public: {} } },
|
||||
require(name) {
|
||||
switch (name) {
|
||||
case './server/ExporterExcel': return { ExporterExcel: Exporter };
|
||||
case './server/ExporterExcelBoard': return { ExporterExcelBoard: Exporter };
|
||||
case '/models/lib/exportFields': return exportFields;
|
||||
case 'meteor/webapp': return { WebApp: { handlers: { get(url, fn) {
|
||||
assert.equal(url, '/api/boards/:boardId/exportExcel');
|
||||
handler = fn;
|
||||
} } } };
|
||||
case '/server/apiMiddleware': return { safeRoute: fn => fn };
|
||||
case '/server/authentication': return { Authentication: { async checkUserId() {
|
||||
calls.push('authenticate');
|
||||
throw Object.assign(new Error('Unauthorized'), { statusCode: 403 });
|
||||
} } };
|
||||
case '/models/lib/importExportSecurity': return { async refuseDisabledExport(res) {
|
||||
if (!disabled) return false;
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Export disabled');
|
||||
return true;
|
||||
} };
|
||||
default: throw new Error(`Unexpected module: ${name}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
const source = fs.readFileSync(path.join(__dirname, '../models/exportExcel.js'), 'utf8');
|
||||
vm.runInNewContext(source.replace(/^import .*;\r?\n/gm, ''), context);
|
||||
assert.equal(typeof handler, 'function');
|
||||
return {
|
||||
calls,
|
||||
async get(query = {}) {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
ends: 0,
|
||||
writeHead(status, headers) { this.statusCode = status; this.headers = headers; },
|
||||
end(body) { this.body = body; this.ends += 1; },
|
||||
};
|
||||
await handler({ params: { boardId: 'board' }, query }, res);
|
||||
assert.equal(res.ends, 1, 'respond exactly once');
|
||||
return res;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const query of [{}, { authToken: 'token' }]) {
|
||||
test(`a missing board returns plain-text 404 ${query.authToken ? 'with a token' : 'anonymously'}`, async () => {
|
||||
const f = fixture();
|
||||
const res = await f.get(query);
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.equal(res.headers['Content-Type'], 'text/plain; charset=utf-8');
|
||||
assert.equal(res.body, 'Board not found');
|
||||
assert.deepEqual(f.calls, [['board', 'board']]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const query of [{}, { fields: 'description' }]) {
|
||||
test(`an existing public board still builds ${query.fields ? 'the table' : 'the card layout'}`, async () => {
|
||||
const f = fixture({ board: { isPublic: () => true } });
|
||||
const res = await f.get(query);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body, 'workbook');
|
||||
assert.deepEqual(f.calls, [['board', 'board'], 'exporter', 'build']);
|
||||
});
|
||||
}
|
||||
|
||||
test('an existing private board still requires authentication', async () => {
|
||||
const f = fixture({ board: { isPublic: () => false } });
|
||||
const res = await f.get();
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.equal(res.body, 'Unauthorized');
|
||||
assert.deepEqual(f.calls, [['board', 'board'], 'authenticate']);
|
||||
});
|
||||
|
||||
test('disabled exports are refused before looking up a board', async () => {
|
||||
const f = fixture({ disabled: true });
|
||||
assert.equal((await f.get()).statusCode, 403);
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('a database failure is not misreported as a missing board', async () => {
|
||||
const error = new Error('database unavailable');
|
||||
await assert.rejects(() => fixture({ lookupError: error }).get(), error);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
// The board-open profile (docs/Databases/FerretDB/1/SpeedTest/app): the part that can be
|
||||
// checked without a browser and a database - the CPU profile of the Meteor server is
|
||||
// split by who spends it, and the README states what was measured and what was not.
|
||||
//
|
||||
// Run: node tests/boardOpenProfile.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { categorise, analyse } = require('../docs/Databases/FerretDB/1/SpeedTest/app/profileAnalysis.cjs');
|
||||
|
||||
const DIR = path.join(__dirname, '..', 'docs/Databases/FerretDB/1/SpeedTest/app');
|
||||
const node = (id, functionName, url = '') => ({ id, callFrame: { functionName, url } });
|
||||
|
||||
test('time is split by who spends it', () => {
|
||||
const cases = [
|
||||
['(idle)', '', 'idle'],
|
||||
['(garbage collector)', '', 'garbage collector'],
|
||||
['(program)', '', 'native (program)'],
|
||||
['deserializeObject', 'file:///x/npm-mongo/node_modules/mongodb/lib/bson.cjs', 'MongoDB driver (wire, decode, encode)'],
|
||||
['publish', 'file:///x/programs/server/packages/ddp-server.js', 'Meteor DDP (publish, mergebox, sessions)'],
|
||||
['replaceTypes', 'file:///x/programs/server/packages/mongo.js', 'Meteor mongo (observe, polling, diff)'],
|
||||
['stringify', 'file:///x/programs/server/packages/ejson.js', 'EJSON'],
|
||||
['CollectionDocument', 'file:///x/_build/main-dev/server-rspack.cjs', 'WeKan code'],
|
||||
['writeBuffer', 'node:internal/stream_base_commons', 'node internals'],
|
||||
['something', 'file:///x/programs/server/packages/random.js', 'other packages'],
|
||||
];
|
||||
for (const [fn, url, want] of cases) assert.equal(categorise(node(1, fn, url)), want, `${fn} ${url}`);
|
||||
// negative: a dependency under node_modules, and a Meteor package whose path has /server/ in it,
|
||||
// are never "WeKan code", even when the path mentions wekan
|
||||
assert.notEqual(categorise(node(1, 'f', 'file:///x/wekan/node_modules/lodash/lodash.js')), 'WeKan code');
|
||||
assert.equal(categorise(node(1, 'f', 'file:///x/wekan/.meteor/local/build/programs/server/packages/check.js')), 'other packages');
|
||||
});
|
||||
|
||||
test('analyse adds up the samples, leaves the idle time out of "busy", and orders by cost', () => {
|
||||
const nodes = [node(1, '(idle)'), node(2, 'deserializeObject', 'file:///x/mongodb/lib/bson.cjs'),
|
||||
node(3, 'CollectionDocument', 'file:///x/_build/server.cjs'), node(4, '(garbage collector)')];
|
||||
// timeDeltas are in microseconds; sample i is charged the delta after it
|
||||
const profile = { nodes, samples: [1, 2, 2, 3, 4, 1], timeDeltas: [0, 4000, 6000, 3000, 2000, 5000, 0] };
|
||||
const a = analyse(profile);
|
||||
assert.equal(Math.round(a.totalMs), 20);
|
||||
assert.equal(Math.round(a.busyMs), 16, '4 of the 20 ms are idle');
|
||||
assert.deepEqual(a.categories.map(([c]) => c), ['MongoDB driver (wire, decode, encode)', 'garbage collector', 'WeKan code']);
|
||||
assert.deepEqual(a.categories.map(([, ms]) => Math.round(ms)), [9, 5, 2]);
|
||||
assert.ok(a.categories.every(([, ms]) => ms > 0));
|
||||
assert.equal(a.topFunctions[0][0].split(' ')[0], 'deserializeObject');
|
||||
// negative: an empty profile is nothing, not NaN
|
||||
const empty = analyse({ nodes: [], samples: [], timeDeltas: [] });
|
||||
assert.equal(empty.busyMs, 0);
|
||||
assert.deepEqual(empty.categories, []);
|
||||
});
|
||||
|
||||
test('the script parses, never touches a database outside its own, and the README says what it measured', () => {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
execFileSync(process.execPath, ['--check', path.join(DIR, 'board-open.cjs')]);
|
||||
const script = fs.readFileSync(path.join(DIR, 'board-open.cjs'), 'utf8');
|
||||
// it seeds a user and a board of its own, in the database named by WEKAN_MONGO_URL
|
||||
assert.match(script, /db\.seedUser\(\)/);
|
||||
assert.doesNotMatch(script, /dropDatabase|deleteMany\(\{\}\)|drop\(\)/);
|
||||
const md = fs.readFileSync(path.join(DIR, 'README.md'), 'utf8');
|
||||
for (const needle of ['development build', 'same ~210 KB', 'run 1 / run 2', '400-600 ms', 'within the noise', 'not identified', 'does not separate']) {
|
||||
assert.ok(md.includes(needle), needle);
|
||||
}
|
||||
// the root README points to it
|
||||
assert.ok(fs.readFileSync(path.join(DIR, '..', 'README.md'), 'utf8').includes('[app/](app/)'));
|
||||
});
|
||||
@@ -111,6 +111,51 @@ test('the smoke test fails when WeKan never answers, and passes when it does (ru
|
||||
}
|
||||
});
|
||||
|
||||
// v12.96 openbsd-arm64: smoke.sh said OK and returned with WeKan and FerretDB
|
||||
// still running - start-wekan.sh defers its TERM trap until the foreground
|
||||
// node exits - and FerretDB removed wekan.sqlite-wal/-shm while the VM's
|
||||
// copyback rsync walked the workspace, failing the job with rsync exit 24.
|
||||
// This launcher has start-wekan.sh's shape: FerretDB in the background, WeKan
|
||||
// in the foreground, a TERM trap, a restart loop.
|
||||
test('the smoke test leaves nothing running and nothing on disk when it returns (runs here)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(ROOT, '.tools', 'tmp', 'bsd-smoke-'));
|
||||
const bundle = path.join(tmp, 'bundle');
|
||||
const tdir = path.join(tmp, 't');
|
||||
const alive = () => spawnSync('pgrep', ['-f', `${bundle}/(ferretdb|main\\.js)`]).status === 0;
|
||||
try {
|
||||
fs.mkdirSync(bundle); fs.mkdirSync(tdir);
|
||||
fs.writeFileSync(path.join(bundle, 'ferretdb'),
|
||||
"const fs=require('fs'),p=require('path');const d=p.join(process.env.WRITABLE_PATH,'files','db');fs.mkdirSync(d,{recursive:true});"
|
||||
+ "for(const s of ['','-wal','-shm'])fs.writeFileSync(p.join(d,'wekan.sqlite'+s),'x');"
|
||||
+ "process.on('SIGTERM',()=>setTimeout(()=>{for(const s of ['-wal','-shm'])fs.rmSync(p.join(d,'wekan.sqlite'+s),{force:true});process.exit(0)},500));"
|
||||
+ 'setInterval(()=>{},1000);\n');
|
||||
fs.writeFileSync(path.join(bundle, 'main.js'),
|
||||
"require('http').createServer((q,r)=>r.end('ok')).listen(process.env.PORT,'127.0.0.1');\n");
|
||||
fs.writeFileSync(path.join(bundle, 'start-wekan.sh'), [
|
||||
'#!/usr/bin/env bash',
|
||||
'DIR="$(cd "$(dirname "$0")" && pwd)"',
|
||||
'FERRET_PID=""',
|
||||
'stop_ferret() { [ -n "$FERRET_PID" ] && kill "$FERRET_PID" 2>/dev/null || true; }',
|
||||
"trap 'stop_ferret; exit 0' INT TERM",
|
||||
'while true; do',
|
||||
' "$NODE" "$DIR/ferretdb" &',
|
||||
' FERRET_PID=$!',
|
||||
' "$NODE" "$DIR/main.js" || true',
|
||||
' stop_ferret; FERRET_PID=""; sleep 1',
|
||||
'done', ''].join('\n'), { mode: 0o755 });
|
||||
const res = spawnSync('bash', [path.join(ROOT, 'releases/bsd/smoke.sh'), bundle, '3199'],
|
||||
{ encoding: 'utf8', env: { ...process.env, TMPDIR: tdir, NODE: process.execPath }, timeout: 120000 });
|
||||
assert.equal(res.status, 0, res.stdout + res.stderr);
|
||||
assert.match(res.stdout, /WeKan answered HTTP 200/);
|
||||
assert.equal(alive(), false, 'negative: no WeKan or FerretDB left running after smoke.sh returned');
|
||||
assert.deepEqual(fs.readdirSync(tdir).filter(n => n.startsWith('wekan-smoke.')), [],
|
||||
'negative: no smoke-test files left for a copyback to find');
|
||||
} finally {
|
||||
spawnSync('pkill', ['-9', '-f', `${bundle}/(ferretdb|main\\.js)`]);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the resolver answers "not built" for a BSD nobody publishes yet (negative), without failing', () => {
|
||||
const res = spawnSync('bash', [path.join(ROOT, 'releases/resolve-node-source.sh'), 'netbsd-x64', 'v26.11.1'], { encoding: 'utf8', timeout: 120000 });
|
||||
if (res.error || /Could not resolve host|curl: \(6\)/.test(res.stderr)) return; // offline: nothing to check
|
||||
|
||||
@@ -41,8 +41,9 @@ console.log('bundleNpmSecurityBumps:');
|
||||
|
||||
test('the manifest lists the packages the scan found, at fixed versions', () => {
|
||||
const expected = {
|
||||
'nodemailer': '8.0', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
|
||||
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16', 'body-parser': '1.20',
|
||||
'nodemailer': '10.3', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
|
||||
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16.1', 'body-parser': '1.20.9', 'body-parser@2': '2.4',
|
||||
'uuid': '11.1.1', 'postcss-selector-parser': '6.1.5',
|
||||
'cookie': '0.7', 'on-headers': '1.1', 'tmp': '0.2', 'diff': '3.5',
|
||||
'@babel/runtime': '7.', 'underscore': '1.13.8',
|
||||
// Artifact Hub's Trivy report of the v12.38 image: same-major fixes.
|
||||
@@ -57,25 +58,106 @@ test('the manifest lists the packages the scan found, at fixed versions', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('uuid is NOT bumped, and says why (negative)', () => {
|
||||
// The one that was tried and reverted. If somebody adds it back, the bundle
|
||||
// stops booting - so the reason lives where they would look.
|
||||
assert.ok(!('uuid' in manifest.minimums), 'uuid must not be in minimums');
|
||||
const reason = (manifest.notFixable.uuid || []).join(' ');
|
||||
assert.ok(/Cannot find module/.test(reason) && /dist\/cjs\/index\.js/.test(reason),
|
||||
'and notFixable carries the boot error it caused');
|
||||
test('uuid is bumped to 11.1.1 and the entry point the bundle recorded is written back', () => {
|
||||
// uuid 11 moved its entry from dist/index.js to dist/cjs/index.js, and a bundle
|
||||
// that recorded dist/index.js died on boot ("Cannot find module
|
||||
// .../uuid/dist/index.js"). The fix is a shim at the old path, not a refusal.
|
||||
assert.strictEqual(manifest.minimums.uuid, '11.1.1');
|
||||
const shim = (manifest.shims || {}).uuid || {};
|
||||
assert.strictEqual(shim['dist/index.js'], "export * from './esm/index.js';\n",
|
||||
'dist/index.js re-exports the ESM build (uuid is "type": "module"; a CJS shim would be parsed as ESM)');
|
||||
assert.ok(/manifest\.shims/.test(bumper) && /writeFileSync\(file, body\)/.test(bumper),
|
||||
'and the bumper writes it after every replacement');
|
||||
// negative: nothing is described as unfixable that was fixed
|
||||
for (const name of ['uuid', 'nodemailer']) {
|
||||
assert.ok(!(name in manifest.notFixable), `${name} must not stay in notFixable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a fix that needs a new major is recorded in notFixable, never in minimums (negative)', () => {
|
||||
for (const name of ['nodemailer', 'postcss-selector-parser', 'uuid']) {
|
||||
const reason = (manifest.notFixable[name] || []).join(' ');
|
||||
assert.ok(/major/.test(reason), `${name} says the fix is a new major`);
|
||||
test('nodemailer 10 gets back the lib/ files meteor/email was built against', () => {
|
||||
// v12.95's bundle failed its smoke boot: nodemailer 10 moved lib/ to dist/cjs/,
|
||||
// and packages/email.js links the paths it recorded for 8.0.3 - "Cannot find
|
||||
// module .../meteor/email/node_modules/nodemailer/lib/nodemailer.js" - and
|
||||
// Npm.requires nodemailer/lib/mail-composer, which Meteor resolves by path.
|
||||
const shim = (manifest.shims || {}).nodemailer || {};
|
||||
const os = require('os');
|
||||
const src = path.join(repoRoot, 'node_modules/nodemailer');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(src, 'package.json'), 'utf8'));
|
||||
assert.ok(Number(pkg.version.split('.')[0]) >= 10, `node_modules/nodemailer ${pkg.version} has the 10 layout`);
|
||||
// every lib/<name> directory nodemailer 10 still exports has its old file back,
|
||||
// so no path a nodemailer 8 caller could have recorded is left unresolvable
|
||||
const dirs = Object.entries(pkg.exports)
|
||||
.filter(([key, target]) => /^\.\/lib\/[a-z0-9-]+$/.test(key)
|
||||
&& target && target.require === `./dist/cjs/${key.slice(6)}/index.js`)
|
||||
.map(([key]) => `lib/${key.slice(6)}/index.js`);
|
||||
assert.ok(dirs.includes('lib/mail-composer/index.js') && dirs.includes('lib/well-known/index.js'));
|
||||
for (const rel of ['lib/package.json', 'lib/nodemailer.js', ...dirs]) {
|
||||
assert.ok(rel in shim, `nodemailer shim for ${rel}`);
|
||||
}
|
||||
// nodemailer stays at the newest 8.x; the 9.x/10.x fixes are named, not taken.
|
||||
assert.ok(manifest.minimums.nodemailer.startsWith('8.'));
|
||||
assert.ok(!('postcss-selector-parser' in manifest.minimums));
|
||||
// lodash.template has had a fixed release (4.18.x) since 2026-04; it is no
|
||||
// longer described as unfixable.
|
||||
const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'nodemailer-shim-'));
|
||||
try {
|
||||
const shimmed = path.join(tmp, 'with', 'nodemailer');
|
||||
const bare = path.join(tmp, 'without', 'nodemailer');
|
||||
for (const [dir, files] of [[shimmed, Object.keys(shim)], [bare, ['lib/nodemailer.js']]]) {
|
||||
fs.cpSync(src, dir, { recursive: true });
|
||||
for (const rel of files) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), shim[rel]);
|
||||
}
|
||||
}
|
||||
// the two paths the bundle recorded, required by file as Meteor's runtime does
|
||||
assert.strictEqual(typeof require(path.join(shimmed, 'lib/nodemailer.js')).createTransport, 'function');
|
||||
assert.strictEqual(typeof require(path.join(shimmed, 'lib/well-known/index.js')), 'function');
|
||||
assert.strictEqual(typeof require(path.join(shimmed, 'lib/mail-composer')), 'function');
|
||||
// and each forward is the module the exports map gives a require() caller
|
||||
for (const rel of dirs) {
|
||||
assert.strictEqual(require(path.join(shimmed, rel)),
|
||||
require(path.join(shimmed, 'dist/cjs', rel.slice(4))), rel);
|
||||
}
|
||||
// negative: without lib/package.json the CommonJS shim is parsed as ESM
|
||||
// (nodemailer is "type": "module") and does not load
|
||||
assert.throws(() => require(path.join(bare, 'lib/nodemailer.js')));
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('body-parser 1.x and 2.x are each raised inside their own major', () => {
|
||||
assert.strictEqual(manifest.minimums['body-parser'], '1.20.9');
|
||||
assert.strictEqual(manifest.minimums['body-parser@2'], '2.4.0');
|
||||
const fs2 = require('fs'), os = require('os'), cp = require('child_process');
|
||||
const bundle = fs2.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'bump-test-'));
|
||||
const put = (dir, version) => {
|
||||
const d = path.join(bundle, 'programs/server/npm/node_modules', dir);
|
||||
fs2.mkdirSync(d, { recursive: true });
|
||||
fs2.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version }));
|
||||
};
|
||||
put('meteor/a/node_modules/body-parser', '1.20.6');
|
||||
put('meteor/b/node_modules/body-parser', '2.3.0');
|
||||
put('meteor/c/node_modules/body-parser', '2.4.0');
|
||||
put('meteor/d/node_modules/body-parser', '1.20.9');
|
||||
put('meteor/e/node_modules/uuid', '8.3.2');
|
||||
put('meteor/f/node_modules/uuid', '14.0.2');
|
||||
put('meteor/g/node_modules/nodemailer', '8.0.11');
|
||||
const out = cp.execFileSync(process.execPath,
|
||||
[path.join(repoRoot, 'releases/bump-bundle-npm-deps.mjs'), bundle, '--dry-run'], { encoding: 'utf8' });
|
||||
fs2.rmSync(bundle, { recursive: true, force: true });
|
||||
const lines = out.split('\n').filter(l => /->/.test(l));
|
||||
const has = re => lines.some(l => re.test(l));
|
||||
assert.ok(has(/body-parser 1\.20\.6 -> 1\.20\.9/), '1.x below 1.20.9 is raised to 1.20.9');
|
||||
assert.ok(has(/body-parser 2\.3\.0 -> 2\.4\.0/), '2.3.0 is raised to 2.4.0');
|
||||
// negative: neither crosses into the other major, and fixed copies are left alone
|
||||
assert.ok(!has(/body-parser 1\.\d+\.\d+ -> 2\./) && !has(/body-parser 2\.\d+\.\d+ -> 1\./));
|
||||
assert.ok(!has(/body-parser 2\.4\.0 ->/) && !has(/body-parser 1\.20\.9 ->/));
|
||||
assert.ok(has(/uuid 8\.3\.2 -> 11\.1\.1/) && !has(/uuid 14\./), 'uuid 14 is above the minimum');
|
||||
assert.ok(has(/nodemailer 8\.0\.11 -> 10\.3\.1/));
|
||||
});
|
||||
|
||||
test('a fix that needs a new major its dependents cannot take is recorded in notFixable (negative)', () => {
|
||||
const reason = (manifest.notFixable['postcss-selector-parser'] || []).join(' ');
|
||||
assert.ok(/major/.test(reason) && /CVE-2026-104844/.test(reason));
|
||||
assert.ok(manifest.minimums['postcss-selector-parser'] === '6.1.5',
|
||||
'but the same-major fix that exists is taken');
|
||||
assert.ok(!('lodash.template' in manifest.notFixable));
|
||||
for (const [name, version] of Object.entries(manifest.minimums)) {
|
||||
assert.ok(/^\d+\.\d+\.\d+$/.test(version), `${name} is pinned to an exact version`);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
|
||||
// A phone in desktop mode: the opened card starts below the (multi-row) header bar and a
|
||||
// dragged place is kept. Run: node tests/cardBelowHeaderOnPhone.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'client/components/cards/cardDetails.css'), 'utf8');
|
||||
const block = /@media screen and \(max-width: 800px\) \{\n body\.desktop-mode \.card-details:not\(\.card-details-popup\):not\(\[style\*="top"\]\),[^{]*\{([^}]*)\}/.exec(css);
|
||||
|
||||
test('below 801px the desktop-mode card starts at the measured header height', () => {
|
||||
assert.ok(block, 'the phone rule exists');
|
||||
assert.match(block[1], /top: calc\(var\(--wekan-header-height, 0px\) \+ 8px\) !important;/);
|
||||
});
|
||||
|
||||
test('negative: a card with an inline top (dragged) never matches it, so the drag sticks', () => {
|
||||
const selectors = css.slice(css.indexOf(block[0]), css.indexOf(block[1])).replace(/\/\*.*?\*\//g, '').split(',');
|
||||
assert.ok(selectors.every(s => /:not\(\[style\*="top"\]\)|card-details-maximized/.test(s)));
|
||||
// the variable is published by Utils, so the rule has a source
|
||||
assert.match(fs.readFileSync(path.join(__dirname, '..', 'client/lib/utils.js'), 'utf8'), /--wekan-header-height/);
|
||||
});
|
||||
|
||||
test('a card opened as a popup starts below the header too, not at the top of the window', () => {
|
||||
assert.ok(block[0].includes('.card-details.card-details-popup:not([style*="top"])'));
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
// The opened card covers the header bar on an iPad. iOS WebKit (iPad Safari and Chrome)
|
||||
// makes every scrolling element a stacking context, so the card's z-index is compared
|
||||
// only inside #content, and the header bar (#header-quick-access, z-index 1000, a flex
|
||||
// item of body) was painted over the card's top row. While a card is open the header
|
||||
// drops to the level of the content. tests/playwright/specs/card-over-header-ios-stacking
|
||||
// .e2e.js simulates the trap and shows it in a browser.
|
||||
//
|
||||
// Run: node tests/cardOverHeaderStacking.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const header = read('client/components/main/header.css');
|
||||
|
||||
test('while a card is open the header bar is at the level of the content', () => {
|
||||
assert.match(header, /body:has\(\.card-details\) #header-quick-access \{\n z-index: 0;\n\}/);
|
||||
// negative: it is conditional - the header keeps 1000 when no card is open
|
||||
assert.match(header, /\n#header-quick-access \{[^}]*z-index: 1000;/);
|
||||
assert.doesNotMatch(header.replace(/body:has\(\.card-details\) #header-quick-access \{\n z-index: 0;\n\}/, ''), /#header-quick-access \{[^}]*z-index: 0;/);
|
||||
});
|
||||
|
||||
test('it names the header that exists: #header-quick-access, not the old #header', () => {
|
||||
const rule = /body:has\(\.card-details\) (#[\w-]+)/.exec(header);
|
||||
assert.equal(rule[1], '#header-quick-access');
|
||||
assert.ok(read('client/components/main/header.jade').includes('#header-quick-access'), 'the template draws it');
|
||||
// the card's own level is above the header's normal one, so a browser without the trap needs nothing
|
||||
const card = read('client/components/cards/cardDetails.css');
|
||||
assert.match(card, /body\.desktop-mode \.card-details:not\(\.card-details-popup\) \{[^}]*z-index: 2001;/);
|
||||
});
|
||||
|
||||
test('the content is the later sibling that paints over the header at the same level', () => {
|
||||
// #content comes after the header in the page and is positioned, so at z-index 0 against 0
|
||||
// it is painted last, which is what puts the card over the header's second row as well.
|
||||
const layouts = read('client/components/main/layouts.css');
|
||||
assert.match(layouts, /\n#content \{\n position: relative;\n flex: 1;/);
|
||||
});
|
||||
@@ -462,4 +462,17 @@ test('CLAUDE.md states these rules, so they are not folklore', () => {
|
||||
assert.ok(/release summary → topic summary → commit\s+detail/.test(claude));
|
||||
});
|
||||
|
||||
test('CLAUDE.md and AGENTS.md keep changelogs and direct commits to maintainer mode', () => {
|
||||
for (const doc of ['CLAUDE.md', 'AGENTS.md']) {
|
||||
const s = read(doc);
|
||||
assert.ok(/Changelogs are updated only in maintainer mode/.test(s), doc);
|
||||
assert.ok(/only mode that\s+updates changelogs/.test(s), doc);
|
||||
assert.ok(/One feature or one fix per pull request/.test(s), doc);
|
||||
assert.ok(/\*\*No changelog changes\.\*\*/.test(s), doc);
|
||||
// "good practice for everyone" used to include the CHANGELOG structure, which
|
||||
// told a contributor to write the entries a pull request must now leave out.
|
||||
assert.ok(!/The rest \([^)]*CHANGELOG[^)]*\) is\s+good practice for everyone/.test(s), doc);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
// #6861, item 3: with several WeKan servers on one database exactly ONE streams the continuous
|
||||
// backup, and another takes over when it stops. Run: node tests/continuousBackupLease.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease } = require('../models/lib/backupLease');
|
||||
|
||||
// A collection with the atomic semantics the lease relies on: every call is ONE step, and an insert
|
||||
// of an existing _id is a duplicate key. (Each method runs to completion before the next starts, as
|
||||
// a database's single-document operations do.)
|
||||
function fakeCollection() {
|
||||
const docs = new Map();
|
||||
const matches = (doc, filter, now) => Object.entries(filter).every(([key, want]) => {
|
||||
if (key === '$or') return want.some(branch => matches(doc, branch, now));
|
||||
const have = doc[key];
|
||||
if (want && typeof want === 'object' && !(want instanceof Date)) {
|
||||
if ('$lte' in want) return have instanceof Date && have <= want.$lte;
|
||||
return false;
|
||||
}
|
||||
if (want === null) return have === null || have === undefined;
|
||||
return have === want;
|
||||
});
|
||||
return {
|
||||
docs,
|
||||
async insertOne(doc) {
|
||||
await Promise.resolve();
|
||||
if (docs.has(doc._id)) throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
|
||||
docs.set(doc._id, { ...doc });
|
||||
return { insertedId: doc._id };
|
||||
},
|
||||
async updateOne(filter, update) {
|
||||
await Promise.resolve();
|
||||
const doc = docs.get(filter._id);
|
||||
if (!doc || !matches(doc, filter)) return { matchedCount: 0, modifiedCount: 0 };
|
||||
Object.assign(doc, update.$set);
|
||||
return { matchedCount: 1, modifiedCount: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const at = ms => new Date(1700000000000 + ms);
|
||||
|
||||
test('24 servers acquiring at once: exactly one leads', async () => {
|
||||
const c = fakeCollection();
|
||||
const results = await Promise.all(Array.from({ length: 24 }, (_, i) => acquireLease(c, `server-${i}`, at(0))));
|
||||
assert.equal(results.filter(Boolean).length, 1);
|
||||
assert.equal(c.docs.size, 1, 'one lease document');
|
||||
});
|
||||
|
||||
test('the leader renews, and the others stay out while the lease is alive', async () => {
|
||||
const c = fakeCollection();
|
||||
assert.equal(await acquireLease(c, 'a', at(0)), true);
|
||||
assert.equal(await acquireLease(c, 'b', at(RENEW_MS)), false, 'b cannot take a live lease');
|
||||
assert.equal(await acquireLease(c, 'a', at(RENEW_MS)), true, 'a renews');
|
||||
assert.equal(c.docs.get(LEASE_ID).expiresAt.getTime(), at(RENEW_MS).getTime() + TTL_MS, 'and the expiry moves');
|
||||
assert.equal(await acquireLease(c, 'b', at(2 * RENEW_MS)), false, 'still a\'s after the renewal');
|
||||
});
|
||||
|
||||
test('failover: a leader that stops renewing is replaced once its lease has expired', async () => {
|
||||
const c = fakeCollection();
|
||||
await acquireLease(c, 'a', at(0));
|
||||
assert.equal(await acquireLease(c, 'b', at(TTL_MS - 1)), false, 'one millisecond before it lapses');
|
||||
assert.equal(await acquireLease(c, 'b', at(TTL_MS)), true, 'when it lapses b leads');
|
||||
assert.equal(await acquireLease(c, 'a', at(TTL_MS + 1)), false, 'and the old leader finds it lost it');
|
||||
});
|
||||
|
||||
test('release lets the next server lead at once; only the holder can release', async () => {
|
||||
const c = fakeCollection();
|
||||
await acquireLease(c, 'a', at(0));
|
||||
await releaseLease(c, 'b');
|
||||
assert.equal(await acquireLease(c, 'c', at(1)), false, 'b was not the holder, so nothing was released');
|
||||
await releaseLease(c, 'a');
|
||||
assert.equal(await acquireLease(c, 'c', at(1)), true, 'a released');
|
||||
});
|
||||
|
||||
test('takeover: a restore becomes the only writer, and the former leader loses the lease', async () => {
|
||||
const c = fakeCollection();
|
||||
await acquireLease(c, 'leader', at(0));
|
||||
const { waitMs } = await takeoverLease(c, 'restorer', at(5));
|
||||
assert.equal(waitMs, RENEW_MS, 'it waits one renewal so the former leader has stopped');
|
||||
assert.equal(await acquireLease(c, 'leader', at(RENEW_MS)), false, 'the former leader cannot renew');
|
||||
assert.equal(await acquireLease(c, 'restorer', at(RENEW_MS)), true);
|
||||
assert.equal((await takeoverLease(fakeCollection(), 'first', at(0))).waitMs, 0, 'with nobody to wait for there is no wait');
|
||||
});
|
||||
|
||||
test('other database errors are not swallowed (negative)', async () => {
|
||||
const broken = { async insertOne() { throw new Error('connection lost'); }, async updateOne() { return {}; } };
|
||||
await assert.rejects(() => acquireLease(broken, 'a', at(0)), /connection lost/);
|
||||
await assert.rejects(() => takeoverLease(broken, 'a', at(0)), /connection lost/);
|
||||
});
|
||||
|
||||
test('negative: the lease never upserts (an unmatched upsert must insert, and what then happens is the database\'s)', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'models/lib/backupLease.js'), 'utf8');
|
||||
assert.doesNotMatch(src.replace(/\/\/.*$/gm, ''), /upsert/);
|
||||
});
|
||||
|
||||
test('continuousBackup.js runs the stream through the lease, on every path', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'server/continuousBackup.js'), 'utf8');
|
||||
// the only direct apply() is inside the leader loop, after acquireLease said this server leads
|
||||
const applies = src.split('\n').filter(l => /backupManager\(\)\.apply\(/.test(l));
|
||||
assert.equal(applies.length, 1, 'one apply(), in evaluateLease');
|
||||
assert.match(src, /leading = await acquireLease\(leaseCollection\(\), serverId\);\s*\n\s*if \(!leading\) \{[^}]*backupManager\(\)\.stop\(\)/);
|
||||
assert.match(src, /async function evaluateLease\(\) \{\s*\n\s*if \(restoring\) return;/);
|
||||
// startup and saveSettings converge through the loop; restore and fetchFromCloud take the lease first
|
||||
assert.match(src, /Meteor\.startup\(async \(\) => \{\s*try \{\s*await evaluateLease\(\);\s*startLeaseLoop\(\);/);
|
||||
assert.match(src, /await evaluateLease\(\);\s*\n\s*return \{ \.\.\.backupManager\(\)\.status\(\), leading \};/);
|
||||
assert.equal((src.match(/await becomeOnlyWriter\(\);/g) || []).length, 2, 'restore and fetchFromCloud');
|
||||
assert.match(src, /const wasLeader = leading;[\s\S]*if \(!wasLeader\) await new Promise/);
|
||||
// a settings change another server saved is followed by the leader
|
||||
assert.match(src, /stamp !== appliedStamp/);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
// A missing board, or a token that is too long, must not answer an export request
|
||||
// with HTTP 200. PR #6869 fixed the Excel board route; the same two answers were in
|
||||
// the PDF routes, so this pins the SHAPE of the fault over every export route and
|
||||
// not only the one that was reported.
|
||||
//
|
||||
// Run: node tests/exportErrorStatuses.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const modelsDir = path.join(__dirname, '..', 'models');
|
||||
const files = fs.readdirSync(modelsDir).filter(f => /^export.*\.js$/.test(f));
|
||||
const read = f => fs.readFileSync(path.join(modelsDir, f), 'utf8').split('\n');
|
||||
|
||||
// every `res.end('literal error text')` and the last non-blank line before it
|
||||
function endsWithoutStatus(lines) {
|
||||
const found = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (!/res\.end\((['"`]|TAPi18n)/.test(line)) return;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && lines[j].trim() === '') j -= 1;
|
||||
if (!/writeHead|statusCode|\.status\(/.test(lines[j])) found.push(`${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
test('no export route ends a text answer without choosing its status', () => {
|
||||
assert.ok(files.length >= 5, 'the export route files are found');
|
||||
for (const f of files) {
|
||||
assert.deepEqual(endsWithoutStatus(read(f)), [], `${f} answers with the default 200`);
|
||||
}
|
||||
});
|
||||
|
||||
test('negative: the scan does catch the fault shape', () => {
|
||||
const bad = ["if (!board) {", " res.end('Board not found');", " return;", "}"];
|
||||
assert.equal(endsWithoutStatus(bad).length, 1);
|
||||
const good = ["if (!board) {", " res.writeHead(404, {});", " res.end('Board not found');", "}"];
|
||||
assert.equal(endsWithoutStatus(good).length, 0);
|
||||
});
|
||||
|
||||
test('"Board not found" is a 404 and a too-long token is a 400, in every export route', () => {
|
||||
for (const f of files) {
|
||||
const lines = read(f);
|
||||
lines.forEach((line, i) => {
|
||||
const m = /res\.end\('(Board not found|Invalid token)'\)/.exec(line);
|
||||
if (!m) return;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && lines[j].trim() === '') j -= 1;
|
||||
const status = /writeHead\((\d+)/.exec(lines[j]);
|
||||
assert.ok(status, `${f}:${i + 1} sets a status`);
|
||||
if (m[1] === 'Board not found') assert.equal(status[1], '404', `${f}:${i + 1}`);
|
||||
else assert.ok(['400', '401'].includes(status[1]), `${f}:${i + 1} is ${status[1]}`);
|
||||
});
|
||||
}
|
||||
// the PDF routes are the ones that had it
|
||||
const pdf = fs.readFileSync(path.join(modelsDir, 'exportPDF.js'), 'utf8');
|
||||
assert.equal((pdf.match(/writeHead\(404[^)]*\);\s*res\.end\('Board not found'\)/g) || []).length, 2);
|
||||
});
|
||||
@@ -139,7 +139,15 @@ for (const platform of ['darwin', 'linux']) {
|
||||
// A watcher that never reports anything: FSEvents in its first moments.
|
||||
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
// node:test runs after-hooks in the order they were registered, not last-in first-out. The engine
|
||||
// must be stopped BEFORE its directory is removed: a hook that removed the directory first let
|
||||
// stop() seal into a path that no longer existed, and the ENOENT surfaced after the test as
|
||||
// "generated asynchronous activity after the test ended".
|
||||
let stopEngine;
|
||||
t.after(async () => {
|
||||
await stopEngine?.();
|
||||
fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const live = path.join(root, 'live');
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
|
||||
@@ -150,7 +158,7 @@ for (const platform of ['darwin', 'linux']) {
|
||||
const scan = engine.scan.bind(engine);
|
||||
engine.scan = () => { calls.push('scan'); return scan(); };
|
||||
await engine.start();
|
||||
t.after(() => engine.stop());
|
||||
stopEngine = () => engine.stop();
|
||||
assert.deepEqual(calls.slice(0, 2), ['watch', 'scan'], 'the watcher is up before the first scan');
|
||||
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
|
||||
for (let i = 0; i < 40 && engine.status().puts < 2; i += 1) await sleep(100);
|
||||
@@ -175,7 +183,15 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
|
||||
const calls = [];
|
||||
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
// node:test runs after-hooks in the order they were registered, not last-in first-out. The engine
|
||||
// must be stopped BEFORE its directory is removed: a hook that removed the directory first let
|
||||
// stop() seal into a path that no longer existed, and the ENOENT surfaced after the test as
|
||||
// "generated asynchronous activity after the test ended".
|
||||
let stopEngine;
|
||||
t.after(async () => {
|
||||
await stopEngine?.();
|
||||
fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const live = path.join(root, 'live');
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
|
||||
@@ -188,7 +204,7 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
|
||||
engine.scan = () => { calls.push('scan'); return scan(); };
|
||||
await engine.start();
|
||||
let stopped = false;
|
||||
t.after(() => (stopped ? undefined : engine.stop()));
|
||||
stopEngine = () => (stopped ? undefined : engine.stop());
|
||||
if (stopAt === 'at once') { await engine.stop(); stopped = true; }
|
||||
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
|
||||
await sleep(2600); // past the two-second catch-up
|
||||
@@ -200,7 +216,8 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
|
||||
|
||||
test('file streams: attachments, avatars and logs, with watching, restored to a moment', { timeout: 60000 }, async t => {
|
||||
const root = tmpRoot();
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let stopEngine;
|
||||
t.after(async () => { await stopEngine?.(); fs.rmSync(root, { recursive: true, force: true }); }); // engine first: hooks run in registration order
|
||||
const live = path.join(root, 'live');
|
||||
const sources = [
|
||||
{ area: 'attachments', key: 'attachments', path: path.join(live, 'attachments') },
|
||||
@@ -223,7 +240,7 @@ test('file streams: attachments, avatars and logs, with watching, restored to a
|
||||
onError: error => { throw error; }, onState: async s => { state = s; } }).start();
|
||||
// Its seal and scan timers keep the process alive: stop it however this test
|
||||
// ends, or a failed assertion below becomes a 300 s suite timeout.
|
||||
t.after(() => engine.stop());
|
||||
stopEngine = () => engine.stop();
|
||||
assert.equal(engine.status().files, 4, 'the symbolic link is skipped');
|
||||
// Watching notices a change without a rescan (the scan runs hourly here).
|
||||
// File-system events can lag by seconds on a loaded machine: allow 20 s.
|
||||
|
||||
@@ -23,6 +23,9 @@ const zshScripts = [
|
||||
const fixtures = {
|
||||
alpine: 'ID=alpine\n',
|
||||
arch: 'ID=arch\n',
|
||||
omarchy: 'ID=omarchy\nID_LIKE=arch\n',
|
||||
omarchyAlone: 'NAME="Omarchy"\nID=omarchy\n',
|
||||
endeavouros: 'ID=endeavouros\nID_LIKE=arch\n',
|
||||
fedora: 'ID=fedora\n',
|
||||
rhel: 'ID=rhel\n',
|
||||
oracle: 'ID=ol\nID_LIKE="fedora rhel"\n',
|
||||
@@ -44,7 +47,8 @@ for (const [name, contents] of Object.entries(fixtures)) {
|
||||
});
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const expected = name === 'oracle' ? 'rhel' : name;
|
||||
const expected = name === 'oracle' ? 'rhel'
|
||||
: ['omarchy', 'omarchyAlone', 'endeavouros'].includes(name) ? 'arch' : name;
|
||||
assert.strictEqual(result.stdout.trim(), expected);
|
||||
});
|
||||
}
|
||||
@@ -81,7 +85,9 @@ test('Homebrew uses macOS formula names', () => {
|
||||
|
||||
test('every Linux family has an explicit package-manager command', () => {
|
||||
const src = fs.readFileSync(helper, 'utf8');
|
||||
for (const command of ['apk add --no-cache', 'pacman -Sy --needed --noconfirm',
|
||||
// Arch's command is `pacman -S --needed` inside _et_pacman: a bare `-Sy` is
|
||||
// a partial upgrade, which Arch does not support (archOmarchyScripts.test.cjs).
|
||||
for (const command of ['apk add --no-cache', 'pacman -S --needed --noconfirm',
|
||||
'dnf', 'yum', 'apt-get']) assert.ok(src.includes(command), command);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
// #6872: "Requested by" (and "Assigned by") never reached the minicard. The
|
||||
// opened card's + button picks board members into card.requesters /
|
||||
// card.assigners (user ids, cardIdentityPicker), and the free-text
|
||||
// requestedBy / assignedBy is optional - but the minicard only drew the free
|
||||
// text, so a requester picked the usual way was not shown at all. And the
|
||||
// field's icon was a cart on the card and the minicard but a head with a plus
|
||||
// in Card Settings, with Assigned by the other way round.
|
||||
// Run: node tests/minicardRequestedAssignedBy6872.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(root, rel), 'utf8');
|
||||
let passed = 0;
|
||||
const test = (name, fn) => { fn(); passed += 1; console.log(' ok -', name); };
|
||||
|
||||
const minicardJade = read('client/components/cards/minicard.jade');
|
||||
const minicardJs = read('client/components/cards/minicard.js');
|
||||
const detailsJade = read('client/components/cards/cardDetails.jade');
|
||||
const { CARD_SETTINGS_ROWS } = require('../models/lib/cardSettingsRows');
|
||||
|
||||
// The `if $eq field "<key>"` block of a template, up to the next field.
|
||||
function block(src, key) {
|
||||
const start = src.indexOf(`if $eq field "${key}"`);
|
||||
assert.ok(start >= 0, key);
|
||||
const next = src.indexOf('if $eq field "', start + 1);
|
||||
return src.slice(start, next < 0 ? undefined : next);
|
||||
}
|
||||
const rowOf = key => CARD_SETTINGS_ROWS.find(r => r.key === key);
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'requestedBy', ids: 'getRequesters', text: 'getRequestedBy', helper: 'hasRequestedByOnMinicard' },
|
||||
{ key: 'assignedBy', ids: 'getAssigners', text: 'getAssignedBy', helper: 'hasAssignedByOnMinicard' },
|
||||
];
|
||||
|
||||
for (const f of FIELDS) {
|
||||
test(`${f.key}: the minicard draws the picked members as avatars`, () => {
|
||||
const b = block(minicardJade, f.key);
|
||||
assert.match(b, new RegExp(`each userId in ${f.ids}\\s+\\+userAvatar\\(userId=userId noRemove=true\\)`));
|
||||
assert.match(b, new RegExp(`if ${f.text}\\s+span \\{\\{ ${f.text} \\}\\}`));
|
||||
});
|
||||
|
||||
test(`${f.key}: shown when EITHER a member was picked or a name typed`, () => {
|
||||
assert.match(block(minicardJade, f.key), new RegExp(`if ${f.helper}\\b`));
|
||||
assert.match(minicardJs, new RegExp(
|
||||
`${f.helper}\\(\\) \\{\\s*return !!this\\.${f.text}\\(\\) \\|\\| \\(this\\.${f.ids}\\(\\) \\|\\| \\[\\]\\)\\.length > 0;`));
|
||||
});
|
||||
|
||||
test(`${f.key}: Card Settings, the opened card and the minicard use one icon`, () => {
|
||||
const icon = (block(minicardJade, f.key).match(/i\.fa\.(fa-[a-z-]+)/) || [])[1];
|
||||
assert.ok(icon, `${f.key} minicard icon`);
|
||||
assert.strictEqual((block(detailsJade, f.key).match(/i\.fa\.(fa-[a-z-]+)/) || [])[1], icon, 'opened card');
|
||||
const row = rowOf(f.key);
|
||||
assert.ok(row, `${f.key} settings row`);
|
||||
assert.deepStrictEqual(row.icons, [icon], 'Card Settings row');
|
||||
});
|
||||
}
|
||||
|
||||
test('the two fields keep distinct icons (Requested by is the cart)', () => {
|
||||
assert.deepStrictEqual(rowOf('requestedBy').icons, ['fa-shopping-cart']);
|
||||
assert.deepStrictEqual(rowOf('assignedBy').icons, ['fa-user-plus']);
|
||||
});
|
||||
|
||||
// Negative: the shape of the fault was a minicard person block gated on the
|
||||
// free-text value alone. No such block may come back for either field.
|
||||
test('negative: no minicard block gates a person field on its free text alone', () => {
|
||||
for (const f of FIELDS) {
|
||||
assert.doesNotMatch(block(minicardJade, f.key), new RegExp(`^\\s*if ${f.text}\\s*$\\n\\s*\\.minicard-`, 'm'), f.key);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const BoardPage = require('../pages/BoardPage');
|
||||
const ExcelJS = require('../../../node_modules/@wekanteam/exceljs');
|
||||
|
||||
async function expectWorkbook(response) {
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()['content-type']).toContain('spreadsheetml.sheet');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(await response.body());
|
||||
const values = [];
|
||||
workbook.eachSheet(sheet => sheet.eachRow(row => values.push(JSON.stringify(row.values))));
|
||||
expect(values.join('\n')).toContain('Alpha Card');
|
||||
}
|
||||
|
||||
test('a missing board Excel export returns 404 with or without a token', async ({ request, user }) => {
|
||||
const url = `/api/boards/${db.uid('missing')}/exportExcel`;
|
||||
for (const suffix of ['', `?authToken=${encodeURIComponent(user.token)}`]) {
|
||||
const response = await request.get(url + suffix);
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()['content-type']).toBe('text/plain; charset=utf-8');
|
||||
expect(response.headers()['content-disposition']).toBeUndefined();
|
||||
expect(await response.text()).toBe('Board not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('the board menu exports Excel and a deleted board link returns 404', async ({ boardPage: page, board }) => {
|
||||
await new BoardPage(page).openSidebar();
|
||||
await page.locator('.board-sidebar .js-open-board-menu').click();
|
||||
await page.locator('.js-pop-over .js-export-board').click();
|
||||
const anchor = page.locator('.js-pop-over a[href*="/exportExcel?"]');
|
||||
await expect(anchor).toBeVisible();
|
||||
const href = await anchor.getAttribute('href');
|
||||
await expectWorkbook(await page.request.get(href));
|
||||
|
||||
db.deleteOne('boards', { _id: board.boardId });
|
||||
const response = await page.goto(href);
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()['content-type']).toBe('text/plain; charset=utf-8');
|
||||
expect(response.headers()['content-disposition']).toBeUndefined();
|
||||
expect(await response.text()).toBe('Board not found');
|
||||
await expect(page.locator('body')).toHaveText('Board not found');
|
||||
});
|
||||
|
||||
test('public board Excel exports still produce both workbook layouts', async ({ request, board }) => {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
|
||||
for (const suffix of ['', '?fields=description']) {
|
||||
await expectWorkbook(await request.get(`/api/boards/${board.boardId}/exportExcel${suffix}`));
|
||||
}
|
||||
});
|
||||
|
||||
test('private board Excel exports still refuse an unrelated member', async ({ request, board, user2 }) => {
|
||||
const response = await request.get(`/api/boards/${board.boardId}/exportExcel?authToken=${encodeURIComponent(user2.token)}`);
|
||||
expect(response.status()).toBe(403);
|
||||
expect(await response.text()).not.toContain('Alpha Card');
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
// iPad Safari and Chrome (both WebKit) make every SCROLLING element - #content, the
|
||||
// board wrapper - a stacking context of its own. The opened card is inside them, so its
|
||||
// z-index (2001) is compared only there, and the header (#header-quick-access, z-index
|
||||
// 1000, a flex item of body and so a stacking context too) is painted over the card: its top row - the collapse caret, the card
|
||||
// number, the menu, maximize and X - sat behind the header bar (boards.wekan.team,
|
||||
// iPad Chrome, 2026-11-04). Other browsers do not do it. The trap is simulated here with
|
||||
// isolation: isolate, which makes the same stacking context, so Chromium and WebKit
|
||||
// show what the iPad does. Desktop mode, forced as the toggle does.
|
||||
const { test, expect } = require('../fixtures');
|
||||
const BoardPage = require('../pages/BoardPage');
|
||||
|
||||
async function overHeader(page) {
|
||||
return page.evaluate(() => {
|
||||
const card = document.querySelector('.card-details');
|
||||
const header = document.querySelector('#header-quick-access');
|
||||
const c = card.getBoundingClientRect();
|
||||
const h = header.getBoundingClientRect();
|
||||
const top = Math.max(c.top, h.top);
|
||||
const bottom = Math.min(c.bottom, h.bottom);
|
||||
if (bottom - top < 8) return { overlaps: false };
|
||||
// The card's own header row: the caret, the number, the menu and X are drawn there.
|
||||
const y = Math.round(top + 6);
|
||||
const x = Math.round(c.left + Math.min(40, c.width / 2));
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const closeBtn = card.querySelector('.js-close-card-details').getBoundingClientRect();
|
||||
const closeHit = document.elementFromPoint(Math.round(closeBtn.left + closeBtn.width / 2), Math.round(Math.max(closeBtn.top, h.top) + 4));
|
||||
return {
|
||||
overlaps: true,
|
||||
cardIsTopmost: !!hit && card.contains(hit),
|
||||
closeButtonIsTopmost: !!closeHit && card.contains(closeHit),
|
||||
cardTop: Math.round(c.top), headerBottom: Math.round(h.bottom),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const trap of ['#content', '.board-wrapper', '#content, .board-wrapper']) {
|
||||
test(`the opened card covers the header when ${trap} is a stacking context (iOS WebKit), open and collapsed`, async ({ boardPage: page, board }) => {
|
||||
await page.evaluate(() => localStorage.setItem('wekan-mobile-mode', 'false'));
|
||||
await page.reload();
|
||||
await new BoardPage(page).clickCard(board.listIds[0], 'Alpha Card');
|
||||
await expect(page.locator('.card-details')).toBeVisible();
|
||||
await expect(page.locator('body')).toHaveClass(/desktop-mode/);
|
||||
await page.addStyleTag({ content: `${trap} { isolation: isolate; }` });
|
||||
|
||||
const open = await overHeader(page);
|
||||
expect(open.overlaps, 'the docked card overlaps the header').toBe(true);
|
||||
expect(open.cardIsTopmost, `the card is drawn over the header (card top ${open.cardTop}, header bottom ${open.headerBottom})`).toBe(true);
|
||||
expect(open.closeButtonIsTopmost, 'and its close button can be reached').toBe(true);
|
||||
|
||||
await page.locator('.card-details .card-collapse-toggle').first().click();
|
||||
await expect(page.locator('.card-details.card-details-collapsed')).toBeVisible();
|
||||
const collapsed = await overHeader(page);
|
||||
expect(collapsed.overlaps).toBe(true);
|
||||
expect(collapsed.cardIsTopmost, 'collapsed too').toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('with no card open the header is where it was (negative)', async ({ boardPage: page }) => {
|
||||
await expect(page.locator('.card-details')).toHaveCount(0);
|
||||
const z = await page.evaluate(() => getComputedStyle(document.querySelector('#header-quick-access')).zIndex);
|
||||
expect(z, 'the header keeps its own level while no card is open').toBe('1000');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
// #6872: a requester or assigner picked with the opened card's + button is
|
||||
// stored as a user id in card.requesters / card.assigners. The minicard only
|
||||
// drew the free-text requestedBy / assignedBy, so a picked member never showed
|
||||
// there although "Show on Minicard" was on. It shows as an avatar now, and a
|
||||
// typed name still shows beside it. tests/minicardRequestedAssignedBy6872.test.cjs
|
||||
// pins the template; this drives it.
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
|
||||
test.describe('#6872 Requested by / Assigned by on the minicard', () => {
|
||||
test('a picked member and a typed name both show', async ({ boardPage, board, user }) => {
|
||||
const alpha = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
db.updateOne('boards', { _id: board.boardId }, {
|
||||
$set: { allowsRequestedBy: true, allowsRequestedByOnMinicard: true,
|
||||
allowsAssignedBy: true, allowsAssignedByOnMinicard: true },
|
||||
});
|
||||
db.updateOne('cards', { _id: alpha._id }, {
|
||||
$set: { requesters: [user.id], requestedBy: '', assigners: [], assignedBy: 'Customer X' },
|
||||
});
|
||||
await boardPage.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
const card = boardPage.locator('.js-minicard').filter({ hasText: 'Alpha Card' }).first();
|
||||
await expect(card).toBeVisible();
|
||||
// Only a picked member, no free text: this is the case that showed nothing.
|
||||
await expect(card.locator('.minicard-requested-by .fa-shopping-cart')).toBeVisible();
|
||||
await expect(card.locator('.minicard-requested-by .member')).toHaveCount(1);
|
||||
// Only free text: still shown, with Assigned by's own icon.
|
||||
await expect(card.locator('.minicard-assigned-by')).toContainText('Customer X');
|
||||
await expect(card.locator('.minicard-assigned-by .fa-user-plus')).toBeVisible();
|
||||
});
|
||||
|
||||
test('negative: nothing picked and nothing typed draws no line', async ({ boardPage, board }) => {
|
||||
const alpha = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
db.updateOne('cards', { _id: alpha._id }, {
|
||||
$set: { requesters: [], requestedBy: '', assigners: [], assignedBy: '' },
|
||||
});
|
||||
await boardPage.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
const card = boardPage.locator('.js-minicard').filter({ hasText: 'Alpha Card' }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('.minicard-requested-by')).toHaveCount(0);
|
||||
await expect(card.locator('.minicard-assigned-by')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Show on Minicard off hides a picked member', async ({ boardPage, board, user }) => {
|
||||
const alpha = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsRequestedByOnMinicard: false } });
|
||||
db.updateOne('cards', { _id: alpha._id }, { $set: { requesters: [user.id] } });
|
||||
await boardPage.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
const card = boardPage.locator('.js-minicard').filter({ hasText: 'Alpha Card' }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('.minicard-requested-by')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const { test: base, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
|
||||
const headers = token => ({ Authorization: `Bearer ${token}` });
|
||||
const integrationPath = (boardId, id) => `/api/boards/${boardId}/integrations/${id}`;
|
||||
|
||||
const test = base.extend({
|
||||
integration: async ({ request, user, board }, use) => {
|
||||
const response = await request.post(`/api/boards/${board.boardId}/integrations`, {
|
||||
headers: headers(user.token), data: { url: 'https://example.com/webhook' },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
const { _id } = await response.json();
|
||||
try {
|
||||
await use({ id: _id, path: integrationPath(board.boardId, _id) });
|
||||
} finally {
|
||||
db.deleteOne('integrations', { _id });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test('integration PUT rejects missing and wrong-board IDs and preserves real updates', async ({ request, user, board, integration }) => {
|
||||
const other = db.seedBoard({ ownerId: user.id });
|
||||
try {
|
||||
const before = db.findOne('integrations', { _id: integration.id });
|
||||
for (const path of [
|
||||
integrationPath(board.boardId, 'missing-integration'),
|
||||
integrationPath(other.boardId, integration.id),
|
||||
]) {
|
||||
for (const data of [{}, { title: 'Should not change', enabled: false, activities: [] }]) {
|
||||
const response = await request.put(path, { headers: headers(user.token), data });
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect(await response.json()).toEqual({ error: 'Integration not found' });
|
||||
expect(db.findOne('integrations', { _id: integration.id })).toEqual(before);
|
||||
}
|
||||
}
|
||||
expect(db.findOne('integrations', { _id: 'missing-integration' })).toBeNull();
|
||||
|
||||
for (const data of [{ title: 'Updated webhook', enabled: false, activities: [] }, { title: 'Updated webhook', enabled: false }, {}]) {
|
||||
const response = await request.put(integration.path, { headers: headers(user.token), data });
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toEqual({ _id: integration.id });
|
||||
expect(db.findOne('integrations', { _id: integration.id })).toMatchObject({ title: 'Updated webhook', enabled: false, activities: [] });
|
||||
}
|
||||
const response = await request.get(integration.path, { headers: headers(user.token) });
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ _id: integration.id, title: 'Updated webhook', enabled: false, activities: [] });
|
||||
} finally {
|
||||
db.cleanup({ boardIds: [other.boardId] });
|
||||
}
|
||||
});
|
||||
|
||||
test('integration PUT preserves authentication and board-admin requirements', async ({ request, board, integration }) => {
|
||||
const before = db.findOne('integrations', { _id: integration.id });
|
||||
for (const id of [integration.id, 'missing-integration']) {
|
||||
const path = integrationPath(board.boardId, id);
|
||||
expect((await request.put(path, { data: { title: 'Unauthorized' } })).status()).toBe(401);
|
||||
// A refused management request may block its actor. Use a fresh ordinary
|
||||
// member for each ID so each request exercises the board-admin guard.
|
||||
const member = db.seedUser();
|
||||
try {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $push: { members: {
|
||||
userId: member.id, isActive: true, isAdmin: false, isNoComments: false, isCommentOnly: false,
|
||||
} } });
|
||||
expect((await request.put(path, { headers: headers(member.token), data: { title: 'Forbidden' } })).status()).toBe(403);
|
||||
} finally {
|
||||
db.cleanup({ userIds: [member.id] });
|
||||
}
|
||||
}
|
||||
expect(db.findOne('integrations', { _id: integration.id })).toEqual(before);
|
||||
});
|
||||
|
||||
test('successful REST updates appear in webhook settings after a wrong-board update is rejected', async ({ request, user, board, integration, boardPage }) => {
|
||||
const other = db.seedBoard({ ownerId: user.id });
|
||||
try {
|
||||
const response = await request.put(integration.path, {
|
||||
headers: headers(user.token), data: { title: 'Retained webhook', enabled: false, activities: [] },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
const rejected = await request.put(integrationPath(other.boardId, integration.id), {
|
||||
headers: headers(user.token), data: { title: 'Wrong-board overwrite', enabled: true },
|
||||
});
|
||||
expect(rejected.status()).toBe(404);
|
||||
|
||||
const menu = boardPage.locator('.board-sidebar .js-open-board-menu');
|
||||
if (!(await menu.isVisible())) await boardPage.locator('.js-toggle-page-sidebar').first().click();
|
||||
await menu.click();
|
||||
await boardPage.locator('.js-outgoing-webhooks').click();
|
||||
const form = boardPage.locator('form.integration-form').filter({
|
||||
has: boardPage.locator(`input[name="id"][value="${integration.id}"]`),
|
||||
});
|
||||
await expect(form.locator('.js-outgoing-webhooks-title')).toHaveValue('Retained webhook');
|
||||
await expect(form.locator('.js-toggle-webhook-enabled .materialCheckBox')).toHaveClass(/is-checked/);
|
||||
expect(db.findOne('integrations', { _id: integration.id })).toMatchObject({ title: 'Retained webhook', enabled: false });
|
||||
} finally {
|
||||
db.cleanup({ boardIds: [other.boardId] });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken } = require('../helpers/auth');
|
||||
const AdminPage = require('../pages/AdminPage');
|
||||
|
||||
for (const [kind, label] of [['org', 'Organization'], ['team', 'Team']]) {
|
||||
test.describe(`REST ${label.toLowerCase()} features`, () => {
|
||||
let id;
|
||||
const fields = ['SharedTemplates', 'PropagateMembersToBoards', 'SyncMembersFromAuth']
|
||||
.map(suffix => kind + suffix);
|
||||
const endpoint = target => `/api/admin/${kind}s/${encodeURIComponent(target)}/features`;
|
||||
const headers = token => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
test.beforeEach(() => {
|
||||
id = db.uid(kind);
|
||||
db.insertOne(kind, {
|
||||
_id: id, [`${kind}DisplayName`]: id, [`${kind}ShortName`]: id,
|
||||
[`${kind}IsActive`]: true, ...Object.fromEntries(fields.map(field => [field, false])),
|
||||
});
|
||||
});
|
||||
test.afterEach(() => db.deleteOne(kind, { _id: id }));
|
||||
|
||||
test('missing records return JSON 404 without changing other records', async ({ request, adminUser }) => {
|
||||
const missing = db.uid('missing');
|
||||
const before = db.findOne(kind, { _id: id });
|
||||
for (const data of [{ [fields[0]]: true }, {}, { unrelated: true }]) {
|
||||
const response = await request.put(endpoint(missing), { headers: headers(adminUser.token), data });
|
||||
expect(response.status()).toBe(404);
|
||||
expect(await response.json()).toEqual({ error: `${label} not found` });
|
||||
expect(db.findOne(kind, { _id: id })).toEqual(before);
|
||||
expect(db.findOne(kind, { _id: missing })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('existing records still set and clear each feature and accept empty bodies', async ({ request, adminUser }) => {
|
||||
for (const field of fields) {
|
||||
for (const value of [true, false]) {
|
||||
const response = await request.put(endpoint(id), {
|
||||
headers: headers(adminUser.token), data: { [field]: value, unrelated: true },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ _id: id, [field]: value });
|
||||
const saved = db.findOne(kind, { _id: id });
|
||||
expect(saved[field]).toBe(value);
|
||||
expect(saved.unrelated).toBeUndefined();
|
||||
}
|
||||
}
|
||||
for (const data of [{}, { unrelated: true }]) {
|
||||
const response = await request.put(endpoint(id), { headers: headers(adminUser.token), data });
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ _id: id });
|
||||
}
|
||||
});
|
||||
|
||||
test('non-admin and unauthenticated callers cannot probe or update records', async ({ request, user }) => {
|
||||
const before = db.findOne(kind, { _id: id });
|
||||
for (const target of [id, db.uid('missing')]) {
|
||||
for (const token of [user.token, null]) {
|
||||
const response = await request.put(endpoint(target), {
|
||||
headers: token ? headers(token) : {}, data: { [fields[0]]: true },
|
||||
});
|
||||
expect(response.status()).toBe(token ? 403 : 401);
|
||||
expect(db.findOne(kind, { _id: id })).toEqual(before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('feature updates appear in the People table while a missing update leaves it unchanged', async ({ page, request, adminUser }) => {
|
||||
await loginWithToken(page, adminUser.id, adminUser.token);
|
||||
await new AdminPage(page).navigateToPeople();
|
||||
await page.locator(`.js-left-menu-item[data-id="${kind}-setting"]`).click();
|
||||
const row = page.locator('table tbody tr').filter({ hasText: id });
|
||||
const checkbox = row.locator(`input[data-feature="${fields[0]}"]`);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
const missing = await request.put(endpoint(db.uid('missing')), {
|
||||
headers: headers(adminUser.token), data: { [fields[0]]: true },
|
||||
});
|
||||
expect(missing.status()).toBe(404);
|
||||
expect(await missing.json()).toEqual({ error: `${label} not found` });
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
for (const value of [true, false]) {
|
||||
const response = await request.put(endpoint(id), {
|
||||
headers: headers(adminUser.token), data: { [fields[0]]: value },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
await expect(checkbox).toBeChecked({ checked: value });
|
||||
expect(db.findOne(kind, { _id: id })[fields[0]]).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken } = require('../helpers/auth');
|
||||
const AdminPage = require('../pages/AdminPage');
|
||||
|
||||
const headers = token => ({ Authorization: `Bearer ${token}` });
|
||||
const userPath = id => `/api/users/${id}`;
|
||||
|
||||
test('user PUT returns JSON 404 for missing users without changing existing users', async ({ request, adminUser, user }) => {
|
||||
const missing = db.uid('missing');
|
||||
const before = db.findOne('users', { _id: user.id });
|
||||
for (const data of [{ action: 'disableLogin' }, { action: 'enableLogin' }, { action: 'takeOwnership' }, {}]) {
|
||||
const response = await request.put(userPath(missing), { headers: headers(adminUser.token), data });
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect(await response.json()).toEqual({ error: 'User not found' });
|
||||
expect(db.findOne('users', { _id: missing })).toBeNull();
|
||||
expect(db.findOne('users', { _id: user.id })).toEqual(before);
|
||||
}
|
||||
});
|
||||
|
||||
test('user PUT preserves successful updates and hides session secrets', async ({ request, adminUser, user }) => {
|
||||
for (const [action, disabled] of [['disableLogin', true], ['enableLogin', false]]) {
|
||||
const response = await request.put(userPath(user.id), { headers: headers(adminUser.token), data: { action } });
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({ _id: user.id, loginDisabled: disabled });
|
||||
expect(body).not.toHaveProperty('services');
|
||||
expect(body).not.toHaveProperty('sessionData');
|
||||
const stored = db.findOne('users', { _id: user.id });
|
||||
expect(stored.loginDisabled).toBe(disabled);
|
||||
expect(stored.services.resume.loginTokens).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('user PUT checks authentication and site-admin access before missing-user responses', async ({ request, user }) => {
|
||||
const before = db.findOne('users', { _id: user.id });
|
||||
for (const id of [user.id, db.uid('missing')]) {
|
||||
expect((await request.put(userPath(id), { data: { action: 'disableLogin' } })).status()).toBe(401);
|
||||
expect((await request.put(userPath(id), {
|
||||
headers: headers(user.token), data: { action: 'disableLogin' },
|
||||
})).status()).toBe(403);
|
||||
}
|
||||
expect(db.findOne('users', { _id: user.id })).toEqual(before);
|
||||
});
|
||||
|
||||
test('People shows valid REST login changes after a missing-user update is rejected', async ({ page, request, adminUser, user }) => {
|
||||
await loginWithToken(page, adminUser.id, adminUser.token);
|
||||
const admin = new AdminPage(page);
|
||||
await admin.navigateToPeople();
|
||||
const row = admin.userRowByUsername(user.username);
|
||||
const status = row.locator('.js-toggle-active-status');
|
||||
await expect(status).toHaveAttribute('data-is-active', 'true');
|
||||
|
||||
const missing = await request.put(userPath(db.uid('missing')), {
|
||||
headers: headers(adminUser.token), data: { action: 'disableLogin' },
|
||||
});
|
||||
expect(missing.status()).toBe(404);
|
||||
expect(await missing.json()).toEqual({ error: 'User not found' });
|
||||
await expect(status).toHaveAttribute('data-is-active', 'true');
|
||||
|
||||
for (const [action, active] of [['disableLogin', 'false'], ['enableLogin', 'true']]) {
|
||||
const response = await request.put(userPath(user.id), {
|
||||
headers: headers(adminUser.token), data: { action },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
await expect(status).toHaveAttribute('data-is-active', active);
|
||||
expect(db.findOne('users', { _id: user.id }).loginDisabled).toBe(active === 'false');
|
||||
}
|
||||
});
|
||||
@@ -37,7 +37,8 @@ const { verify } = require('../releases/verify-mime-runtime.cjs');
|
||||
const snap = fs.readFileSync(path.resolve('snapcraft.yaml'), 'utf8');
|
||||
assert.match(snap, /stage-packages:\s*- file\s*- libmagic-mgc/);
|
||||
const setup = fs.readFileSync(path.resolve('build.sh'), 'utf8');
|
||||
for (const command of ['apk add', 'pacman -Sy', 'dnf install', 'apt-get install']) {
|
||||
// Arch/Omarchy installs through _et_pacman (no `pacman -Sy` partial upgrade).
|
||||
for (const command of ['apk add', '_et_pacman', 'dnf install', 'apt-get install']) {
|
||||
assert.ok(setup.split('\n').some(line => line.includes(command) && /\bfile\b/.test(line)), command);
|
||||
}
|
||||
for (const filename of ['models/fileValidation.js', 'models/lib/fileTypeCorrection.js', 'server/methods/fileStatusAudit.js']) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
|
||||
// Exercise the registered integration PUT handler, including board scope and
|
||||
// authorization, without starting Meteor. Live database coverage is in Playwright.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { test } = require('node:test');
|
||||
const { normalizeDeliverySettings } = require('../models/lib/notificationDelivery');
|
||||
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
|
||||
|
||||
const route = '/api/boards/:boardId/integrations/:intId';
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function fixture({ deny = false, lookupError } = {}) {
|
||||
const calls = [];
|
||||
const documents = [
|
||||
{ _id: 'integration', boardId: 'board', title: 'Webhook', enabled: true, activities: ['all'] },
|
||||
{ _id: 'foreign-integration', boardId: 'other-board', title: 'Other webhook', enabled: true },
|
||||
];
|
||||
const matches = (doc, selector) => Object.entries(selector).every(([key, value]) => doc[key] === value);
|
||||
let handler;
|
||||
const context = {
|
||||
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(verb => [verb, (url, fn) => {
|
||||
if (verb === 'put' && url === route) handler = fn;
|
||||
}])) },
|
||||
Authentication: { async checkBoardAdmin(userId, boardId) {
|
||||
calls.push(['admin', userId, boardId]);
|
||||
if (deny) throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
||||
} },
|
||||
ReactiveCache: { async getIntegration(selector) {
|
||||
calls.push(['lookup', plain(selector)]);
|
||||
if (lookupError) throw lookupError;
|
||||
return documents.find(doc => matches(doc, selector));
|
||||
} },
|
||||
Integrations: { direct: { async updateAsync(selector, modifier) {
|
||||
calls.push(['update', plain(selector), plain(modifier)]);
|
||||
const doc = documents.find(item => matches(item, selector));
|
||||
if (!doc) return 0;
|
||||
Object.assign(doc, modifier.$set);
|
||||
for (const key of Object.keys(modifier.$unset || {})) delete doc[key];
|
||||
return 1;
|
||||
} } },
|
||||
normalizeDeliverySettings,
|
||||
publicErrorData,
|
||||
sendJsonResult(res, result) { res.responses.push(plain(result)); },
|
||||
};
|
||||
const source = fs.readFileSync(path.join(__dirname, '../server/models/integrations.js'), 'utf8');
|
||||
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
|
||||
assert.equal(typeof handler, 'function');
|
||||
return {
|
||||
calls, documents,
|
||||
async put(body = {}, params = {}) {
|
||||
const res = { responses: [] };
|
||||
await handler({ userId: 'admin', params: { boardId: 'board', intId: 'integration', ...params }, body }, res);
|
||||
assert.equal(res.responses.length, 1, 'respond exactly once');
|
||||
return res.responses[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a valid integration updates falsy values and keeps the existing response', async () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await f.put({ title: 'Updated webhook', enabled: false, activities: [] }), {
|
||||
code: 200, data: { _id: 'integration' },
|
||||
});
|
||||
assert.deepEqual(f.documents[0], {
|
||||
_id: 'integration', boardId: 'board', title: 'Updated webhook', enabled: false, activities: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('empty and unchanged updates of an existing integration remain successful', async () => {
|
||||
const f = fixture();
|
||||
for (const body of [{}, { title: 'Webhook', enabled: true }]) {
|
||||
assert.deepEqual(await f.put(body), { code: 200, data: { _id: 'integration' } });
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, params] of [
|
||||
['missing integration', { intId: 'missing' }],
|
||||
['integration belonging to another board', { intId: 'foreign-integration' }],
|
||||
['existing integration under another board', { boardId: 'other-board' }],
|
||||
]) {
|
||||
test(`${name} returns 404 before any field is written or validated`, async () => {
|
||||
for (const body of [{}, { title: 'Changed', enabled: false, activities: [] }, { notificationDelivery: 'invalid' }]) {
|
||||
const f = fixture();
|
||||
const before = plain(f.documents);
|
||||
assert.deepEqual(await f.put(body, params), { code: 404, data: { error: 'Integration not found' } });
|
||||
assert.deepEqual(f.calls, [
|
||||
['admin', 'admin', params.boardId || 'board'],
|
||||
['lookup', { _id: params.intId || 'integration', boardId: params.boardId || 'board' }],
|
||||
]);
|
||||
assert.deepEqual(f.documents, before);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('board authorization precedes lookups for both existing and missing IDs', async () => {
|
||||
for (const intId of ['integration', 'missing']) {
|
||||
const f = fixture({ deny: true });
|
||||
const response = await f.put({ title: 'Changed' }, { intId });
|
||||
assert.equal(response.code, 403);
|
||||
assert.deepEqual(f.calls, [['admin', 'admin', 'board']]);
|
||||
assert.equal(f.documents[0].title, 'Webhook');
|
||||
}
|
||||
});
|
||||
|
||||
test('database errors keep their server-error response instead of becoming 404', async () => {
|
||||
const f = fixture({ lookupError: new Error('database unavailable') });
|
||||
const response = await f.put({ title: 'Changed' });
|
||||
assert.equal(response.code, 500);
|
||||
assert.equal(f.documents[0].title, 'Webhook');
|
||||
assert.ok(!f.calls.some(([name]) => name === 'update'));
|
||||
});
|
||||
|
||||
test('invalid delivery settings still reject an existing integration before writes', async () => {
|
||||
const f = fixture();
|
||||
const response = await f.put({ title: 'Changed', notificationDelivery: 'invalid' });
|
||||
assert.equal(response.code, 400);
|
||||
assert.equal(response.data.error, 'invalid-notification-delivery');
|
||||
assert.equal(f.documents[0].title, 'Webhook');
|
||||
assert.ok(!f.calls.some(([name]) => name === 'update'));
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
// Every integration route that WRITES must answer 404 for an integration that is not on the
|
||||
// board in the URL (#6862 did GET; PR #6871 did PUT). The two activities routes had the same
|
||||
// fault: they updated nothing and answered 200 with `data: null`. This pins the shape over the
|
||||
// whole file, so a new write route cannot bring it back.
|
||||
//
|
||||
// Run: node tests/restIntegrationWriteRoutes404.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { test } = require('node:test');
|
||||
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
|
||||
|
||||
const file = path.join(__dirname, '..', 'server/models/integrations.js');
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
|
||||
test('every handler that updates an integration looks it up on that board first', () => {
|
||||
const updating = source.split(/\nWebApp\.handlers\./).slice(1)
|
||||
.filter(block => /Integrations\.direct\.updateAsync/.test(block));
|
||||
assert.ok(updating.length >= 3, 'PUT and both activities routes are found');
|
||||
for (const block of updating) {
|
||||
const update = block.indexOf('Integrations.direct.updateAsync');
|
||||
const guard = block.indexOf('integrationOr404(');
|
||||
assert.ok(guard > -1 && guard < update, `${block.split('\n')[0]} checks before it writes`);
|
||||
// and only after board-admin authorization, so the answer tells nobody anything
|
||||
assert.ok(block.indexOf('checkBoardAdmin') < guard, `${block.split('\n')[0]} authorizes first`);
|
||||
}
|
||||
});
|
||||
|
||||
test('negative: the check is not the old "write, then answer 200 with whatever is there"', () => {
|
||||
assert.match(source, /async function integrationOr404\(/);
|
||||
assert.match(source, /code: 404, data: \{ error: 'Integration not found' \}/);
|
||||
});
|
||||
|
||||
function run(handlerUrl, verb, integrations) {
|
||||
const calls = [];
|
||||
let handler;
|
||||
const context = {
|
||||
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(v => [v, (url, fn) => {
|
||||
if (v === verb && url === handlerUrl) handler = fn;
|
||||
}])) },
|
||||
Authentication: { async checkBoardAdmin() {} },
|
||||
ReactiveCache: { async getIntegration(selector) {
|
||||
return integrations.find(i => i._id === selector._id && i.boardId === selector.boardId);
|
||||
} },
|
||||
Integrations: { direct: { async updateAsync(...args) { calls.push(args); return 1; } } },
|
||||
normalizeDeliverySettings: x => x,
|
||||
publicErrorData,
|
||||
sendJsonResult(res, result) { res.responses.push(JSON.parse(JSON.stringify(result))); },
|
||||
};
|
||||
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
|
||||
return {
|
||||
calls,
|
||||
async call(params, body) {
|
||||
const res = { responses: [] };
|
||||
await handler({ userId: 'u', params, body }, res);
|
||||
return res.responses[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const verb of ['post', 'delete']) {
|
||||
test(`${verb.toUpperCase()} .../activities answers 404 and writes nothing for a missing or foreign integration`, async () => {
|
||||
const url = '/api/boards/:boardId/integrations/:intId/activities';
|
||||
const r = run(url, verb, [{ _id: 'i1', boardId: 'b1' }, { _id: 'i2', boardId: 'b2' }]);
|
||||
assert.deepEqual(await r.call({ boardId: 'b1', intId: 'nope' }, { activities: ['a'] }), { code: 404, data: { error: 'Integration not found' } });
|
||||
assert.deepEqual(await r.call({ boardId: 'b1', intId: 'i2' }, { activities: ['a'] }), { code: 404, data: { error: 'Integration not found' } });
|
||||
assert.equal(r.calls.length, 0, 'no write happened');
|
||||
// positive: its own integration is still updated
|
||||
const ok = await r.call({ boardId: 'b1', intId: 'i1' }, { activities: ['a'] });
|
||||
assert.equal(ok.code, 200);
|
||||
assert.equal(r.calls.length, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { test } = require('node:test');
|
||||
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
|
||||
|
||||
// Execute the registered REST handlers. The Playwright suite verifies actual
|
||||
// persistence and the feature checkboxes in Admin Panel > People.
|
||||
for (const [kind, collection, label] of [
|
||||
['org', 'Org', 'Organization'], ['team', 'Team', 'Team'],
|
||||
]) {
|
||||
const source = fs.readFileSync(path.join(__dirname, `../server/models/${kind}.js`), 'utf8');
|
||||
const route = `/api/admin/${kind}s/:${kind}Id/features`;
|
||||
const start = source.indexOf(`WebApp.handlers.put('${route}'`);
|
||||
const end = source.indexOf('\n/**', start);
|
||||
assert.ok(start >= 0 && end > start, `${kind} feature route exists`);
|
||||
const fields = ['SharedTemplates', 'PropagateMembersToBoards', 'SyncMembersFromAuth']
|
||||
.map(suffix => kind + suffix);
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function fixture({ missing, denied, lookupError } = {}) {
|
||||
const calls = [];
|
||||
const document = { _id: 'existing', [fields[0]]: false };
|
||||
let handler;
|
||||
vm.runInNewContext(source.slice(start, end), {
|
||||
WebApp: { handlers: { put(url, callback) {
|
||||
assert.equal(url, route);
|
||||
handler = callback;
|
||||
} } },
|
||||
Authentication: { async checkUserId(userId) {
|
||||
await Promise.resolve();
|
||||
calls.push(['authorize', userId]);
|
||||
if (denied) throw Object.assign(new Error('Denied'), { statusCode: denied });
|
||||
} },
|
||||
[`${kind.toUpperCase()}_FEATURE_FIELDS`]: fields,
|
||||
[collection]: {
|
||||
async updateAsync(id, modifier) {
|
||||
calls.push(['update', id, plain(modifier)]);
|
||||
if (missing) return 0;
|
||||
Object.assign(document, modifier.$set);
|
||||
return 1;
|
||||
},
|
||||
async findOneAsync(id) {
|
||||
calls.push(['lookup', id]);
|
||||
if (lookupError) throw lookupError;
|
||||
return missing ? missing.value : plain(document);
|
||||
},
|
||||
},
|
||||
publicErrorData,
|
||||
sendJsonResult(res, result) {
|
||||
res.responses.push({ code: result.code, data: result.data && plain(result.data) });
|
||||
},
|
||||
});
|
||||
return {
|
||||
calls, document,
|
||||
async put(body, id = 'existing', userId = 'admin') {
|
||||
const res = { responses: [] };
|
||||
await handler({ userId, params: { [`${kind}Id`]: id }, body }, res);
|
||||
assert.equal(res.responses.length, 1, 'respond exactly once');
|
||||
return res.responses[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const value of [undefined, null]) {
|
||||
for (const body of [{ [fields[0]]: true }, {}, { unrelated: true }]) {
|
||||
test(`${kind}: missing record (${value}), body ${JSON.stringify(body)} returns JSON 404`, async () => {
|
||||
const f = fixture({ missing: { value } });
|
||||
const before = plain(f.document);
|
||||
assert.deepEqual(await f.put(body, 'missing'), {
|
||||
code: 404, data: { error: `${label} not found` },
|
||||
});
|
||||
assert.deepEqual(f.document, before);
|
||||
assert.deepEqual(f.calls[0], ['authorize', 'admin']);
|
||||
assert.deepEqual(f.calls.at(-1), ['lookup', 'missing']);
|
||||
assert.ok(f.calls.filter(call => call[0] === 'update').every(call => call[1] === 'missing'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test(`${kind}: existing records still set and clear each whitelisted feature`, async () => {
|
||||
const f = fixture();
|
||||
for (const field of fields) {
|
||||
for (const value of [true, false]) {
|
||||
const response = await f.put({ [field]: value, unrelated: true });
|
||||
assert.equal(response.code, 200);
|
||||
assert.equal(response.data._id, 'existing');
|
||||
assert.equal(response.data[field], value);
|
||||
assert.equal(f.document[field], value);
|
||||
assert.equal(f.document.unrelated, undefined);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(f.calls.slice(0, 3), [
|
||||
['authorize', 'admin'], ['update', 'existing', { $set: { [fields[0]]: true } }],
|
||||
['lookup', 'existing'],
|
||||
]);
|
||||
});
|
||||
|
||||
test(`${kind}: empty and unrecognized bodies return an existing record without writing`, async () => {
|
||||
for (const body of [undefined, {}, { unrelated: true }]) {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await f.put(body), { code: 200, data: f.document });
|
||||
assert.deepEqual(f.calls, [['authorize', 'admin'], ['lookup', 'existing']]);
|
||||
}
|
||||
});
|
||||
|
||||
for (const denied of [401, 403]) {
|
||||
test(`${kind}: authorization ${denied} precedes lookup and mutation`, async () => {
|
||||
const f = fixture({ denied });
|
||||
assert.equal((await f.put({ [fields[0]]: true }, 'missing')).code, denied);
|
||||
assert.deepEqual(f.calls, [['authorize', 'admin']]);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${kind}: database failures retain the safe error response`, async () => {
|
||||
const f = fixture({ lookupError: new Error('private database detail') });
|
||||
assert.deepEqual(await f.put({}), { code: 500, data: { error: 'Internal server error' } });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
// Execute the registered user PUT handler; Playwright covers real persistence
|
||||
// and the People page against a running server.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { test } = require('node:test');
|
||||
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '../server/models/users.js'), 'utf8');
|
||||
const start = source.indexOf("WebApp.handlers.put('/api/users/:userId'");
|
||||
const end = source.indexOf("WebApp.handlers.post('/api/boards/:boardId/members/:userId/add'", start);
|
||||
assert.ok(start >= 0 && end > start, 'the user PUT route is present');
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function fixture({ missing, denied, lookupError } = {}) {
|
||||
const calls = [];
|
||||
const document = {
|
||||
_id: 'user', username: 'test-user', loginDisabled: false,
|
||||
services: { resume: { loginTokens: [{ hashedToken: 'secret' }] } },
|
||||
sessionData: { private: true },
|
||||
};
|
||||
let handler;
|
||||
const context = {
|
||||
WebApp: { handlers: { put(route, callback) {
|
||||
assert.equal(route, '/api/users/:userId');
|
||||
handler = callback;
|
||||
} } },
|
||||
Authentication: { async checkUserId(userId) {
|
||||
await Promise.resolve();
|
||||
calls.push(['authorize', userId]);
|
||||
if (denied) throw Object.assign(new Error('Denied'), { statusCode: denied });
|
||||
} },
|
||||
ReactiveCache: {
|
||||
async getUser(selector) {
|
||||
calls.push(['lookup', plain(selector)]);
|
||||
if (lookupError) throw lookupError;
|
||||
return missing ? missing.value : plain(document);
|
||||
},
|
||||
async getBoards() { calls.push(['boards']); return []; },
|
||||
},
|
||||
Users: { async updateAsync(selector, modifier) {
|
||||
calls.push(['update', plain(selector), plain(modifier)]);
|
||||
document.loginDisabled = modifier.$set.loginDisabled;
|
||||
if (modifier.$set['services.resume.loginTokens']) document.services.resume.loginTokens = [];
|
||||
return 1;
|
||||
} },
|
||||
publicErrorData,
|
||||
sendJsonResult(res, result) { res.responses.push(plain(result)); },
|
||||
};
|
||||
const secretsStart = source.indexOf('function withoutSecrets(');
|
||||
const secretsEnd = source.indexOf("WebApp.handlers.get('/api/users/:userId'", secretsStart);
|
||||
vm.runInNewContext(source.slice(secretsStart, secretsEnd) + source.slice(start, end), context);
|
||||
return {
|
||||
calls, document,
|
||||
async put(action, id = 'user', userId = 'admin') {
|
||||
const res = { responses: [] };
|
||||
await handler({ userId, params: { userId: id }, body: { action } }, res);
|
||||
assert.equal(res.responses.length, 1, 'respond exactly once');
|
||||
return res.responses[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const value of [undefined, null]) {
|
||||
for (const action of ['disableLogin', 'enableLogin', 'takeOwnership', undefined]) {
|
||||
test(`missing user (${value}) returns 404 for ${action || 'no action'} without side effects`, async () => {
|
||||
const f = fixture({ missing: { value } });
|
||||
const before = plain(f.document);
|
||||
assert.deepEqual(await f.put(action, 'missing'), { code: 404, data: { error: 'User not found' } });
|
||||
assert.deepEqual(f.calls, [['authorize', 'admin'], ['lookup', { _id: 'missing' }]]);
|
||||
assert.deepEqual(f.document, before);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('existing users can be disabled and enabled with sanitized responses', async () => {
|
||||
const f = fixture();
|
||||
for (const [action, disabled] of [['disableLogin', true], ['enableLogin', false]]) {
|
||||
assert.deepEqual(await f.put(action), {
|
||||
code: 200, data: { _id: 'user', username: 'test-user', loginDisabled: disabled },
|
||||
});
|
||||
assert.equal(f.document.loginDisabled, disabled);
|
||||
assert.deepEqual(f.document.services.resume.loginTokens, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('self-disable and an empty action keep their existing no-op behavior', async () => {
|
||||
const f = fixture();
|
||||
for (const action of ['disableLogin', undefined]) {
|
||||
const response = await f.put(action, 'user', 'user');
|
||||
assert.equal(response.code, 200);
|
||||
assert.equal(response.data.loginDisabled, false);
|
||||
}
|
||||
assert.ok(!f.calls.some(([name]) => name === 'update'));
|
||||
});
|
||||
|
||||
test('ownership of an existing user with no boards still returns an empty list', async () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await f.put('takeOwnership'), { code: 200, data: [] });
|
||||
assert.ok(f.calls.some(([name]) => name === 'boards'));
|
||||
});
|
||||
|
||||
test('authentication and admin authorization finish before the target lookup', async () => {
|
||||
for (const denied of [401, 403]) {
|
||||
for (const id of ['user', 'missing']) {
|
||||
const f = fixture({ denied });
|
||||
assert.equal((await f.put('disableLogin', id)).code, denied);
|
||||
assert.deepEqual(f.calls, [['authorize', 'admin']]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a database failure remains a sanitized server error', async () => {
|
||||
const f = fixture({ lookupError: new Error('database unavailable') });
|
||||
assert.deepEqual(await f.put('disableLogin'), {
|
||||
code: 500, data: { error: 'Internal server error' },
|
||||
});
|
||||
assert.ok(!f.calls.some(([name]) => name === 'update' || name === 'boards'));
|
||||
});
|
||||
@@ -29,21 +29,30 @@ const fnText = fnOf('this_attempts_log') + fnOf('infra_failure');
|
||||
// The line that records which logs existed when the attempt started.
|
||||
const logsBefore = /\n *(logs_before=" \$\(ls [^\n]*)\n/.exec(step)[1];
|
||||
|
||||
function infraFailure({ remoteLog = '', buildLogs = {}, oldBuildLogs = {} }) {
|
||||
function infraFailure({ remoteLog = '', buildLogs = {}, oldBuildLogs = {}, execLogs = {}, oldExecLogs = {} }) {
|
||||
const dir = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'lp-'));
|
||||
try {
|
||||
for (const [name, text] of Object.entries(oldBuildLogs)) {
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
fs.utimesSync(path.join(dir, name), new Date(2000, 0, 1), new Date(2000, 0, 1));
|
||||
}
|
||||
// snapcraft's execution logs live in their own directory, not the cwd.
|
||||
fs.mkdirSync(path.join(dir, 'sclog'));
|
||||
for (const [name, text] of Object.entries(oldExecLogs)) {
|
||||
fs.writeFileSync(path.join(dir, 'sclog', name), text);
|
||||
fs.utimesSync(path.join(dir, 'sclog', name), new Date(2000, 0, 1), new Date(2000, 0, 1));
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'marker'), '');
|
||||
fs.utimesSync(path.join(dir, 'marker'), new Date(2010, 0, 1), new Date(2010, 0, 1));
|
||||
// This attempt's logs arrive after logs_before is taken, as on Launchpad.
|
||||
fs.mkdirSync(path.join(dir, 'new'));
|
||||
for (const [name, text] of Object.entries(buildLogs)) fs.writeFileSync(path.join(dir, 'new', name), text);
|
||||
fs.mkdirSync(path.join(dir, 'newsc'));
|
||||
for (const [name, text] of Object.entries(execLogs)) fs.writeFileSync(path.join(dir, 'newsc', name), text);
|
||||
fs.writeFileSync(path.join(dir, 'remote.log'), remoteLog);
|
||||
const script = `cd "${dir}"; remote_log=remote.log; attempt_marker=marker\n${logsBefore}\n`
|
||||
+ `for f in new/*; do [ -e "$f" ] && mv "$f" .; done\n${fnText}\ninfra_failure`;
|
||||
const script = `cd "${dir}"; remote_log=remote.log; attempt_marker=marker; snapcraft_log_dir="${dir}/sclog"\n${logsBefore}\n`
|
||||
+ `for f in new/*; do [ -e "$f" ] && mv "$f" .; done\n`
|
||||
+ `for f in newsc/*; do [ -e "$f" ] && mv "$f" sclog/; done\n${fnText}\ninfra_failure`;
|
||||
return spawnSync('bash', ['-c', script]).status === 0;
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -93,6 +102,37 @@ test('a new log counts even in the marker\'s own second (bash 3.2 -nt is whole s
|
||||
}
|
||||
});
|
||||
|
||||
// v12.96 s390x: snapcraft printed only its last exception; the 503 that caused
|
||||
// it was in the "Full execution log" file alone.
|
||||
const V1296_STDOUT = 'Recovering build snapcraft-wekan-5326\n'
|
||||
+ 'Could not find repository at path ~xet7/xet7-craft-remote-build/+git/snapcraft-wekan-5326\n'
|
||||
+ "Full execution log: '/home/runner/.local/state/snapcraft/log/snapcraft-20261105-201734.420875.log'\n";
|
||||
const V1296_EXEC = 'lazr.restfulclient.errors.ServerError: HTTP Error 503: Service Unavailable\n'
|
||||
+ "b'<html><head><title>Error: Timeout</title>'\n"
|
||||
+ 'craft_application.launchpad.errors.NotFoundError: Could not find repository at path ~xet7/x\n';
|
||||
|
||||
test('v12.96 s390x: a 503 only in this attempt\'s snapcraft execution log is infrastructure', () => {
|
||||
assert.equal(infraFailure({ remoteLog: V1296_STDOUT, execLogs: { 'snapcraft-20261105-201734.420875.log': V1296_EXEC } }), true);
|
||||
});
|
||||
|
||||
test('negative: an earlier attempt\'s execution log does not count for this one', () => {
|
||||
assert.equal(infraFailure({ remoteLog: V1296_STDOUT, oldExecLogs: { 'snapcraft-20261105-201734.420875.log': V1296_EXEC } }), false);
|
||||
});
|
||||
|
||||
test('negative: an execution log with no outage in it is not infrastructure', () => {
|
||||
assert.equal(infraFailure({ remoteLog: V1296_STDOUT, execLogs: { 'snapcraft-x.log': 'Stage package not found in part \'wekan\': libssl3\n' } }), false);
|
||||
});
|
||||
|
||||
test('v12.96 s390x attempt 3: a half-created repository refusing our push is infrastructure', () => {
|
||||
assert.equal(infraFailure({ remoteLog: 'remote: You do not have permission to create refs/heads/main.\n'
|
||||
+ "Git operation failed with: Could not push 'HEAD' to 'git.launchpad.net/~xet7/x' with refspec 'refs/heads/master:refs/heads/main'\n" }), true);
|
||||
});
|
||||
|
||||
test('the execution logs that existed before the attempt are recorded with the build logs', () => {
|
||||
assert.match(logsBefore, /"\$snapcraft_log_dir"\/\*\.log/);
|
||||
assert.match(step, /\n\s+snapcraft_log_dir="\$HOME\/\.local\/state\/snapcraft\/log"\n/);
|
||||
});
|
||||
|
||||
test('negative: a 4xx from Launchpad is not taken for an outage', () => {
|
||||
assert.equal(infraFailure({ remoteLog: 'HTTP Error 400: Bad Request' }), false);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user