mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 12:45:03 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60377a4dc2 | ||
|
|
5c45d55d70 | ||
|
|
e5a84cbcef | ||
|
|
1ef7a57ce4 | ||
|
|
9caf0c8653 | ||
|
|
adb73fd8ee | ||
|
|
ea5724babd | ||
|
|
b895e61237 | ||
|
|
3108e017fc | ||
|
|
081690875d | ||
|
|
68b575637d | ||
|
|
6d5f660c27 | ||
|
|
f54aa93dc1 | ||
|
|
e3458004ec | ||
|
|
6ae4ad3545 | ||
|
|
b0a71ba57b | ||
|
|
f3d013339c | ||
|
|
95ea04b049 | ||
|
|
3b33c7d7ba | ||
|
|
4762bed8c1 | ||
|
|
029ecb6b39 | ||
|
|
49c309b324 | ||
|
|
e09267cc90 | ||
|
|
b46f155fa3 | ||
|
|
0c0288a6c5 | ||
|
|
c61a1b7f41 |
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=$?
|
||||
|
||||
@@ -427,6 +427,48 @@ directly after the merge.
|
||||
application-specific names (say "the client" / "a Meteor 3 driver" / a bare
|
||||
`#NNNN`); its `CHANGELOG.md` may use `wekan/wekan#NNNN`.
|
||||
|
||||
### Gambas Web Kanban companion: `.tools/weba`
|
||||
|
||||
- `.tools/weba` is the separate `wekan/weba` repository, a Gambas Web UI
|
||||
Kanban application with SQLite by default. Its project is `.tools/weba/WeKan`,
|
||||
its entry form is `WeKan/.src/WebformLogin.class`, and its logged-in UI is
|
||||
`WeKan/.src/WebformMain.class` / `.webform`. Custom browser reports live in
|
||||
`WeKan/.public/kanban.js` and `kanban.css`; database access lives in
|
||||
`WebDatabase.module` and `KanbanStore.module`. These files belong to weba,
|
||||
never to a WeKan commit.
|
||||
- Apply this repository's author, local-commit-only and no-push rules there too:
|
||||
check weba's own identity and branch, commit as Lauri Ojansivu on `main`, update
|
||||
its own `CHANGELOG.md` in its existing format. Do not add instruction files to
|
||||
weba. Track active work, limitations and remaining WeKan page parity in its
|
||||
`TODO.md`, keeping completed work distinct from unverified work.
|
||||
- The user-provided Gambas installation and source are under
|
||||
`.tools/bin/gambas3`: IDE archives in `bin/`, components in `lib/gambas3/`,
|
||||
class metadata in `usr/share/gambas3/info/`, and upstream source in
|
||||
`src/gambas/`. Inspect the actual files before use: the initial copy contains
|
||||
the IDE archives but no `bin/gbc3` or `bin/gbx3`. An IDE archive requires the
|
||||
Gambas interpreter and is not itself the compiler. Do not assume a complete
|
||||
runtime or a particular CPU architecture from the directory name.
|
||||
- Gambas tools are `gbc3` (compiler), `gbx3` (project runner), and `gba3`
|
||||
(archiver). Compile with `gbc3 -j 1 -a .tools/weba/WeKan`; `-j 1` avoids
|
||||
restricted process-group errors in the sandbox. A relocated compiler accepts
|
||||
`-r <installation-prefix>`; a relocated runner may need `GB_PATH` pointing
|
||||
at its component directory and `LD_LIBRARY_PATH` for local native dependencies.
|
||||
Keep these settings scoped to the command. Place downloaded test toolchains
|
||||
under `.tools/` and set repository-local `TMPDIR` before builds and tests.
|
||||
- Run `tests/run-store.sh` from weba with `TMPDIR` set; it copies the project and
|
||||
SQLite database before CRUD and authentication tests, so never run its test
|
||||
modules directly against the real database. `GBC` and `GBX` can select local
|
||||
executables. Run `node tests/reports.test.cjs` with `jsdom` available (the
|
||||
parent WeKan `node_modules` can supply it through `NODE_PATH`). A successful
|
||||
compile or DOM test does not establish a working browser login session.
|
||||
- Prefer Gambas `Connection.Tables` / `Fields` for schema creation and driver
|
||||
`Find`, `Create`, `Edit`, `LastInsertId` and parameterized `Exec` for data.
|
||||
Do not introduce SQLite-specific DDL, PRAGMA, SQL date functions, conflict
|
||||
clauses or driver-specific generated-key SQL. SQLite is the tested default;
|
||||
other writable Gambas drivers need separate conformance runs and their driver
|
||||
components enabled. Connection overrides are `WEBA_DB_TYPE`, `WEBA_DB_HOST`,
|
||||
`WEBA_DB_NAME`, `WEBA_DB_USER` and `WEBA_DB_PASSWORD`; never log credentials.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
|
||||
@@ -124,6 +124,226 @@ its reason.
|
||||
|
||||
</details>
|
||||
|
||||
# v12.98 2026-11-06 WeKan ® release
|
||||
|
||||
**In short:** **GroupLinkBleed** is fixed: a board admin can no longer skip the
|
||||
board methods by writing the board directly, to attach an organization or team
|
||||
they do not belong to or to add a member past the invitation checks.
|
||||
**Public board CSV and TSV exports** honour the selected columns, and the REST
|
||||
**card archive, restore and custom-field** endpoints answer an unmatched card
|
||||
with a JSON 404 instead of 500, and a REST **card update** can set sort 0, turn
|
||||
overtime off and clear a description. The **notifications drawer** gets its
|
||||
attachments again, and the developer instructions cover the **weba** Gambas
|
||||
companion repository.
|
||||
|
||||
This release fixes the following CRITICAL SECURITY ISSUE of [GroupLinkBleed](https://wekan.fi/hall-of-fame/grouplinkbleed/):
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/e3458004ec">Board admins can no longer attach foreign teams or add members by direct board writes</a>. Thanks to bhaswanthc and xet7.</summary>
|
||||
|
||||
[GroupLinkBleed](https://wekan.fi/hall-of-fame/grouplinkbleed/),
|
||||
[GHSA-v544-rmr5-38gx](https://github.com/wekan/wekan/security/advisories/GHSA-v544-rmr5-38gx).
|
||||
The board allow rule lets a board admin update any field, and two checks lived
|
||||
only in Meteor methods: adding only the organizations and teams the admin
|
||||
belongs to ([#5141](https://github.com/wekan/wekan/issues/5141)), and the
|
||||
invitation's checks, including the same organization/team policy
|
||||
([#6116](https://github.com/wekan/wekan/issues/6116)). A client writing the
|
||||
board over DDP skipped both, so a whole team could be given an attacker's
|
||||
board and any user added to it. Client writes naming `orgs`, `teams` or
|
||||
`domains`, and client updates that add or reactivate a member, are now
|
||||
refused and shown in Admin Panel / Problems; role changes and removals still
|
||||
pass. `tests/groupLinkBleed.test.cjs` runs the real deny rules and fails if a
|
||||
method-guarded board field is client-writable.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
**Exports** - the CSV and TSV columns a public board exports.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/4762bed8c1">Public board CSV and TSV exports honour the selected fields</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
On a public board the CSV and TSV export ignored `fields=` and wrote all 20
|
||||
columns, while a private board wrote only the selected ones (PR
|
||||
[#6875](https://github.com/wekan/wekan/pull/6875)). The validated selection is
|
||||
now parsed once, before the public/private branch, so both get the same
|
||||
columns; a missing, empty or unrecognised selection still exports all of them,
|
||||
and the export guards are unchanged. `tests/publicCsvExportFields.test.cjs`
|
||||
drives the route for comma, semicolon and tab, and a Playwright spec checks the
|
||||
export menu's checkboxes on a public board.
|
||||
|
||||
</details>
|
||||
|
||||
**REST API** - what the card endpoints accept, and answer when no card matches.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/3b33c7d7ba">Card archive and restore answer a JSON 404 for an unmatched card instead of 500</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`POST …/cards/:cardId/archive` and `…/unarchive` threw `Meteor.Error(404)`
|
||||
when no card matched the board, list, id and archive state. A WebApp handler is
|
||||
a plain connect handler, so that reached the client as HTTP 500 (PR
|
||||
[#6876](https://github.com/wekan/wekan/pull/6876)). They now answer through
|
||||
`sendJsonResult` with `{ error: 'Card not found' }` and 404, writing nothing.
|
||||
`tests/restCardArchive.test.cjs` and a Playwright spec cover a missing card,
|
||||
the wrong list, the wrong board, the wrong state and refused callers.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b0a71ba57b">A card update can set sort to 0</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT …/cards/:cardId` with `{ "sort": 0 }` answered 404 and left the card
|
||||
where it was, because the handler read `sort` by truthiness (PR
|
||||
[#6877](https://github.com/wekan/wekan/pull/6877)). It now writes any sort that
|
||||
is present. `tests/restCardSort.test.cjs` and a Playwright spec check 0, other
|
||||
numbers, form-encoded `'0'`, an omitted sort and refused callers.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b895e61237">A card update checks sort, isOverTime and description, so false and empty work</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
Writing `sort` whenever it is present would also have written `null`, `''` or
|
||||
`'abc'` as the card's position, and the same handler read `isOverTime` and
|
||||
`description` by truthiness, so overtime could not be turned off and a
|
||||
description could not be cleared. `parseCardScalarFields()` decides all three
|
||||
by presence before the first write: sort a finite number, isOverTime a boolean
|
||||
(`'true'`/`'false'` from a form too), description a string or `null` to clear.
|
||||
Anything else is a 400 and the card is unchanged.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/6d5f660c27">The card custom-field update answers a JSON 404 for an unmatched card instead of 500</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`POST …/cards/:cardId/customFields/:customFieldId` had the same
|
||||
`throw new Meteor.Error(404)` as the archive endpoints, and now answers the
|
||||
same JSON 404. `tests/restHandlerNotFound.test.cjs` drives it for a matching,
|
||||
missing, wrong-list, wrong-board and archived card, and scans every
|
||||
`WebApp.handlers` route in the tree so no REST handler answers not-found by
|
||||
throwing again.
|
||||
|
||||
</details>
|
||||
|
||||
**Notifications** - what the notifications drawer receives.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/adb73fd8ee">The notifications drawer receives its attachments again, without a server exception</a>. Thanks to xet7.</summary>
|
||||
|
||||
Every `notificationAttachments` subscription failed with `Publish function can
|
||||
only return a Cursor or an array of Cursors`: Attachments is an ostrio:files
|
||||
FilesCollection, and the publication returned its FilesCursor instead of the
|
||||
Mongo cursor it wraps. It now returns `.cursor`, as every other attachment
|
||||
publication does. `tests/publishFilesCursor.test.cjs` runs the publication and
|
||||
scans every publishing file so no attachment or avatar cursor query is
|
||||
published without it.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following developer-facing changes:
|
||||
|
||||
**Tests** - guards that had drifted from the code they pin.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/68b575637d">Three client guards follow the code they pin again</a>. Thanks to xet7.</summary>
|
||||
|
||||
`sidebarBelowHeader`, `maximizedCardPosition` and `titleClickToEdit` read
|
||||
source that the full screen button, the phone-below-header rule and the
|
||||
Pointer Events drag had since moved. The behaviour had not changed; the guards
|
||||
now read the new places, `titleClickToEdit` calls `cardHeaderPress()` from
|
||||
`models/lib/windowDrag.js`, and each still fails when its behaviour is removed.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/081690875d">The real single executable test says why it cannot run on a kernel with large pages</a>. Thanks to xet7.</summary>
|
||||
|
||||
`--build-sea` injects the SEA blob with LIEF, which adds 4 KiB-aligned
|
||||
segments; a Linux kernel with 16 KiB or 64 KiB pages cannot map them, and the
|
||||
executable dies with SIGSEGV - a one-line `console.log` SEA does too. The test
|
||||
failed there as `null !== 0`; it is now skipped on such a kernel with that
|
||||
reason. Release builders have 4 KiB pages and still run it.
|
||||
|
||||
</details>
|
||||
|
||||
**Developer instructions** - what an assistant working in this repository is
|
||||
told.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/1ef7a57ce4">AGENTS.md and CLAUDE.md describe the weba companion repository</a>. Thanks to xet7.</summary>
|
||||
|
||||
`.tools/weba` is the separate `wekan/weba` repository, a Gambas Web UI Kanban
|
||||
with SQLite by default. The instructions say where its project, forms, browser
|
||||
reports and database modules are, that WeKan's author, local-commit and no-push
|
||||
rules apply there too, how to compile and run it with `gbc3`/`gbx3` from the
|
||||
local Gambas under `.tools/bin/gambas3`, how its tests run on a copy of the
|
||||
database, and to keep its SQL portable across Gambas database drivers.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# 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
|
||||
|
||||
+23
-16
@@ -5,6 +5,29 @@ each one changed and the project status are in
|
||||
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
|
||||
make changes there.
|
||||
|
||||
# v12.98 2026-11-06 WeKan ® release
|
||||
|
||||
**GroupLinkBleed** is fixed: a board admin can no longer skip the
|
||||
board methods by writing the board directly, to attach an organization or team
|
||||
they do not belong to or to add a member past the invitation checks.
|
||||
**Public board CSV and TSV exports** honour the selected columns, and the REST
|
||||
**card archive, restore and custom-field** endpoints answer an unmatched card
|
||||
with a JSON 404 instead of 500, and a REST **card update** can set sort 0, turn
|
||||
overtime off and clear a description. The **notifications drawer** gets its
|
||||
attachments again, and the developer instructions cover the **weba** Gambas
|
||||
companion repository.
|
||||
|
||||
[All changes in v12.98](CHANGELOG-big.md#v1298-2026-11-06-wekan--release)
|
||||
|
||||
# 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
|
||||
@@ -33,19 +56,3 @@ 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
|
||||
from fastest to slowest with how many times slower each database is, and two
|
||||
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)
|
||||
@@ -427,6 +427,48 @@ directly after the merge.
|
||||
application-specific names (say "the client" / "a Meteor 3 driver" / a bare
|
||||
`#NNNN`); its `CHANGELOG.md` may use `wekan/wekan#NNNN`.
|
||||
|
||||
### Gambas Web Kanban companion: `.tools/weba`
|
||||
|
||||
- `.tools/weba` is the separate `wekan/weba` repository, a Gambas Web UI
|
||||
Kanban application with SQLite by default. Its project is `.tools/weba/WeKan`,
|
||||
its entry form is `WeKan/.src/WebformLogin.class`, and its logged-in UI is
|
||||
`WeKan/.src/WebformMain.class` / `.webform`. Custom browser reports live in
|
||||
`WeKan/.public/kanban.js` and `kanban.css`; database access lives in
|
||||
`WebDatabase.module` and `KanbanStore.module`. These files belong to weba,
|
||||
never to a WeKan commit.
|
||||
- Apply this repository's author, local-commit-only and no-push rules there too:
|
||||
check weba's own identity and branch, commit as Lauri Ojansivu on `main`, update
|
||||
its own `CHANGELOG.md` in its existing format. Do not add instruction files to
|
||||
weba. Track active work, limitations and remaining WeKan page parity in its
|
||||
`TODO.md`, keeping completed work distinct from unverified work.
|
||||
- The user-provided Gambas installation and source are under
|
||||
`.tools/bin/gambas3`: IDE archives in `bin/`, components in `lib/gambas3/`,
|
||||
class metadata in `usr/share/gambas3/info/`, and upstream source in
|
||||
`src/gambas/`. Inspect the actual files before use: the initial copy contains
|
||||
the IDE archives but no `bin/gbc3` or `bin/gbx3`. An IDE archive requires the
|
||||
Gambas interpreter and is not itself the compiler. Do not assume a complete
|
||||
runtime or a particular CPU architecture from the directory name.
|
||||
- Gambas tools are `gbc3` (compiler), `gbx3` (project runner), and `gba3`
|
||||
(archiver). Compile with `gbc3 -j 1 -a .tools/weba/WeKan`; `-j 1` avoids
|
||||
restricted process-group errors in the sandbox. A relocated compiler accepts
|
||||
`-r <installation-prefix>`; a relocated runner may need `GB_PATH` pointing
|
||||
at its component directory and `LD_LIBRARY_PATH` for local native dependencies.
|
||||
Keep these settings scoped to the command. Place downloaded test toolchains
|
||||
under `.tools/` and set repository-local `TMPDIR` before builds and tests.
|
||||
- Run `tests/run-store.sh` from weba with `TMPDIR` set; it copies the project and
|
||||
SQLite database before CRUD and authentication tests, so never run its test
|
||||
modules directly against the real database. `GBC` and `GBX` can select local
|
||||
executables. Run `node tests/reports.test.cjs` with `jsdom` available (the
|
||||
parent WeKan `node_modules` can supply it through `NODE_PATH`). A successful
|
||||
compile or DOM test does not establish a working browser login session.
|
||||
- Prefer Gambas `Connection.Tables` / `Fields` for schema creation and driver
|
||||
`Find`, `Create`, `Edit`, `LastInsertId` and parameterized `Exec` for data.
|
||||
Do not introduce SQLite-specific DDL, PRAGMA, SQL date functions, conflict
|
||||
clauses or driver-specific generated-key SQL. SQLite is the tested default;
|
||||
other writable Gambas drivers need separate conformance runs and their driver
|
||||
components enabled. Connection overrides are `WEBA_DB_TYPE`, `WEBA_DB_HOST`,
|
||||
`WEBA_DB_NAME`, `WEBA_DB_USER` and `WEBA_DB_PASSWORD`; never log credentials.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.96
|
||||
ARG VERSION=12.98
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.96.0"
|
||||
appVersion: "v12.98.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
|
||||
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-12.96-win64.zip](https://github.com/wekan/wekan/releases/download/v12.96/wekan-12.96-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-12.98-win64.zip](https://github.com/wekan/wekan/releases/download/v12.98/wekan-12.98-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.96-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-12.98-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
+7
-6
@@ -569,10 +569,15 @@ if (Meteor.isServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The same column selection applies to public and private boards.
|
||||
const exportOptions = {
|
||||
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
|
||||
};
|
||||
|
||||
// If board is public, skip expensive authentication operations
|
||||
if (board.isPublic()) {
|
||||
// Public boards don't require authentication - skip hash operations
|
||||
const exporter = new Exporter(boardId);
|
||||
const exporter = new Exporter(boardId, undefined, exportOptions);
|
||||
|
||||
if( req.query.delimiter == "\t" ) {
|
||||
// TSV file
|
||||
@@ -632,11 +637,7 @@ if (Meteor.isServer) {
|
||||
});
|
||||
}
|
||||
|
||||
// #1173: the same selection every other board export honours - on a CSV it
|
||||
// lands on COLUMNS, since that is what a CSV has.
|
||||
const exporter = new Exporter(boardId, undefined, {
|
||||
fields: parseExportFields(req.query && req.query.fields, BOARD_EXPORT_FIELD_KEYS),
|
||||
});
|
||||
const exporter = new Exporter(boardId, undefined, exportOptions);
|
||||
if (await exporter.canExport(user)) {
|
||||
if (impersonateDone) {
|
||||
let exportType = 'exportCSV';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
// GroupLinkBleed (GHSA-v544-rmr5-38gx): which client writes to a board skip a
|
||||
// check that only a Meteor method makes. Used by the Boards.deny rules in
|
||||
// server/permissions/boards.js. Pure: tests/groupLinkBleed.test.cjs.
|
||||
|
||||
// orgs, teams and domains are written only by setBoardOrgs, setBoardTeams and
|
||||
// setBoardDomains (#5141 own groups only, #5850 domain validation).
|
||||
const GROUP_LINK_FIELDS = ['orgs', 'teams', 'domains'];
|
||||
|
||||
function isGroupLinkField(field) {
|
||||
if (typeof field !== 'string') return false;
|
||||
return GROUP_LINK_FIELDS.some(f => field === f || field.startsWith(`${f}.`));
|
||||
}
|
||||
|
||||
// A client board insert carrying any group link. The UI creates a board with
|
||||
// none and adds them through the methods afterwards.
|
||||
function groupLinksInInsert(doc) {
|
||||
return GROUP_LINK_FIELDS.some(f => doc && Array.isArray(doc[f]) && doc[f].length > 0);
|
||||
}
|
||||
|
||||
// Members may be changed from the client (role changes, removal), never added:
|
||||
// adding goes through inviteUserToBoard. Allowed shapes:
|
||||
// $set members.<i>.<field> for an existing index, not changing userId
|
||||
// $set members.<i> an entry keeping the same userId
|
||||
// $set members an array whose userIds were all on the board
|
||||
// $unset, $pull, $pullAll, $pop (remove only)
|
||||
// Everything else that touches members - $push, $addToSet, $setOnInsert, an
|
||||
// index past the end, a positional or non-numeric index, a new userId - adds.
|
||||
// So does switching a removed (inactive) member back on: removal keeps the
|
||||
// entry with isActive false, and bringing it back is an invitation too.
|
||||
const MEMBER_SAFE_OPS = ['$set', '$unset', '$pull', '$pullAll', '$pop'];
|
||||
const isMembersPath = p => p === 'members' || p.startsWith('members.');
|
||||
|
||||
function addsBoardMember(doc, modifier) {
|
||||
const current = (doc && Array.isArray(doc.members)) ? doc.members : [];
|
||||
const ids = new Set(current.map(m => m && m.userId).filter(Boolean));
|
||||
const inactive = new Set(current.filter(m => m && m.userId && m.isActive !== true).map(m => m.userId));
|
||||
const reactivates = m => m && m.isActive === true && inactive.has(m.userId);
|
||||
for (const [op, args] of Object.entries(modifier || {})) {
|
||||
if (!args || typeof args !== 'object') continue;
|
||||
if (op === '$rename') {
|
||||
if (Object.keys(args).some(isMembersPath) || Object.values(args).some(v => typeof v === 'string' && isMembersPath(v))) return true;
|
||||
continue;
|
||||
}
|
||||
const paths = Object.keys(args).filter(isMembersPath);
|
||||
if (!paths.length) continue;
|
||||
if (!MEMBER_SAFE_OPS.includes(op)) return true;
|
||||
if (op !== '$set') continue;
|
||||
for (const path of paths) {
|
||||
const value = args[path];
|
||||
const parts = path.split('.');
|
||||
if (parts.length === 1) {
|
||||
if (!Array.isArray(value) || value.some(m => !m || !ids.has(m.userId) || reactivates(m))) return true;
|
||||
continue;
|
||||
}
|
||||
if (!/^\d+$/.test(parts[1])) return true;
|
||||
const i = Number(parts[1]);
|
||||
if (i >= current.length) return true;
|
||||
const existing = current[i] && current[i].userId;
|
||||
if (parts.length === 2) {
|
||||
if (!value || typeof value !== 'object' || value.userId !== existing || reactivates(value)) return true;
|
||||
continue;
|
||||
}
|
||||
if (parts[2] === 'userId') return true;
|
||||
if (parts.length === 3 && parts[2] === 'isActive' && value === true && inactive.has(existing)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { GROUP_LINK_FIELDS, isGroupLinkField, groupLinksInInsert, addsBoardMember };
|
||||
@@ -96,6 +96,12 @@ const CATALOG = {
|
||||
'ssrf.webhook-forge': { category: 'ssrf', bleed: 'HookBleed', severity: 'high', cwe: 'CWE-345' },
|
||||
'authz.board-owner': { category: 'authz', bleed: 'OwnerBleed', severity: 'high', cwe: 'CWE-639' },
|
||||
'authz.background': { category: 'authz', bleed: 'BackgroundBleed', severity: 'high', cwe: 'CWE-639' },
|
||||
// GroupLinkBleed (GHSA-v544-rmr5-38gx): a client board write naming orgs,
|
||||
// teams or domains, which the UI changes only through the board methods.
|
||||
'authz.group-link': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
|
||||
// GroupLinkBleed, member variant: a client board update adding a member
|
||||
// instead of going through inviteUserToBoard.
|
||||
'authz.member-add': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
|
||||
'injection.prototype': { category: 'injection', bleed: 'PrototypeBleed', severity: 'high', cwe: 'CWE-1321' },
|
||||
'authz.notification-tray': { category: 'authz', bleed: 'TrayBleed', severity: 'high', cwe: 'CWE-639' },
|
||||
'authn.cas-state': { category: 'authn', bleed: 'CasTokenBleed', severity: 'high', cwe: 'CWE-352' },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.96.0",
|
||||
"version": "v12.98.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v12.96.0",
|
||||
"version": "v12.98.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.96.0",
|
||||
"version": "v12.98.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v12.96</title>
|
||||
<title>WeKan REST API v12.98</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.96","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.98","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
-16
@@ -1,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v12.96
|
||||
version: v12.98
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
@@ -3014,21 +3014,11 @@ paths:
|
||||
description: the title value
|
||||
type: string
|
||||
required: true
|
||||
- name: sort
|
||||
in: formData
|
||||
description: the sort value
|
||||
type: string
|
||||
required: true
|
||||
- name: parentId
|
||||
in: formData
|
||||
description: the parentId value
|
||||
type: string
|
||||
required: true
|
||||
- name: description
|
||||
in: formData
|
||||
description: the description value
|
||||
type: string
|
||||
required: true
|
||||
- name: color
|
||||
in: formData
|
||||
description: the color value
|
||||
@@ -3064,11 +3054,6 @@ paths:
|
||||
description: the spentTime value
|
||||
type: string
|
||||
required: true
|
||||
- name: isOverTime
|
||||
in: formData
|
||||
description: the isOverTime value
|
||||
type: string
|
||||
required: true
|
||||
- name: customFields
|
||||
in: formData
|
||||
description: the customFields value
|
||||
|
||||
+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
|
||||
|
||||
@@ -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 = 1296,
|
||||
appVersion = 1298,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "12.96~2026-11-05"),
|
||||
appMarketingVersion = (defaultText = "12.98~2026-11-06"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// #5398 normalizeMoveParams - one consistent set of board-move params
|
||||
// #5399 computeTopSort - sort value that lands a card on TOP of a list
|
||||
// #5537 parseCardDate - parse an ISO date string into a Date that persists
|
||||
// #6877 parseCardScalarFields - sort 0, isOverTime false, an empty description
|
||||
|
||||
/**
|
||||
* #5399 Moving a card to another list via the API must put it on TOP of the
|
||||
@@ -103,3 +104,42 @@ export function parseCardDate(value) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The card PUT fields whose valid values include a falsy one: sort 0,
|
||||
* isOverTime false and an empty description (#6877). A truthiness check
|
||||
* skipped them, so 0 could not be written, an overtime flag could not be
|
||||
* turned off and a description could not be cleared. Presence decides now,
|
||||
* and the value is checked before the handler writes anything, so a bad
|
||||
* value is a 400 rather than a half-applied edit.
|
||||
*
|
||||
* Form-encoded clients send strings, so '0' is a number and 'true'/'false'
|
||||
* are booleans here, as in JSON.
|
||||
*
|
||||
* @param {object} body the request body
|
||||
* @returns {{ error: string } | { set: object }} what to $set, or why not
|
||||
*/
|
||||
export function parseCardScalarFields(body) {
|
||||
const has = key => !!body && Object.prototype.hasOwnProperty.call(body, key) && body[key] !== undefined;
|
||||
const set = {};
|
||||
if (has('sort')) {
|
||||
const raw = body.sort;
|
||||
const sort = typeof raw === 'number' ? raw
|
||||
: (typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN);
|
||||
if (!Number.isFinite(sort)) return { error: 'Invalid sort' };
|
||||
set.sort = sort;
|
||||
}
|
||||
if (has('isOverTime')) {
|
||||
const raw = body.isOverTime;
|
||||
if (raw === true || raw === 'true') set.isOverTime = true;
|
||||
else if (raw === false || raw === 'false') set.isOverTime = false;
|
||||
else return { error: 'Invalid isOverTime' };
|
||||
}
|
||||
if (has('description')) {
|
||||
const raw = body.description;
|
||||
if (raw === null) set.description = '';
|
||||
else if (typeof raw === 'string') set.description = raw;
|
||||
else return { error: 'Invalid description' };
|
||||
}
|
||||
return { set };
|
||||
}
|
||||
+20
-10
@@ -11,7 +11,7 @@ import { add, now } from '/imports/lib/dateUtils';
|
||||
import { Authentication } from '/server/authentication';
|
||||
import { sendJsonResult } from '/server/apiMiddleware';
|
||||
import { allowIsBoardMember, allowIsBoardMemberWithWriteAccess, computeSortForIndex, mergeLabelIds, canAssignCardMember, isCardDateClear } from '/server/lib/utils';
|
||||
import { computeTopSort, normalizeMoveParams, parseCardDate } from '/server/lib/restCardHelpers';
|
||||
import { computeTopSort, normalizeMoveParams, parseCardDate, parseCardScalarFields } from '/server/lib/restCardHelpers';
|
||||
const { coerceRestArrayParam } = require('/server/lib/restArrayParam');
|
||||
const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency');
|
||||
const { stampCardListEntry } = require('/models/lib/cardListEntry');
|
||||
@@ -1787,6 +1787,13 @@ WebApp.handlers.put(
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
|
||||
return;
|
||||
}
|
||||
// sort 0, isOverTime false and '' are values, not absences (#6877); checked
|
||||
// before the first write, so a bad one leaves the card as it was.
|
||||
const scalars = parseCardScalarFields(req.body);
|
||||
if (scalars.error) {
|
||||
sendJsonResult(res, { code: 400, data: { error: scalars.error } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.body.title) {
|
||||
const newTitle =
|
||||
@@ -1800,10 +1807,10 @@ WebApp.handlers.put(
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if (req.body.sort) {
|
||||
if ('sort' in scalars.set) {
|
||||
await Cards.direct.updateAsync(
|
||||
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
||||
{ $set: { sort: req.body.sort } },
|
||||
{ $set: { sort: scalars.set.sort } },
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
@@ -1826,10 +1833,10 @@ WebApp.handlers.put(
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if (req.body.description) {
|
||||
if ('description' in scalars.set) {
|
||||
await Cards.direct.updateAsync(
|
||||
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
||||
{ $set: { description: req.body.description } },
|
||||
{ $set: { description: scalars.set.description } },
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
@@ -1943,10 +1950,10 @@ WebApp.handlers.put(
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if (req.body.isOverTime) {
|
||||
if ('isOverTime' in scalars.set) {
|
||||
await Cards.direct.updateAsync(
|
||||
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
||||
{ $set: { isOverTime: req.body.isOverTime } },
|
||||
{ $set: { isOverTime: scalars.set.isOverTime } },
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
@@ -2449,7 +2456,8 @@ WebApp.handlers.post(
|
||||
archived: false,
|
||||
});
|
||||
if (!card) {
|
||||
throw new Meteor.Error(404, 'Card not found');
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
|
||||
return;
|
||||
}
|
||||
const updatedCustomFields = (card.customFields || []).map(cf =>
|
||||
cf._id === paramCustomFieldId ? { _id: cf._id, value: paramCustomFieldValue } : cf,
|
||||
@@ -2483,7 +2491,8 @@ WebApp.handlers.post(
|
||||
archived: false,
|
||||
});
|
||||
if (!card) {
|
||||
throw new Meteor.Error(404, 'Card not found');
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
|
||||
return;
|
||||
}
|
||||
await card.archive();
|
||||
sendJsonResult(res, {
|
||||
@@ -2508,7 +2517,8 @@ WebApp.handlers.post(
|
||||
archived: true,
|
||||
});
|
||||
if (!card) {
|
||||
throw new Meteor.Error(404, 'Card not found');
|
||||
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
|
||||
return;
|
||||
}
|
||||
await card.restore();
|
||||
sendJsonResult(res, {
|
||||
|
||||
@@ -108,6 +108,52 @@ Boards.allow({
|
||||
fetch: ['members'],
|
||||
});
|
||||
|
||||
// GroupLinkBleed (GHSA-v544-rmr5-38gx): orgs, teams and domains are written
|
||||
// only by the methods setBoardOrgs, setBoardTeams and setBoardDomains, which
|
||||
// let a board admin add only the organizations and teams they belong to
|
||||
// (#5141) and validate domains (#5850). The allow rule above admits any field
|
||||
// for a board admin, so a client collection write would skip those checks. The
|
||||
// UI never writes these fields directly; the methods write server-side, which
|
||||
// allow/deny does not see.
|
||||
const { isGroupLinkField, groupLinksInInsert, addsBoardMember } = require('/models/lib/boardWriteGuards');
|
||||
function recordBoardWriteDenial(key, userId, source, detail) {
|
||||
try {
|
||||
require('/server/lib/securityLog').record({ key, action: 'blocked', source, userId, detail });
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
}
|
||||
Boards.deny({
|
||||
insert(userId, doc) {
|
||||
if (!groupLinksInInsert(doc)) return false;
|
||||
recordBoardWriteDenial('authz.group-link', userId, 'ddp:boards.insert',
|
||||
'A client board insert named organizations, teams or domains.');
|
||||
return true;
|
||||
},
|
||||
update(userId, doc, fieldNames, modifier) {
|
||||
const fields = [...(fieldNames || [])];
|
||||
if (modifier && modifier.$rename) fields.push(...Object.values(modifier.$rename));
|
||||
if (!fields.some(isGroupLinkField)) return false;
|
||||
recordBoardWriteDenial('authz.group-link', userId, 'ddp:boards.update',
|
||||
'A client board update named organizations, teams or domains outside the board methods.');
|
||||
return true;
|
||||
},
|
||||
fetch: [],
|
||||
});
|
||||
|
||||
// GroupLinkBleed, member variant: a board admin adding a user straight into
|
||||
// `members` skips inviteUserToBoard (who may invite, disabled accounts, the
|
||||
// #6116 same organization/team policy). Client code only changes a member's
|
||||
// role or removes one (Boards.helpers removeMember, setMemberPermission), so
|
||||
// any client write that ADDS a member is refused.
|
||||
Boards.deny({
|
||||
update(userId, doc, fieldNames, modifier) {
|
||||
if (!(fieldNames || []).includes('members') || !addsBoardMember(doc, modifier)) return false;
|
||||
recordBoardWriteDenial('authz.member-add', userId, 'ddp:boards.update',
|
||||
'A client board update added a member outside inviteUserToBoard.');
|
||||
return true;
|
||||
},
|
||||
fetch: ['members'],
|
||||
});
|
||||
|
||||
// The number of users that have starred this board is managed by trusted code
|
||||
// and the user is not allowed to update it
|
||||
Boards.deny({
|
||||
|
||||
@@ -23,7 +23,11 @@ Meteor.publish('notificationAttachments', async function() {
|
||||
{},
|
||||
true,
|
||||
);
|
||||
return ret;
|
||||
// Attachments is an ostrio:files FilesCollection: its find() is a FilesCursor,
|
||||
// which a publication cannot return ("Publish function can only return a
|
||||
// Cursor"). The Mongo cursor it wraps is .cursor, as every other attachment
|
||||
// publication returns.
|
||||
return ret.cursor || ret;
|
||||
});
|
||||
|
||||
// gets all cards associated with activities associated with the current user
|
||||
|
||||
+4
-4
@@ -19,7 +19,7 @@
|
||||
# base: core24 (not core26) · no build-base: devel · grade: stable
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: wekan
|
||||
version: '12.96'
|
||||
version: '12.98'
|
||||
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.96-${WEKAN_ARCH}.zip"
|
||||
WEKAN_ZIP="wekan-12.98-${WEKAN_ARCH}.zip"
|
||||
echo "Cleaning environment first"
|
||||
rm -rf .build
|
||||
mkdir .build
|
||||
cd .build
|
||||
#wget https://github.com/wekan/wekan/releases/download/v12.96/wekan-12.96-amd64.zip
|
||||
#wget https://github.com/wekan/wekan/releases/download/v12.98/wekan-12.98-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.96/${WEKAN_ZIP}"
|
||||
"https://github.com/wekan/wekan/releases/download/v12.98/${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`);
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: GroupLinkBleed (GHSA-v544-rmr5-38gx). Boards.allow lets a board admin
|
||||
// update any field of their board, and two checks lived only inside Meteor
|
||||
// methods: "a board admin may add only the organizations and teams they belong
|
||||
// to" (#5141, setBoardOrgs/setBoardTeams) and inviteUserToBoard's checks (who
|
||||
// may invite, disabled accounts, the #6116 same organization/team policy). A
|
||||
// client calling Boards.update / Boards.insert directly - /boards/update over
|
||||
// DDP - skipped both: it could attach any team or organization to its board,
|
||||
// so the whole group saw it and became members, and push any user id into
|
||||
// members.
|
||||
//
|
||||
// This runs the REAL deny rules of server/permissions/boards.js in a vm, with
|
||||
// stubbed imports, against the reporter's writes and against every write the
|
||||
// client legitimately makes. A write is refused when any deny rule says so,
|
||||
// exactly as Meteor's allow/deny decides.
|
||||
// Run: node tests/groupLinkBleed.test.cjs
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
const guards = require('../models/lib/boardWriteGuards');
|
||||
|
||||
function loadRules() {
|
||||
const denies = [];
|
||||
const recorded = [];
|
||||
const Boards = { deny: rules => denies.push(rules), allow: () => {} };
|
||||
const resolve = spec => {
|
||||
if (spec === '/server/lib/securityLog') return { record: evt => recorded.push(evt) };
|
||||
if (spec === '/models/attachments') return { default: { findOneAsync: async () => null } };
|
||||
return require(path.join(ROOT, spec));
|
||||
};
|
||||
const src = read('server/permissions/boards.js')
|
||||
.replace(/^import Boards from .*$/m, '')
|
||||
.replace(/^import TableVisibilityModeSettings from .*$/m,
|
||||
'const TableVisibilityModeSettings = { findOneAsync: async () => null };')
|
||||
.replace(/^import \{ findWhere, where \} from .*$/m,
|
||||
'const where = (a, q) => (a || []).filter(x => Object.keys(q).every(k => x[k] === q[k]));'
|
||||
+ 'const findWhere = (a, q) => where(a, q)[0];')
|
||||
.replace(/^import \{[^}]*\} from '\/server\/lib\/utils';$/m,
|
||||
'const allowIsBoardAdminOrSiteAdmin = () => true; const canUpdateBoardSort = () => false;'
|
||||
+ 'const canUpdateBoardSameWidthValue = () => false;')
|
||||
.replace(/^import \{[^}]*\} from '\/server\/lib\/subtaskDepositAccess';$/m,
|
||||
'const canWriteSubtaskDeposit = async () => true; const recordSubtaskDepositDenial = () => {};');
|
||||
assert.ok(!/^import /m.test(src), 'every import of server/permissions/boards.js is stubbed');
|
||||
vm.runInNewContext(src, { Boards, require: resolve, console });
|
||||
return { denies, recorded };
|
||||
}
|
||||
|
||||
const ADMIN = 'admin';
|
||||
const board = () => ({
|
||||
_id: 'b1',
|
||||
members: [
|
||||
{ userId: ADMIN, isAdmin: true, isActive: true },
|
||||
{ userId: 'bob', isAdmin: false, isActive: true },
|
||||
{ userId: 'gone', isAdmin: false, isActive: false },
|
||||
],
|
||||
teams: [{ teamId: 'mine', teamDisplayName: 'Mine', isActive: true }],
|
||||
});
|
||||
const fieldsOf = modifier => [...new Set(Object.values(modifier).flatMap(a => Object.keys(a).map(k => k.split('.')[0])))];
|
||||
|
||||
async function refusedUpdate(modifier, doc = board()) {
|
||||
const { denies, recorded } = loadRules();
|
||||
for (const rules of denies) {
|
||||
if (rules.update && await rules.update(ADMIN, doc, fieldsOf(modifier), modifier)) return { refused: true, recorded };
|
||||
}
|
||||
return { refused: false, recorded };
|
||||
}
|
||||
async function refusedInsert(doc) {
|
||||
const { denies, recorded } = loadRules();
|
||||
for (const rules of denies) {
|
||||
if (rules.insert && await rules.insert(ADMIN, doc)) return { refused: true, recorded };
|
||||
}
|
||||
return { refused: false, recorded };
|
||||
}
|
||||
|
||||
const newMember = userId => ({ userId, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false,
|
||||
isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false });
|
||||
|
||||
test('the reported attack: a foreign team, organization or domain written straight to the board is refused and recorded', async () => {
|
||||
for (const modifier of [
|
||||
{ $set: { teams: [{ teamId: 'foreign', teamDisplayName: 'x', isActive: true }] } },
|
||||
{ $push: { teams: { teamId: 'foreign', teamDisplayName: 'x', isActive: true } } },
|
||||
{ $addToSet: { orgs: { orgId: 'foreign', orgDisplayName: 'x', isActive: true } } },
|
||||
{ $set: { 'teams.0.teamId': 'foreign' } },
|
||||
{ $set: { domains: [{ domain: 'example.com', isActive: true }] } },
|
||||
{ $set: { title: 'harmless', orgs: [{ orgId: 'foreign', orgDisplayName: 'x', isActive: true }] } },
|
||||
]) {
|
||||
const { refused, recorded } = await refusedUpdate(modifier);
|
||||
assert.equal(refused, true, JSON.stringify(modifier));
|
||||
assert.equal(recorded[0].key, 'authz.group-link', JSON.stringify(modifier));
|
||||
assert.equal(recorded[0].action, 'blocked');
|
||||
}
|
||||
for (const field of ['teams', 'orgs', 'domains']) {
|
||||
const { refused, recorded } = await refusedInsert({ title: 'x', [field]: [{ isActive: true }] });
|
||||
assert.equal(refused, true, field);
|
||||
assert.equal(recorded[0].source, 'ddp:boards.insert');
|
||||
}
|
||||
});
|
||||
|
||||
test('the member variant: adding or bringing back a member from the client is refused and recorded', async () => {
|
||||
for (const modifier of [
|
||||
{ $push: { members: newMember('stranger') } },
|
||||
{ $addToSet: { members: newMember('stranger') } },
|
||||
{ $set: { members: [...board().members, newMember('stranger')] } },
|
||||
{ $set: { 'members.3': newMember('stranger') } },
|
||||
{ $set: { 'members.1': newMember('stranger') } },
|
||||
{ $set: { 'members.1.userId': 'stranger' } },
|
||||
{ $set: { 'members.$.isActive': true } },
|
||||
{ $set: { 'members.2.isActive': true } },
|
||||
{ $set: { 'members.2': { ...board().members[2], isActive: true } } },
|
||||
{ $set: { members: board().members.map(m => ({ ...m, isActive: true })) } },
|
||||
]) {
|
||||
const { refused, recorded } = await refusedUpdate(modifier);
|
||||
assert.equal(refused, true, JSON.stringify(modifier));
|
||||
assert.equal(recorded.at(-1).key, 'authz.member-add', JSON.stringify(modifier));
|
||||
}
|
||||
});
|
||||
|
||||
test('negative: what the client legitimately writes still passes and is not logged', async () => {
|
||||
for (const modifier of [
|
||||
// setMemberPermission (models/boards.js), dotted $set + $unset.
|
||||
{ $set: { 'members.1.isAdmin': true, 'members.1.isCommentOnly': false }, $unset: { 'members.1.customRoleId': '' } },
|
||||
// removeMember: deactivate, keep the last admin, orphan cleanup.
|
||||
{ $set: { 'members.1.isActive': false, 'members.1.isAdmin': false }, $unset: { 'members.1.grantedBy': '' } },
|
||||
{ $set: { 'members.0.isActive': true } },
|
||||
{ $set: { members: board().members.filter(m => m.userId !== 'bob') } },
|
||||
{ $pull: { members: { userId: 'bob' } } },
|
||||
// Ordinary board settings.
|
||||
{ $set: { title: 'Renamed', color: 'belize' } },
|
||||
{ $set: { allowsSubtasks: true } },
|
||||
]) {
|
||||
const { refused, recorded } = await refusedUpdate(modifier);
|
||||
assert.equal(refused, false, JSON.stringify(modifier));
|
||||
assert.deepEqual(recorded, [], JSON.stringify(modifier));
|
||||
}
|
||||
// The UI creates a board without groups or other members.
|
||||
const { refused } = await refusedInsert({ title: 'New', members: [newMember(ADMIN)], teams: [], orgs: [] });
|
||||
assert.equal(refused, false);
|
||||
});
|
||||
|
||||
test('the pure helpers', () => {
|
||||
assert.equal(guards.isGroupLinkField('teams'), true);
|
||||
assert.equal(guards.isGroupLinkField('orgs.0.orgId'), true);
|
||||
assert.equal(guards.isGroupLinkField('domainsX'), false);
|
||||
assert.equal(guards.isGroupLinkField('title'), false);
|
||||
assert.equal(guards.addsBoardMember(board(), { $rename: { 'members': 'x' } }), true);
|
||||
assert.equal(guards.addsBoardMember(board(), { $set: { 'members.1.isWorker': true } }), false);
|
||||
assert.equal(guards.addsBoardMember({}, { $set: { 'members.0.isActive': false } }), true, 'no members: no index exists');
|
||||
});
|
||||
|
||||
test('the methods keep their checks, the catalog has the keys', () => {
|
||||
const methods = read('server/models/boards.js');
|
||||
for (const name of ['setBoardOrgs', 'setBoardTeams']) {
|
||||
const start = methods.indexOf(`async ${name}(`);
|
||||
const guard = methods.indexOf('forbiddenNewGroupIds(', start);
|
||||
const write = methods.indexOf('Boards.updateAsync(', start);
|
||||
assert.ok(start > 0 && guard > start && guard < write, `${name} checks before writing`);
|
||||
}
|
||||
const catalog = read('models/lib/securityCategories.js');
|
||||
assert.match(catalog, /'authz\.group-link':\s*\{[^}]*bleed: 'GroupLinkBleed'[^}]*cwe: 'CWE-863'/);
|
||||
assert.match(catalog, /'authz\.member-add':\s*\{[^}]*bleed: 'GroupLinkBleed'[^}]*cwe: 'CWE-863'/);
|
||||
});
|
||||
|
||||
// The shape of the fault: a field whose only check sits inside a Meteor method
|
||||
// is still writable by a client through the collection, unless a deny rule
|
||||
// names it. Every board field a method guards with an ownership/membership
|
||||
// check must be named by a Boards.deny rule - or, for members, by the add
|
||||
// guard.
|
||||
test('negative: no board field guarded only by a method is left client-writable', () => {
|
||||
const methods = read('server/models/boards.js');
|
||||
const perms = read('server/permissions/boards.js') + read('models/boards.js');
|
||||
const guarded = new Set();
|
||||
for (const m of methods.matchAll(/forbiddenNewGroupIds\(\{\s*previous: board\.(\w+)/g)) guarded.add(m[1]);
|
||||
assert.deepEqual([...guarded].sort(), ['orgs', 'teams']);
|
||||
for (const field of [...guarded, 'domains']) {
|
||||
assert.ok(guards.GROUP_LINK_FIELDS.includes(field), `${field} is denied to client writes`);
|
||||
}
|
||||
assert.match(perms, /addsBoardMember\(doc, modifier\)/);
|
||||
// No client code writes these fields directly: the UI goes through the methods.
|
||||
const walk = dir => fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true }).flatMap(e => {
|
||||
if (e.name === 'tests' || e.name === 'node_modules' || e.name.startsWith('_build')) return [];
|
||||
const rel = `${dir}/${e.name}`;
|
||||
return e.isDirectory() ? walk(rel) : (rel.endsWith('.js') ? [rel] : []);
|
||||
});
|
||||
const offenders = walk('client').filter(file =>
|
||||
/Boards\.update(Async)?\([^;]*\b(teams|orgs|domains)\s*:/.test(read(file))
|
||||
|| /Boards\.update(Async)?\([^;]*\$(push|addToSet)\s*:\s*\{\s*members\b/.test(read(file)));
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -386,8 +386,11 @@ test('cardDetails.css: .card-details-maximized block contains position: fixed an
|
||||
});
|
||||
|
||||
test('cardDetails.css: desktop-mode maximized override exists with !important viewport insets', () => {
|
||||
// The TOP-LEVEL rule, at the start of a line: since efaf94242e the same
|
||||
// selector is also in a narrow-screen @media rule (indented) that only moves
|
||||
// `top` below the header bar on a phone, and must not be mistaken for this one.
|
||||
const m = stripComments(css).match(
|
||||
/body\.desktop-mode \.card-details\.card-details-maximized[^{]*\{([^}]*)\}/,
|
||||
/^body\.desktop-mode \.card-details\.card-details-maximized[^{]*\{([^}]*)\}/m,
|
||||
);
|
||||
assert.ok(m, 'desktop-mode maximized rule exists');
|
||||
for (const decl of ['position: fixed', 'top: 8px', 'inset-inline-start: 8px', 'inset-inline-end: 8px', 'bottom: 8px']) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
// GroupLinkBleed (GHSA-v544-rmr5-38gx, 2026-11-06): a board admin skipped the
|
||||
// board methods' checks by writing the board document over DDP - attaching a
|
||||
// team they do not belong to (#5141), and pushing any user into members past
|
||||
// inviteUserToBoard (#6116).
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken, openBoard } = require('../helpers/auth');
|
||||
|
||||
test('a foreign team or a new member cannot be written straight onto the board', async ({ page, user, user2, board }) => {
|
||||
const foreign = `glbForeign${Date.now()}`;
|
||||
const own = `glbOwn${Date.now()}`;
|
||||
const original = db.getBoard(board.boardId);
|
||||
db.setUserGroups({ userId: user.id, teams: [{ teamId: own, teamDisplayName: 'Own' }] });
|
||||
try {
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
await openBoard(page, board.boardId, board.slug);
|
||||
const write = (modifier) => page.evaluate(async ({ boardId, modifier }) => {
|
||||
try { await Meteor.callAsync('/boards/update', { _id: boardId }, modifier); return 'written'; }
|
||||
catch (error) { return 'denied'; }
|
||||
}, { boardId: board.boardId, modifier });
|
||||
|
||||
// The method refuses the foreign team (the control) ...
|
||||
const method = await page.evaluate(({ boardId, foreign }) => Meteor.callAsync('setBoardTeams',
|
||||
[{ teamId: foreign, teamDisplayName: 'x', isActive: true }], [], boardId).then(() => 'ok', e => e.error), { boardId: board.boardId, foreign });
|
||||
expect(method).toBe('not-authorized');
|
||||
// ... and so does the collection now.
|
||||
expect(await write({ $set: { teams: [{ teamId: foreign, teamDisplayName: 'x', isActive: true }] } })).toBe('denied');
|
||||
expect((db.getBoard(board.boardId).teams || []).some(t => t.teamId === foreign)).toBe(false);
|
||||
|
||||
// The member variant.
|
||||
expect(await write({ $push: { members: { userId: user2.id, isAdmin: false, isActive: true } } })).toBe('denied');
|
||||
expect(db.getBoard(board.boardId).members.some(m => m.userId === user2.id)).toBe(false);
|
||||
|
||||
// Both attempts are in Admin Panel -> Problems; medium never disables.
|
||||
await expect.poll(() => db.findOne('eventlog', { bleed: 'GroupLinkBleed', source: 'ddp:boards.update' })?.count).toBeGreaterThanOrEqual(2);
|
||||
expect(db.findOne('users', { _id: user.id }).loginDisabled).toBeFalsy();
|
||||
|
||||
// Negative: the method still adds the admin's own team, and a role change
|
||||
// written by the client still works.
|
||||
const ownTeam = await page.evaluate(({ boardId, own }) => Meteor.callAsync('setBoardTeams',
|
||||
[{ teamId: own, teamDisplayName: 'Own', isActive: true }], [], boardId).then(() => 'ok', e => e.error), { boardId: board.boardId, own });
|
||||
expect(ownTeam).toBe('ok');
|
||||
expect(db.getBoard(board.boardId).teams.some(t => t.teamId === own)).toBe(true);
|
||||
const index = db.getBoard(board.boardId).members.findIndex(m => m.userId === user.id);
|
||||
expect(await write({ $set: { [`members.${index}.isWorker`]: false } })).toBe('written');
|
||||
} finally {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { members: original.members, teams: original.teams || [] } });
|
||||
db.setUserGroups({ userId: user.id, teams: [] });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const BoardPage = require('../pages/BoardPage');
|
||||
const Papa = require('../../../node_modules/papaparse');
|
||||
|
||||
async function readRows(response, delimiter) {
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()['content-type']).toContain(delimiter === '\t' ? 'text/tsv' : 'text/csv');
|
||||
const result = Papa.parse(await response.text(), { delimiter, skipEmptyLines: true });
|
||||
expect(result.errors).toEqual([]);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
for (const delimiter of [',', ';', '\t']) {
|
||||
test(`public and private exports honor selected columns (${JSON.stringify(delimiter)})`, async ({ request, board, user }) => {
|
||||
db.updateMany('cards', { boardId: board.boardId }, { $set: { description: 'Selected description' } });
|
||||
const query = new URLSearchParams({ fields: 'description,unknown', delimiter, authToken: user.token });
|
||||
const url = `/api/boards/${board.boardId}/export/csv?`;
|
||||
const privateRows = await readRows(await request.get(url + query), delimiter);
|
||||
expect(privateRows[0]).toEqual(['Title', 'Description', 'Archived']);
|
||||
expect(privateRows).toHaveLength(4);
|
||||
expect(privateRows.slice(1).map(row => row[0]).sort()).toEqual(['Alpha Card', 'Beta Card', 'Gamma Card']);
|
||||
for (const row of privateRows.slice(1)) expect(row.slice(1)).toEqual(['Selected description', 'false']);
|
||||
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
|
||||
query.delete('authToken');
|
||||
expect(await readRows(await request.get(url + query), delimiter)).toEqual(privateRows);
|
||||
});
|
||||
}
|
||||
|
||||
test('public exports retain all columns when fields are absent, empty or unrecognized', async ({ request, board }) => {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
|
||||
const url = `/api/boards/${board.boardId}/export/csv`;
|
||||
const full = await readRows(await request.get(url), ',');
|
||||
expect(full[0]).toContain('Owner');
|
||||
expect(full[0]).toContain('List');
|
||||
for (const query of ['?fields=', '?fields=unknown']) {
|
||||
expect(await readRows(await request.get(url + query), ',')).toEqual(full);
|
||||
}
|
||||
});
|
||||
|
||||
test('selecting columns still denies private-board exports to unrelated users', async ({ request, board, user2 }) => {
|
||||
const query = new URLSearchParams({ fields: 'description', authToken: user2.token });
|
||||
const response = await request.get(`/api/boards/${board.boardId}/export/csv?${query}`);
|
||||
expect(response.status()).toBe(403);
|
||||
expect(await response.text()).not.toContain('Alpha Card');
|
||||
});
|
||||
|
||||
test('the public-board export menu applies its column checkboxes to CSV and TSV', async ({ boardPage: page, board }) => {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
|
||||
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 toggles = page.locator('.js-pop-over .js-export-field-toggle');
|
||||
await expect(toggles.first()).toBeVisible();
|
||||
for (const toggle of await toggles.all()) {
|
||||
const selected = await toggle.getAttribute('aria-checked') === 'true';
|
||||
const wanted = await toggle.getAttribute('data-field') === 'description';
|
||||
if (selected !== wanted) await toggle.click();
|
||||
await expect(toggle).toHaveAttribute('aria-checked', String(wanted));
|
||||
}
|
||||
for (const delimiter of [',', ';', '\t']) {
|
||||
const anchors = page.locator('.js-pop-over a[href*="/export/csv?"]');
|
||||
let href;
|
||||
for (const candidate of await anchors.all()) {
|
||||
const value = await candidate.getAttribute('href');
|
||||
if (new URL(value, page.url()).searchParams.get('delimiter') === delimiter) href = value;
|
||||
}
|
||||
expect(href).toBeTruthy();
|
||||
const rows = await readRows(await page.request.get(href), delimiter);
|
||||
expect(rows[0]).toEqual(['Title', 'Description', 'Archived']);
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows.slice(1).map(row => row[0]).sort()).toEqual(['Alpha Card', 'Beta Card', 'Gamma Card']);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
|
||||
const headers = token => ({ Authorization: `Bearer ${token}` });
|
||||
const cardPath = (boardId, listId, cardId) => `/api/boards/${boardId}/lists/${listId}/cards/${cardId}`;
|
||||
|
||||
for (const operation of ['archive', 'unarchive']) {
|
||||
test(`${operation} returns JSON 404 for missing, incorrectly scoped and wrong-state cards without writes`, async ({ request, user, board }) => {
|
||||
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
const other = db.seedBoard({ ownerId: user.id });
|
||||
try {
|
||||
db.updateOne('cards', { _id: card._id }, { $set: { archived: operation === 'unarchive' } });
|
||||
const before = db.findOne('cards', { _id: card._id });
|
||||
const activities = db.find('activities', { cardId: card._id });
|
||||
const paths = [
|
||||
cardPath(board.boardId, card.listId, db.uid('missing')),
|
||||
cardPath(board.boardId, board.listIds[1], card._id),
|
||||
cardPath(other.boardId, other.listIds[0], card._id),
|
||||
];
|
||||
for (const path of paths) {
|
||||
const response = await request.post(`${path}/${operation}`, { headers: headers(user.token) });
|
||||
expect(response.status()).toBe(404);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect(await response.json()).toEqual({ error: 'Card not found' });
|
||||
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
|
||||
expect(db.find('activities', { cardId: card._id })).toEqual(activities);
|
||||
}
|
||||
// A card already in the requested state is outside the route's selector.
|
||||
db.updateOne('cards', { _id: card._id }, { $set: { archived: operation === 'archive' } });
|
||||
const wrongState = db.findOne('cards', { _id: card._id });
|
||||
const response = await request.post(`${cardPath(board.boardId, card.listId, card._id)}/${operation}`, { headers: headers(user.token) });
|
||||
expect(response.status()).toBe(404);
|
||||
expect(await response.json()).toEqual({ error: 'Card not found' });
|
||||
expect(db.findOne('cards', { _id: card._id })).toEqual(wrongState);
|
||||
expect(db.find('activities', { cardId: card._id })).toEqual(activities);
|
||||
} finally {
|
||||
db.cleanup({ boardIds: [other.boardId] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('REST archive and restore persist and update the board, while unauthorized and repeated requests do not write', async ({ boardPage: page, request, user, user2, board }) => {
|
||||
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
const base = cardPath(board.boardId, card.listId, card._id);
|
||||
const minicard = page.locator('.minicard-wrapper').filter({ hasText: 'Alpha Card' });
|
||||
await expect(minicard).toBeVisible();
|
||||
for (const [operation, archived] of [['archive', true], ['unarchive', false]]) {
|
||||
const before = db.findOne('cards', { _id: card._id });
|
||||
const activities = db.find('activities', { cardId: card._id });
|
||||
for (const options of [{}, { headers: headers(user2.token) }]) {
|
||||
const refused = await request.post(`${base}/${operation}`, options);
|
||||
expect([401, 403]).toContain(refused.status());
|
||||
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
|
||||
expect(db.find('activities', { cardId: card._id })).toEqual(activities);
|
||||
}
|
||||
const response = await request.post(`${base}/${operation}`, { headers: headers(user.token) });
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ _id: card._id, archived });
|
||||
expect(db.findOne('cards', { _id: card._id }).archived).toBe(archived);
|
||||
if (archived) await expect(minicard).toHaveCount(0);
|
||||
else await expect(minicard).toBeVisible();
|
||||
const after = db.findOne('cards', { _id: card._id });
|
||||
const afterActivities = db.find('activities', { cardId: card._id });
|
||||
const repeated = await request.post(`${base}/${operation}`, { headers: headers(user.token) });
|
||||
expect(repeated.status()).toBe(404);
|
||||
expect(await repeated.json()).toEqual({ error: 'Card not found' });
|
||||
expect(db.findOne('cards', { _id: card._id })).toEqual(after);
|
||||
expect(db.find('activities', { cardId: card._id })).toEqual(afterActivities);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
|
||||
const headers = token => ({ Authorization: `Bearer ${token}` });
|
||||
const cardPath = card => `/api/boards/${card.boardId}/lists/${card.listId}/cards/${card._id}`;
|
||||
|
||||
test('REST sort zero persists and moves the card to the top of its list', async ({ boardPage: page, request, user, board }) => {
|
||||
const first = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
const second = db.findOne('cards', { boardId: board.boardId, title: 'Beta Card' });
|
||||
db.updateOne('cards', { _id: second._id }, { $set: { listId: first.listId, sort: 200 } });
|
||||
const cards = page.locator('.list').filter({ has: page.locator('.minicard-wrapper').filter({ hasText: 'Alpha Card' }) }).locator('.minicard-title');
|
||||
await expect(cards).toHaveText(['Alpha Card', 'Beta Card']);
|
||||
|
||||
const base = cardPath({ ...second, listId: first.listId });
|
||||
const response = await request.put(base, { headers: headers(user.token), data: { sort: 0 } });
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toEqual({ _id: second._id });
|
||||
expect(db.findOne('cards', { _id: second._id }).sort).toBe(0);
|
||||
const read = await request.get(base, { headers: headers(user.token) });
|
||||
expect(read.status()).toBe(200);
|
||||
expect((await read.json()).sort).toBe(0);
|
||||
await expect(cards).toHaveText(['Beta Card', 'Alpha Card']);
|
||||
await page.reload();
|
||||
await expect(cards).toHaveText(['Beta Card', 'Alpha Card']);
|
||||
|
||||
for (const sort of [5, -1, 0.5]) {
|
||||
const update = await request.put(base, { headers: headers(user.token), data: { sort } });
|
||||
expect(update.status()).toBe(200);
|
||||
expect(db.findOne('cards', { _id: second._id }).sort).toBe(sort);
|
||||
}
|
||||
// Form-encoded clients already send zero as a truthy string.
|
||||
const form = await request.put(base, { headers: headers(user.token), form: { sort: '0' } });
|
||||
expect(form.status()).toBe(200);
|
||||
expect(db.findOne('cards', { _id: second._id }).sort).toBe(0);
|
||||
const edit = await request.put(base, { headers: headers(user.token), data: { description: 'Edited without moving' } });
|
||||
expect(edit.status()).toBe(200);
|
||||
expect(db.findOne('cards', { _id: second._id })).toMatchObject({ sort: 0, description: 'Edited without moving' });
|
||||
});
|
||||
|
||||
test('sort zero rejects unauthenticated, unauthorized and incorrectly scoped edits without writes', async ({ request, user, user2, board }) => {
|
||||
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
const before = db.findOne('cards', { _id: card._id });
|
||||
for (const auth of [{}, headers(user2.token)]) {
|
||||
const response = await request.put(cardPath(card), { headers: auth, data: { sort: 0 } });
|
||||
expect([401, 403]).toContain(response.status());
|
||||
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
|
||||
}
|
||||
for (const target of [{ ...card, _id: db.uid('missing') }, { ...card, listId: board.listIds[1] }]) {
|
||||
const response = await request.put(cardPath(target), { headers: headers(user.token), data: { sort: 0 } });
|
||||
expect(response.status()).toBe(404);
|
||||
expect(await response.json()).toEqual({ error: 'Card not found' });
|
||||
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
|
||||
}
|
||||
});
|
||||
@@ -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,102 @@
|
||||
'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 source = fs.readFileSync(path.join(__dirname, '../models/export.js'), 'utf8');
|
||||
const start = source.indexOf(" WebApp.handlers.get('/api/boards/:boardId/export/csv'");
|
||||
const end = source.indexOf('\n }));', start) + '\n }));'.length;
|
||||
assert.ok(start >= 0 && end > start, 'CSV route exists');
|
||||
const fieldsSource = fs.readFileSync(path.join(__dirname, '../models/lib/exportFields.js'), 'utf8');
|
||||
const { parseExportFields, BOARD_EXPORT_FIELD_KEYS } = new Function(
|
||||
fieldsSource.replace(/export \{[\s\S]*?\};/, '')
|
||||
+ '\nreturn { parseExportFields, BOARD_EXPORT_FIELD_KEYS };',
|
||||
)();
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function fixture({ isPublic = true, missing = false, disabled = false, allowed = true } = {}) {
|
||||
let handler;
|
||||
const exports = [];
|
||||
const user = { _id: 'member', profile: { language: 'en' } };
|
||||
vm.runInNewContext(source.slice(start, end), {
|
||||
WebApp: { handlers: { get(route, callback) { handler = callback; } } },
|
||||
safeRoute: callback => callback,
|
||||
require(name) {
|
||||
if (name === '/models/lib/importExportSecurity') return {
|
||||
async refuseDisabledExport(res) {
|
||||
if (disabled) { res.writeHead(403); res.end('Export disabled'); }
|
||||
return disabled;
|
||||
},
|
||||
};
|
||||
if (name === '/server/lib/activeUser') return { async activeUserByToken() { return user; } };
|
||||
throw new Error(`Unexpected require: ${name}`);
|
||||
},
|
||||
ReactiveCache: {
|
||||
async getBoard() { return missing ? null : { isPublic: () => isPublic }; },
|
||||
async getImpersonatedUser() { return false; },
|
||||
},
|
||||
Exporter: class {
|
||||
constructor(boardId, attachmentId, options = {}) {
|
||||
this.record = { boardId, attachmentId, options: plain(options) };
|
||||
exports.push(this.record);
|
||||
}
|
||||
async canExport(viewer) { assert.equal(viewer, user); return allowed; }
|
||||
async buildCsvStream(res, delimiter, language) {
|
||||
Object.assign(this.record, { delimiter, language });
|
||||
res.write('csv');
|
||||
}
|
||||
},
|
||||
parseExportFields, BOARD_EXPORT_FIELD_KEYS,
|
||||
logExportDenied() {},
|
||||
});
|
||||
return {
|
||||
exports,
|
||||
async get(query) {
|
||||
const res = {
|
||||
status: null, chunks: [], ended: false,
|
||||
writeHead(status, headers) { this.status = status; this.headers = headers || {}; },
|
||||
write(chunk) { this.chunks.push(chunk); },
|
||||
end(chunk) { if (chunk) this.chunks.push(chunk); this.ended = true; },
|
||||
};
|
||||
await handler({ params: { boardId: 'board' }, query }, res);
|
||||
assert.equal(res.ended, true);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const delimiter of [',', ';', '\t']) {
|
||||
test(`public and private exports pass the same validated fields (${JSON.stringify(delimiter)})`, async () => {
|
||||
for (const isPublic of [true, false]) {
|
||||
const f = fixture({ isPublic });
|
||||
const res = await f.get({ delimiter, fields: ' description,unknown,dates ', authToken: 'token' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(f.exports, [{
|
||||
boardId: 'board', attachmentId: undefined,
|
||||
options: { fields: ['description', 'dates'] }, delimiter, language: 'en',
|
||||
}]);
|
||||
assert.equal(res.headers['Content-Type'], delimiter === '\t' ? 'text/tsv' : 'text/csv; charset=utf-8');
|
||||
assert.equal(res.chunks.length, delimiter === '\t' ? 1 : 2, 'CSV retains its BOM');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const fields of [undefined, '', 'unknown', ['description']]) {
|
||||
test(`public export keeps all columns for absent or invalid selection ${JSON.stringify(fields)}`, async () => {
|
||||
const f = fixture();
|
||||
assert.equal((await f.get({ fields })).status, 200);
|
||||
assert.equal(f.exports[0].options.fields, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const options of [{ missing: true }, { disabled: true }, { isPublic: false, allowed: false }]) {
|
||||
test(`field selection does not bypass export guards ${JSON.stringify(options)}`, async () => {
|
||||
const f = fixture(options);
|
||||
const res = await f.get({ fields: 'description', authToken: 'token' });
|
||||
assert.equal(res.status, options.missing ? 404 : 403);
|
||||
assert.ok(f.exports.every(record => record.delimiter === undefined), 'no CSV was streamed');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
// Attachments and Avatars are ostrio:files FilesCollections. Their find() - and
|
||||
// ReactiveCache.getAttachments()/getAvatars() with getQuery = true - is a
|
||||
// FilesCursor, and a publication that returns one fails every subscription:
|
||||
//
|
||||
// Exception from sub notificationAttachments id ... Error: Publish function
|
||||
// can only return a Cursor or an array of Cursors
|
||||
//
|
||||
// The Mongo cursor a publication can return is the FilesCursor's `.cursor`.
|
||||
// notificationAttachments returned the FilesCursor itself, so the notifications
|
||||
// drawer never received the attachments its activities name.
|
||||
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 root = path.join(__dirname, '..');
|
||||
const SKIP = new Set(['node_modules', '_build', '.build', '.tools', '.meteor', 'tests', '.git', 'client']);
|
||||
|
||||
function sourceFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) sourceFiles(full, out);
|
||||
else if (/\.(js|mjs|cjs)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The argument text of a call starting at `open` (the index of its "(").
|
||||
function callArgs(source, open) {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === '(') depth++;
|
||||
else if (source[i] === ')' && --depth === 0) return { text: source.slice(open + 1, i), end: i + 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('a FilesCollection query asked for as a cursor is published as its .cursor', () => {
|
||||
const offenders = [];
|
||||
let queries = 0;
|
||||
for (const file of sourceFiles(root)) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
// Only what is published: a helper elsewhere may use the FilesCursor on
|
||||
// purpose (Cards.attachments() calls its .each()).
|
||||
if (!/Meteor\.publish\(|publishComposite\(/.test(source)) continue;
|
||||
const re = /ReactiveCache\.get(Attachments|Avatars)\(/g;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const call = callArgs(source, m.index + m[0].length - 1);
|
||||
if (!call || !/,\s*true\s*,?\s*$/.test(call.text)) continue; // fetched: an array
|
||||
queries++;
|
||||
// The FilesCursor is unwrapped either on the call itself,
|
||||
// `(await ReactiveCache.getAttachments(..., true)).cursor`, or through the
|
||||
// variable it was assigned to, `result.cursor`.
|
||||
const after = source.slice(call.end, call.end + 12);
|
||||
const before = source.slice(Math.max(0, m.index - 120), m.index);
|
||||
const assigned = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?$/.exec(before);
|
||||
const unwrapped = /^\)?\.cursor/.test(after)
|
||||
|| (assigned && new RegExp(`\\b${assigned[1]}\\.cursor\\b`).test(source.slice(call.end, call.end + 1500)));
|
||||
if (!unwrapped) offenders.push(`${path.relative(root, file)}:${source.slice(0, m.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
assert.ok(queries >= 8, `found the cursor queries (${queries})`);
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
test('notificationAttachments publishes the Mongo cursor, not the FilesCursor', async () => {
|
||||
const source = fs.readFileSync(path.join(root, 'server/publications/notifications.js'), 'utf8');
|
||||
const start = source.indexOf("Meteor.publish('notificationAttachments'");
|
||||
const end = source.indexOf('\n});', start) + '\n});'.length;
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const mongoCursor = { kind: 'Mongo.Cursor' };
|
||||
let selector;
|
||||
let publication;
|
||||
vm.runInNewContext(source.slice(start, end), {
|
||||
Meteor: { publish(name, fn) { publication = fn; } },
|
||||
async activityDocs() { return [{ attachmentId: 'a1' }, { cardId: 'c1' }, { attachmentId: 'a2' }]; },
|
||||
ReactiveCache: {
|
||||
async getAttachments(sel, options, getQuery) {
|
||||
selector = sel;
|
||||
assert.equal(getQuery, true);
|
||||
// What ostrio:files answers: a FilesCursor wrapping the Mongo cursor.
|
||||
return { cursor: mongoCursor, each() {}, fetch() { return []; } };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(await publication.call({ userId: 'u1' }), mongoCursor);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(selector)), { _id: { $in: ['a1', 'a2'] } },
|
||||
'only the attachments the readable activities name');
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
// Exercise the registered archive/restore handlers and their scoped lookups.
|
||||
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 base = '/api/boards/:boardId/lists/:listId/cards/:cardId';
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function fixture(operation, { deny = false, refuseList = false, lookupError, mutationError } = {}) {
|
||||
const calls = [];
|
||||
const card = {
|
||||
_id: 'card', boardId: 'board', listId: 'list', archived: operation === 'unarchive',
|
||||
async archive() { calls.push(['archive']); if (mutationError) throw mutationError; this.archived = true; },
|
||||
async restore() { calls.push(['restore']); if (mutationError) throw mutationError; this.archived = false; },
|
||||
};
|
||||
const handlers = {};
|
||||
const context = {
|
||||
WebApp: { handlers: { post(url, handler) { handlers[url] = handler; } } },
|
||||
Authentication: { async checkBoardWriteAccess(userId, boardId) {
|
||||
calls.push(['access', userId, boardId]);
|
||||
if (deny) throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
||||
} },
|
||||
async assertListNotRefused(userId, board, listId) {
|
||||
calls.push(['listAccess', userId, board._id, listId]);
|
||||
if (refuseList) throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
||||
},
|
||||
ReactiveCache: {
|
||||
async getBoard(id) { calls.push(['board', id]); return { _id: id }; },
|
||||
async getCard(selector) {
|
||||
calls.push(['card', plain(selector)]);
|
||||
if (lookupError) throw lookupError;
|
||||
return Object.entries(selector).every(([key, value]) => card[key] === value) ? card : null;
|
||||
},
|
||||
},
|
||||
Meteor: { Error: class extends Error { constructor(code, reason) { super(reason); this.error = code; } } },
|
||||
sendJsonResult(res, result) { res.responses.push(plain(result)); },
|
||||
};
|
||||
const source = fs.readFileSync(path.join(__dirname, '../server/models/cards.js'), 'utf8');
|
||||
const start = source.indexOf(`WebApp.handlers.post(\n '${base}/${operation}'`);
|
||||
assert.notEqual(start, -1);
|
||||
const end = source.indexOf('\n);', start);
|
||||
assert.ok(end > start);
|
||||
vm.runInNewContext(source.slice(start, end + '\n);'.length), context);
|
||||
return {
|
||||
calls, card,
|
||||
async post(params = {}) {
|
||||
const res = { responses: [] };
|
||||
await handlers[`${base}/${operation}`]({ userId: 'member', params: {
|
||||
boardId: 'board', listId: 'list', cardId: 'card', ...params,
|
||||
} }, res);
|
||||
assert.equal(res.responses.length, 1, 'respond exactly once');
|
||||
return res.responses[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const operation of ['archive', 'unarchive']) {
|
||||
const archived = operation === 'archive';
|
||||
test(`${operation}: a matching card uses its existing mutation helper`, async () => {
|
||||
const f = fixture(operation);
|
||||
const result = await f.post();
|
||||
assert.equal(result.code, 200);
|
||||
assert.equal(result.data._id, 'card');
|
||||
assert.equal(result.data.archived, archived);
|
||||
assert.equal(f.card.archived, archived);
|
||||
assert.deepEqual(f.calls, [
|
||||
['access', 'member', 'board'], ['board', 'board'], ['listAccess', 'member', 'board', 'list'],
|
||||
['card', { _id: 'card', listId: 'list', boardId: 'board', archived: !archived }],
|
||||
[archived ? 'archive' : 'restore'],
|
||||
]);
|
||||
});
|
||||
|
||||
for (const [name, params] of [
|
||||
['missing card', { cardId: 'missing' }],
|
||||
['wrong list', { listId: 'other-list' }],
|
||||
['wrong board', { boardId: 'other-board' }],
|
||||
['wrong state', {}],
|
||||
]) {
|
||||
test(`${operation}: ${name} returns JSON 404 without mutating`, async () => {
|
||||
const f = fixture(operation);
|
||||
if (name === 'wrong state') f.card.archived = archived;
|
||||
const before = f.card.archived;
|
||||
assert.deepEqual(await f.post(params), { code: 404, data: { error: 'Card not found' } });
|
||||
assert.equal(f.card.archived, before);
|
||||
assert.ok(!f.calls.some(([name]) => name === 'archive' || name === 'restore'));
|
||||
});
|
||||
}
|
||||
|
||||
test(`${operation}: board and list refusals precede the card lookup`, async () => {
|
||||
for (const options of [{ deny: true }, { refuseList: true }]) {
|
||||
const f = fixture(operation, options);
|
||||
await assert.rejects(() => f.post(), { statusCode: 403 });
|
||||
assert.ok(!f.calls.some(([name]) => ['card', 'archive', 'restore'].includes(name)));
|
||||
}
|
||||
});
|
||||
|
||||
test(`${operation}: actual lookup and mutation failures are not reported as missing cards`, async () => {
|
||||
const error = new Error('database unavailable');
|
||||
for (const options of [{ lookupError: error }, { mutationError: error }]) {
|
||||
await assert.rejects(() => fixture(operation, options).post(), error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'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 route = '/api/boards/:boardId/lists/:listId/cards/:cardId';
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
async function fixture() {
|
||||
const { normalizeMoveParams, parseCardScalarFields } = await import('../server/lib/restCardHelpers.js');
|
||||
const writes = [];
|
||||
const card = { _id: 'card', boardId: 'board', listId: 'list', archived: false, sort: 5 };
|
||||
let handler;
|
||||
const source = fs.readFileSync(path.join(__dirname, '../server/models/cards.js'), 'utf8');
|
||||
const start = source.indexOf(`WebApp.handlers.put(\n '${route}'`);
|
||||
assert.notEqual(start, -1);
|
||||
const end = source.indexOf('\n);', start);
|
||||
assert.ok(end > start);
|
||||
vm.runInNewContext(source.slice(start, end + '\n);'.length), {
|
||||
WebApp: { handlers: { put(url, fn) { assert.equal(url, route); handler = fn; } } },
|
||||
normalizeMoveParams,
|
||||
parseCardScalarFields,
|
||||
Authentication: { async checkBoardWriteAccess(userId) {
|
||||
if (userId !== 'member') throw new Error('Forbidden');
|
||||
} },
|
||||
async assertListNotRefused() {},
|
||||
ReactiveCache: { async getBoard() { return { _id: 'board' }; } },
|
||||
Cards: {
|
||||
async findOneAsync(selector) {
|
||||
if (typeof selector === 'string') return { ...card };
|
||||
return Object.entries(selector).every(([key, value]) => card[key] === value) ? { ...card } : null;
|
||||
},
|
||||
direct: { async updateAsync(selector, modifier) { writes.push(plain({ selector, modifier })); } },
|
||||
},
|
||||
require(name) {
|
||||
assert.equal(name, '/server/models/changeHistoryHooks');
|
||||
return { async recordUpdate() {} };
|
||||
},
|
||||
sendJsonResult(res, result) { res.push(plain(result)); },
|
||||
process: { env: {} },
|
||||
});
|
||||
return {
|
||||
writes,
|
||||
async put(body, { userId = 'member', ...params } = {}) {
|
||||
const responses = [];
|
||||
await handler({ userId, body, params: { boardId: 'board', listId: 'list', cardId: 'card', ...params } }, responses);
|
||||
assert.equal(responses.length, 1);
|
||||
return responses[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A form-encoded client sends '0'; it is written as the number 0, the type
|
||||
// every other sort value has, so ordering compares numbers with numbers.
|
||||
for (const [sort, written] of [[0, 0], [5, 5], [-1, -1], [0.5, 0.5], ['0', 0], [' 2.5 ', 2.5]]) {
|
||||
test(`card PUT writes sort ${JSON.stringify(sort)} through the scoped update`, async () => {
|
||||
const f = await fixture();
|
||||
assert.deepEqual(await f.put({ sort }), { code: 200, data: { _id: 'card' } });
|
||||
assert.deepEqual(f.writes, [{
|
||||
selector: { _id: 'card', listId: 'list', boardId: 'board', archived: false },
|
||||
modifier: { $set: { sort: written } },
|
||||
}]);
|
||||
});
|
||||
}
|
||||
|
||||
// Negative: since presence, not truthiness, decides, a value that is not a
|
||||
// number would otherwise be written as the card's position.
|
||||
for (const sort of [null, '', 'abc', true, {}, [], 'Infinity', Number.NaN]) {
|
||||
test(`card PUT refuses sort ${JSON.stringify(sort)} with 400 and writes nothing`, async () => {
|
||||
const f = await fixture();
|
||||
assert.deepEqual(await f.put({ sort, title: 'Renamed' }), { code: 400, data: { error: 'Invalid sort' } });
|
||||
assert.deepEqual(f.writes, [], 'not even the valid title: the edit is refused whole');
|
||||
});
|
||||
}
|
||||
|
||||
for (const [isOverTime, written] of [[false, false], ['false', false], [true, true], ['true', true]]) {
|
||||
test(`card PUT writes isOverTime ${JSON.stringify(isOverTime)}`, async () => {
|
||||
const f = await fixture();
|
||||
assert.equal((await f.put({ isOverTime })).code, 200);
|
||||
assert.deepEqual(f.writes.map(w => w.modifier), [{ $set: { isOverTime: written } }]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const isOverTime of [null, 0, 'yes', {}]) {
|
||||
test(`card PUT refuses isOverTime ${JSON.stringify(isOverTime)} with 400`, async () => {
|
||||
const f = await fixture();
|
||||
assert.deepEqual(await f.put({ isOverTime }), { code: 400, data: { error: 'Invalid isOverTime' } });
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [description, written] of [['', ''], [null, ''], ['New text', 'New text']]) {
|
||||
test(`card PUT writes description ${JSON.stringify(description)}`, async () => {
|
||||
const f = await fixture();
|
||||
assert.equal((await f.put({ description })).code, 200);
|
||||
assert.deepEqual(f.writes.map(w => w.modifier), [{ $set: { description: written } }]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const description of [5, {}, ['x']]) {
|
||||
test(`card PUT refuses description ${JSON.stringify(description)} with 400`, async () => {
|
||||
const f = await fixture();
|
||||
assert.deepEqual(await f.put({ description }), { code: 400, data: { error: 'Invalid description' } });
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
}
|
||||
|
||||
// Every field of this handler that can be legitimately falsy goes through the
|
||||
// presence check; none is read with a bare `if (req.body.<field>)` again.
|
||||
test('sort, isOverTime and description are not read by truthiness', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../server/models/cards.js'), 'utf8');
|
||||
const start = source.indexOf(`WebApp.handlers.put(\n '${route}'`);
|
||||
const body = source.slice(start, source.indexOf('\n);', start));
|
||||
for (const field of ['sort', 'isOverTime', 'description']) {
|
||||
assert.ok(!new RegExp(`if \\(req\\.body\\.${field}\\)`).test(body), field);
|
||||
assert.ok(new RegExp(`'${field}' in scalars\\.set`).test(body), `${field} uses the parsed value`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an omitted sort does not overwrite the position during another edit', async () => {
|
||||
const f = await fixture();
|
||||
assert.equal((await f.put({ title: 'Renamed' })).code, 200);
|
||||
assert.deepEqual(f.writes.map(write => write.modifier), [{ $set: { title: 'Renamed' } }]);
|
||||
});
|
||||
|
||||
test('an empty edit keeps the existing no-update response and does not write', async () => {
|
||||
const f = await fixture();
|
||||
assert.equal((await f.put({})).code, 404);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
for (const params of [{ cardId: 'missing' }, { listId: 'other-list' }, { boardId: 'other-board' }]) {
|
||||
test(`sort zero does not update a card outside ${JSON.stringify(params)}`, async () => {
|
||||
const f = await fixture();
|
||||
assert.deepEqual(await f.put({ sort: 0 }, params), { code: 404, data: { error: 'Card not found' } });
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
}
|
||||
|
||||
test('sort zero still requires board write access', async () => {
|
||||
const f = await fixture();
|
||||
await assert.rejects(f.put({ sort: 0 }, { userId: 'outsider' }), /Forbidden/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
// A REST handler that throws Meteor.Error(404) answers HTTP 500: WebApp
|
||||
// handlers are plain connect handlers, and a Meteor.Error has no HTTP status.
|
||||
// The not-found answer is sendJsonResult(res, { code: 404, ... }) and return.
|
||||
// #6876 fixed the card archive/restore endpoints; the custom-field endpoint
|
||||
// had the same shape. The negative test below keeps it gone everywhere.
|
||||
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 root = path.join(__dirname, '..');
|
||||
const SKIP = new Set(['node_modules', '_build', '.build', '.tools', '.meteor', 'tests', '.git']);
|
||||
|
||||
function sourceFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) sourceFiles(full, out);
|
||||
else if (/\.(js|mjs|cjs)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The text of every WebApp.handlers.<verb>( ... ) call, found by balancing
|
||||
// parentheses outside strings, comments and template literals.
|
||||
function handlerBlocks(source) {
|
||||
const blocks = [];
|
||||
const re = /WebApp\.handlers\.(get|post|put|delete|patch|use)\(/g;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
let depth = 0;
|
||||
let i = m.index + m[0].length - 1;
|
||||
let quote = null;
|
||||
for (; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (quote) {
|
||||
if (c === '\\') i++;
|
||||
else if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && source[i + 1] === '/') { i = source.indexOf('\n', i); if (i < 0) break; continue; }
|
||||
if (c === '/' && source[i + 1] === '*') { i = source.indexOf('*/', i) + 1; continue; }
|
||||
if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
|
||||
if (c === '(') depth++;
|
||||
else if (c === ')' && --depth === 0) break;
|
||||
}
|
||||
blocks.push({ start: m.index, text: source.slice(m.index, i + 1) });
|
||||
re.lastIndex = i + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
test('no REST handler answers not-found by throwing Meteor.Error(404)', () => {
|
||||
const offenders = [];
|
||||
let handlers = 0;
|
||||
for (const file of sourceFiles(root)) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const block of handlerBlocks(source)) {
|
||||
handlers++;
|
||||
if (/throw\s+new\s+Meteor\.Error\(\s*404\b/.test(block.text)) {
|
||||
const line = source.slice(0, block.start).split('\n').length;
|
||||
offenders.push(`${path.relative(root, file)}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(handlers > 50, `found the REST handlers (${handlers})`);
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
const route = '/api/boards/:boardId/lists/:listId/cards/:cardId/customFields/:customFieldId';
|
||||
|
||||
function customFieldFixture() {
|
||||
const card = { _id: 'card', boardId: 'board', listId: 'list', archived: false,
|
||||
customFields: [{ _id: 'cf', value: 'old' }] };
|
||||
const writes = [];
|
||||
let handler;
|
||||
const source = fs.readFileSync(path.join(root, 'server/models/cards.js'), 'utf8');
|
||||
const start = source.indexOf(`WebApp.handlers.post(\n '${route}'`);
|
||||
assert.notEqual(start, -1, 'custom-field route exists');
|
||||
const block = handlerBlocks(source.slice(start))[0].text;
|
||||
vm.runInNewContext(`${block};`, {
|
||||
WebApp: { handlers: { post(url, fn) { handler = fn; } } },
|
||||
Authentication: { async checkBoardWriteAccess() {} },
|
||||
async assertListNotRefused() {},
|
||||
ReactiveCache: {
|
||||
async getBoard(id) { return { _id: id }; },
|
||||
async getCard(selector) {
|
||||
return Object.entries(selector).every(([k, v]) => card[k] === v) ? card : null;
|
||||
},
|
||||
},
|
||||
Cards: {
|
||||
direct: { async updateAsync(selector, modifier) { writes.push(modifier); } },
|
||||
async findOneAsync() { return card; },
|
||||
},
|
||||
require(name) {
|
||||
assert.equal(name, '/server/models/customFieldLinks');
|
||||
return { async propagateLinkedCustomFields() {} };
|
||||
},
|
||||
Meteor: { Error: class extends Error {} },
|
||||
sendJsonResult(res, result) { res.results.push(JSON.parse(JSON.stringify(result))); },
|
||||
});
|
||||
return {
|
||||
card, writes,
|
||||
async post(params) {
|
||||
const res = { results: [] };
|
||||
await handler({ userId: 'member', body: { value: 'new' }, params: {
|
||||
boardId: 'board', listId: 'list', cardId: 'card', customFieldId: 'cf', ...params,
|
||||
} }, res);
|
||||
assert.equal(res.results.length, 1, 'respond exactly once');
|
||||
return res.results[0];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('custom-field update of a matching card still writes and answers 200', async () => {
|
||||
const f = customFieldFixture();
|
||||
const result = await f.post({});
|
||||
assert.equal(result.code, 200);
|
||||
assert.deepEqual(result.data.customFields, [{ _id: 'cf', value: 'new' }]);
|
||||
assert.equal(f.writes.length, 1);
|
||||
});
|
||||
|
||||
for (const [name, params, archived] of [
|
||||
['a missing card', { cardId: 'missing' }],
|
||||
['a card on the wrong list', { listId: 'other' }],
|
||||
['a card on the wrong board', { boardId: 'other' }],
|
||||
['an archived card', {}, true],
|
||||
]) {
|
||||
test(`custom-field update of ${name} answers JSON 404 without writing`, async () => {
|
||||
const f = customFieldFixture();
|
||||
if (archived) f.card.archived = true;
|
||||
assert.deepEqual(await f.post(params), { code: 404, data: { error: 'Card not found' } });
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
}
|
||||
@@ -216,6 +216,7 @@ const GUARDED = {
|
||||
spacebleed: ['tests/canaryCoverage.test.cjs'],
|
||||
// Published with their names already linked, which the Hall of Fame match
|
||||
// did not read until every name was linked (2026-10-30).
|
||||
grouplinkbleed: ['tests/groupLinkBleed.test.cjs', 'tests/playwright/specs/group-link-bleed.e2e.js'],
|
||||
importplacementbleed: ['tests/importPlacementBleed.test.cjs', 'tests/playwright/specs/security-import-placement.e2e.js'],
|
||||
samlsubjectbleed: ['tests/samlIdentityBinding.test.cjs'],
|
||||
stampbleed: ['tests/stampBleed.test.cjs', 'tests/playwright/specs/upload-stamp-bleed.e2e.js'],
|
||||
@@ -365,7 +366,8 @@ test('the whole published list is accounted for', () => {
|
||||
// until every Hall of Fame name became a link (2026-10-30); all guarded.
|
||||
// 141 -> 143: CrossBoardExportBleed (2026-11-01) and UnassignedAttachBleed
|
||||
// (GHSA-w26g-rc43-q6cm, 2026-11-01), both guarded.
|
||||
assert.strictEqual(total, 143, 'the Hall of Fame and this list disagree on how many there are');
|
||||
// 143 -> 144: GroupLinkBleed (GHSA-v544-rmr5-38gx, 2026-11-06), guarded.
|
||||
assert.strictEqual(total, 144, 'the Hall of Fame and this list disagree on how many there are');
|
||||
});
|
||||
|
||||
test('the companion Hall of Fame names match the inventory when available', () => {
|
||||
|
||||
@@ -86,6 +86,8 @@ test('a header rendered after startup is measured and observed after Blaze flush
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
getElementById: id => bars[id],
|
||||
// The full screen button's listeners, added in the same onRendered.
|
||||
addEventListener() {},
|
||||
documentElement: { style: { setProperty: (_key, value) => { height = value; } } },
|
||||
},
|
||||
ResizeObserver: class {
|
||||
@@ -95,6 +97,9 @@ test('a header rendered after startup is measured and observed after Blaze flush
|
||||
$: () => ({ on() {} }),
|
||||
setTimeout: callback => callback(),
|
||||
Template: { header: { onRendered: callback => { rendered = callback; } } },
|
||||
// onRendered also wires the full screen button (f25957dcf3); it is not
|
||||
// what this test pins, so it gets an inert Fullscreen API.
|
||||
fullscreen: { isSupported: () => false, isFullscreen: () => false, CHANGE_EVENTS: [] },
|
||||
Meteor: { userId: () => 'late-login' },
|
||||
FlowRouter: { watchPathChange() {} },
|
||||
Tracker: { afterFlush: callback => pending.push(callback) },
|
||||
@@ -104,8 +109,12 @@ test('a header rendered after startup is measured and observed after Blaze flush
|
||||
assert.strictEqual(height, '0px', 'no header exists during startup');
|
||||
const header = read('client/components/main/header.js');
|
||||
const start = header.indexOf('Template.header.onRendered(');
|
||||
vm.runInNewContext(header.slice(start, header.indexOf('Template.header.helpers(', start)), context);
|
||||
const instance = { view: { isDestroyed: false }, autorun: callback => callback() };
|
||||
vm.runInNewContext(header.slice(start, header.indexOf('Template.header.onDestroyed(', start)), context);
|
||||
const reactive = () => ({ set() {} });
|
||||
const instance = {
|
||||
view: { isDestroyed: false }, autorun: callback => callback(),
|
||||
fullscreenSupported: reactive(), isFullscreen: reactive(),
|
||||
};
|
||||
rendered.call(instance);
|
||||
let bottom = 168;
|
||||
bars['header-quick-access'] = { getBoundingClientRect: () => ({ bottom }) };
|
||||
|
||||
@@ -47,6 +47,15 @@ const HAS_VFS = spawnSync(process.execPath, ['--no-warnings', '--experimental-vf
|
||||
// seaSupported() asks it.
|
||||
const HAS_SEA = !/Single executable application is disabled/.test(
|
||||
spawnSync(process.execPath, ['--build-sea', path.join(__dirname, 'no-such-sea-config.json')], { encoding: 'utf8' }).stderr || '');
|
||||
// And on Linux, LIEF (inside --build-sea) adds the SEA segments aligned to
|
||||
// 4 KiB pages. A kernel with larger pages - 16 KiB on Apple-silicon Linux, 64
|
||||
// KiB on some ARM servers - cannot map them, and the executable it writes dies
|
||||
// with SIGSEGV before running a line: even a one-line `console.log` SEA does,
|
||||
// so it is Node.js's injection and not WeKan's. The release builders have
|
||||
// 4 KiB pages; elsewhere the real-executable test says why it did not run.
|
||||
const PAGE_SIZE = process.platform === 'linux'
|
||||
? Number(spawnSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).stdout) || 4096
|
||||
: 4096;
|
||||
|
||||
// --- where files go --------------------------------------------------------
|
||||
|
||||
@@ -562,7 +571,8 @@ test('waitUntilStarted: true once the pid file names the new process; false if i
|
||||
test('build, start, status, version, menu and stop a real single executable', {
|
||||
skip: (!HAS_VFS && 'needs --build-sea with useVfs (Node.js 26.11+)')
|
||||
|| (!HAS_SEA && 'this Node.js was compiled without single executable applications (Homebrew\'s is)')
|
||||
|| (process.platform === 'win32' && 'the stand-in FerretDB is a shell script'),
|
||||
|| (process.platform === 'win32' && 'the stand-in FerretDB is a shell script')
|
||||
|| (PAGE_SIZE > 4096 && `--build-sea writes 4 KiB-aligned segments, which this kernel's ${PAGE_SIZE / 1024} KiB pages cannot map`),
|
||||
timeout: 240000,
|
||||
}, async () => {
|
||||
const dir = tmp('sf-e2e-');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -129,28 +129,42 @@ test('the opened card\'s whole title edits (#6839)', () => {
|
||||
assert.ok(/position: absolute/.test(body), 'as an overlay, so the sentence is not cut in two');
|
||||
});
|
||||
|
||||
// Since 1b05673200 the window moves with Pointer Events (a finger too), and the
|
||||
// decision of what a press on the header row does is the pure cardHeaderPress()
|
||||
// in models/lib/windowDrag.js. The handler only describes the pressed element.
|
||||
const { cardHeaderPress } = require('../models/lib/windowDrag');
|
||||
const titleHandler = () => {
|
||||
const handler = cardJs.slice(cardJs.indexOf("'pointerdown .js-card-title-drag-handle'"));
|
||||
assert.ok(handler.length < cardJs.length, 'the title row has a pointerdown handler');
|
||||
return handler.slice(0, handler.indexOf('\n },'));
|
||||
};
|
||||
const press = { onEditable: false, onTitleEditZone: false, onHandle: false, cardHasHandle: false, onControl: false };
|
||||
|
||||
test('the drag handler steps aside for the half that edits (negative)', () => {
|
||||
// Both live on the same heading, so the one that must NOT fire has to say so.
|
||||
const handler = cardJs.slice(cardJs.indexOf("'mousedown .js-card-title-drag-handle'"));
|
||||
const body = handler.slice(0, handler.indexOf('\n },'));
|
||||
assert.ok(/closest\('\.card-details-title-edit-zone'\)\.length > 0/.test(body),
|
||||
const body = titleHandler();
|
||||
assert.ok(/onTitleEditZone: !!closest\('\.card-details-title-edit-zone'\)/.test(body),
|
||||
'the handler tells the decision when the press is inside the zone');
|
||||
assert.deepEqual(cardHeaderPress({ ...press, onTitleEditZone: true }), { start: false, deferred: false },
|
||||
'a press inside the zone is not a drag');
|
||||
assert.ok(/closest\([\s\S]*a, input, textarea, button, select, option/.test(body),
|
||||
assert.ok(/onControl: !!closest\('a, button'\)/.test(body), 'it tells links and buttons apart');
|
||||
assert.deepEqual(cardHeaderPress({ ...press, onControl: true }), { start: true, deferred: true },
|
||||
'and a link is still a link - which is what the close, maximise and menu '
|
||||
+ 'buttons in that row are');
|
||||
assert.ok(/closest\('\.js-card-drag-handle'\)\.length > 0/.test(body),
|
||||
+ 'buttons in that row are: a tap does its job, only a drag moves the window');
|
||||
assert.ok(/if \(closest\('\.js-card-drag-handle'\)\) return;/.test(body),
|
||||
'and the drag handle keeps its own handler, so one press moves the window once');
|
||||
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(body),
|
||||
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(cardJs.slice(cardJs.indexOf('function beginCardWindowDrag'))),
|
||||
'while a real drag still marks the window as user-placed');
|
||||
});
|
||||
|
||||
test('#6641: the opened-card drag bar steps aside for its live title editor', () => {
|
||||
const handler = cardJs.slice(cardJs.indexOf("'mousedown .js-card-title-drag-handle'"));
|
||||
const body = handler.slice(0, handler.indexOf('\n },'));
|
||||
assert.match(body, /a, input, textarea, button, select, option, \.js-card-details-title/);
|
||||
assert.ok(/return;/.test(body), 'native mouse text selection returns before preventDefault');
|
||||
assert.ok(body.indexOf('return;') < body.indexOf('event.preventDefault()'),
|
||||
'the editor guard runs before the window drag suppresses browser selection');
|
||||
const body = titleHandler();
|
||||
assert.match(body, /onEditable: !!closest\('input, textarea, select, option, \.js-card-details-title'\)/);
|
||||
assert.deepEqual(cardHeaderPress({ ...press, onEditable: true, onControl: true }), { start: false, deferred: false },
|
||||
'native text selection in the editor is never a window drag');
|
||||
const begin = cardJs.slice(cardJs.indexOf('function beginCardWindowDrag'));
|
||||
assert.ok(begin.indexOf('if (!press.start') < begin.indexOf('preventDefault()'),
|
||||
'the editor guard returns before the window drag suppresses browser selection');
|
||||
});
|
||||
|
||||
test('the handle appears only when drag handles are on', () => {
|
||||
@@ -160,8 +174,9 @@ test('the handle appears only when drag handles are on', () => {
|
||||
.test(header), 'the handle is behind that question');
|
||||
// ...and then it is the only drag source: the bar steps aside when the
|
||||
// card has a handle (#6839).
|
||||
const handler = cardJs.slice(cardJs.indexOf("'mousedown .js-card-title-drag-handle'"));
|
||||
assert.ok(/closest\('\.card-details'\)\.find\('\.js-card-drag-handle'\)\.length > 0/.test(handler.slice(0, handler.indexOf('\n },'))),
|
||||
assert.ok(/closest\('\.card-details'\)\.querySelector\('\.js-card-drag-handle'\)/.test(titleHandler()),
|
||||
'the handler tells the decision when this card has a handle');
|
||||
assert.deepEqual(cardHeaderPress({ ...press, cardHasHandle: true, onControl: true }), { start: false, deferred: false },
|
||||
'with handles on, only the handle moves the window');
|
||||
assert.ok(/\.card-details:not\(\.card-details-with-handle\) \.card-details-header \{\s*cursor: move;/.test(cardCss),
|
||||
'and the bar shows it moves the window only while there is no handle');
|
||||
|
||||
Reference in new issue
Block a user