Compare commits

...
110 Commits
Author SHA1 Message Date
github-actions[bot] 149944726e Bump versions for v10.78 2026-08-09 15:27:00 +00:00
Lauri Ojansivu bf0b757862 Prepare v10.78 release 2026-08-09 18:25:59 +03:00
Lauri Ojansivu 1c7de782f0 CHANGELOG: the snap and release-notes fixes from the v10.77 build logs.
Thanks to xet7 !
2026-08-09 18:21:43 +03:00
Lauri Ojansivu a820b056b2 Snap: libcurl4 does not exist on armhf, so name the package noble ships.
The armhf snap failed all three attempts in v10.77 while s390x, ppc64el
and riscv64 each built on attempt 1. The Launchpad build log says why,
one line into the mongodb part:

    Stage package not found in part 'mongodb': libcurl4.
    Build failed

Ubuntu 24.04's 64-bit time_t transition renamed the library, and
`libcurl4` is not a real package on ANY architecture in noble - the
binary is `libcurl4t64` everywhere. On the 64-bit architectures the
renamed package keeps the old name alive:

    Package: libcurl4t64
    Architecture: amd64
    Provides: libcurl4 (= 8.5.0-2ubuntu10)

and the armhf libcurl4t64 has no Provides line at all, because there the
ABI really changed. That is what makes this class of bug reach exactly
one architecture, which is also what makes it look like flakiness on
that architecture.

It had already happened, to libssl3 and libgoogle-perftools4 in v10.71;
the comment above this list describes it exactly. libcurl4 was the same
transition and was left behind. Every other stage-package was checked
against the noble armhf main+universe indices at the same time - all 14
of them - and libcurl4 was the only one that did not resolve.

The workflow's own guidance made it worse. snapcraft ends a missing
stage-package as a "Stopped" build with no artifact, which from the
outside is indistinguishable from Launchpad cancelling a build, so the
message said "often an OOM in the Meteor npm install, or a transient
build-farm reset ... re-run, and if it persists on this arch open a
Launchpad question" - the wrong advice for a failure that will never
succeed on a re-run. The step now looks for the line first, names the
package that is missing, says it is NOT transient, explains the t64
Provides asymmetry, and gives the one-line archive query that checks a
name.

New test tests/snapStagePackages.test.cjs, 6 checks: no stage-package is
a known pre-t64 name, libcurl is asked for by the name noble publishes,
the two fixed in v10.71 have not regressed, and - negatively - a name
that was never renamed keeps its plain name, because the rule is not
"add t64 to everything". It also pins that the reason stays written down
beside the list. Verified it fails when libcurl4 is put back.

Thanks to xet7 !
2026-08-09 18:20:42 +03:00
Lauri Ojansivu 6dce9dda79 Release notes and snap logs: report what really happened.
Four things the v10.77 run got wrong, all of them silent - nothing
errored, the reports were simply not what they claimed to be.

THREE GOOD SNAPS WERE REPORTED AS FAILED. s390x, ppc64el and riscv64
each printed "Remote build <arch> succeeded on attempt 1" and downloaded
their .snap, and each job then ended FAILED - because the step that
SAVES the build log could not upload it:

    The path for one of the files in artifact is not valid:
    /snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt.
    Contains the following character:  Colon :

snapcraft names a remote-build log after the recipe and an ISO
timestamp, and upload-artifact refuses a colon because NTFS cannot hold
one. The colons become dashes now, so the timestamp is kept rather than
the name thrown away - and the upload is continue-on-error, because a
DIAGNOSTIC upload must never be able to fail the build it is diagnosing.
The snap is the deliverable; the renaming is not the last thing that
could ever make a log file unuploadable.

EVERY ROW OF THE PROVENANCE TABLE APPEARED TWICE. provenance-table.sh
globbed `provenance/**/*.tsv provenance/*.tsv`, and with globstar `**/`
matches ZERO or more directories, so the first pattern already covered
the second and every file was read twice. It is one pattern now, and
rows are also deduplicated on the WHOLE line - a retried step or a job
that ran again records an identical line, and nothing tells the copies
apart. Deliberately not `sort -u -k1,1 -k2,2`: two rows sharing a bundle
and a binary but differing elsewhere are not a duplicate, they are a
real disagreement about which Node.js went into a bundle, and hiding one
at random is worse than showing both.

AMD64 WAS MISSING FROM THAT TABLE - the platform most people download.
Its step runs `cd .build` before `mkdir -p provenance`, so the rows went
to .build/provenance while upload-artifact looked at provenance/ from
the workspace root and found nothing: "No files were found with the
provided path: provenance/". Every other build job records from the
workspace root, which is why only this one was affected. It uses
$GITHUB_WORKSPACE now.

SIX PLATFORMS SAID VERSION "LATEST". amd64, arm64, win64, win32,
mac-arm64 and mac-x64 passed the literal string; only the
extra-architecture job asked which version `latest` actually was, and it
did so with its own inline curl+python. "Which FerretDB did v10.77 ship"
is the one question that column exists to answer. All seven sites now
call the new releases/ferretdb-latest-tag.sh, which asks once per job
and caches, authenticates when there is a token so a shared 60/hour
limit is not what makes it fail, checks that the answer is shaped like a
tag before printing it into a markdown cell, and prints NOTHING and
exits 0 when it cannot find out - the caller's ${FERRET_TAG:-latest} is
then exactly what it had before. A release note must never fail a build
that produced a good bundle.

The table's own prose linked [wekan/node](https://github.com/wekan/node);
the binaries come from wekan/node-patches, which is what the rows
themselves already linked.

New test tests/provenanceTable.test.cjs, 20 checks, most of them running
the scripts rather than reading them. Verified in both directions: with
the doubled glob restored the glob guard fails, with amd64's relative
path restored its guard fails, and with the literal 'latest' restored
its guard fails.

Thanks to xet7 !
2026-08-09 18:15:22 +03:00
Lauri Ojansivu 47c81cd478 CHANGELOG: rewrap the Upcoming summary.
Thanks to xet7 !
2026-08-09 16:01:11 +03:00
Lauri Ojansivu bc426ba7c2 CHANGELOG: the Upcoming binaries table is FerretDB v1.48.0, not v10.77's v1.45.0.
Thanks to xet7 !
2026-08-09 15:58:58 +03:00
Lauri Ojansivu a0f445c723 CHANGELOG: the Docker provenance and bundled-binary freshness work.
Thanks to xet7 !
2026-08-09 15:51:44 +03:00
Lauri Ojansivu ce69db8c5f Pin that every bundled binary is fetched as the newest one.
WeKan ships binaries other projects publish - FerretDB, the MongoDB
Database Tools, Node.js - and fetches each from a release URL. There are
many such URLs, across workflows, release scripts and compose files, and
they must all agree: one that names a fixed version stops receiving
security fixes silently, and nothing about the build fails when it does.

That is not hypothetical. A Quay scan of the v10.77 image reported Go
advisories in the FerretDB binary baked into the bundle - stdlib 1.25.9
wanting 1.25.11, golang.org/x/sys v0.38.0 wanting 0.44.0. The source was
already fixed and v1.48.0 was already published carrying go1.25.11 and
x/sys v0.46.0; the image had simply captured an older latest at build
time. Had any of these URLs been pinned instead, the rebuild would not
have fixed it either.

The test finds the URLs by searching the repository rather than from a
list, so a build site added later is checked too, and it accepts three
shapes: latest/download/<asset>, the bare latest API endpoint that
release-all.yml asks which version latest resolved to for the provenance
table, and ${VAR} whose default is checked separately to be latest.
Node.js is a different mechanism with the same effect - NODE_VERSION is
the bare major 24 and resolve-node-source.sh answers with that CPU's
newest 24.x from whichever of nodejs.org, unofficial-builds or
wekan/node-patches has one - so it is asserted to stay that way.

Thanks to xet7 !
2026-08-09 15:50:11 +03:00
Lauri Ojansivu 5b31695343 Docker images: full build provenance, at every site that pushes one.
BuildKit attaches MINIMAL provenance by default - a build id and a timestamp -
which is what those `unknown on unknown` rows on quay.io are: the OCI marker for
"not a runnable image", one per platform, so `docker pull` never selects them.
mode=max records what actually answers a supply-chain question instead: which
commit, which base image, which build arguments.

WeKan pushes images from FOUR places - docker-publish.yml's build-push-action,
two `docker buildx build --push` in release-all.yml, and one in
releases/docker-build.sh - and none of them said anything about provenance, so
all four took the default. All four ask for mode=max now. The local `--load`
check build in docker-build.sh deliberately does not: the docker exporter cannot
carry an attestation at all.

SBOM stays off, and the test says why so it is a decision rather than a drift:
it enumerates every OS package and npm dependency, taking the attestation from
~10 KiB per platform to megabytes.

tests/dockerProvenance.test.cjs finds the build sites by SEARCHING for them
rather than from a list, so a fifth one cannot quietly fall back to the default.

It also pins a trap I fell into twice while writing this, once in the shell
script and once in a YAML `run:` block:

    docker buildx build \
      # explain the next flag
      --provenance=mode=max \
      --platform ... \

A `#` after a line continuation comments out the REST OF THE JOINED LINE, so
that is `docker buildx build` with every argument swallowed - no platforms, no
tags, no push. `bash -n` accepts it, because it is valid syntax; it is simply a
different command. Both comments live above their command now, and the test
fails on any comment sitting among a continued command's arguments - while
ignoring a comment block that merely quotes a multi-line example, which is what
it flagged first.

Thanks to xet7 !
2026-08-09 15:38:53 +03:00
Lauri Ojansivu 4a8fbd954e CHANGELOG: the Flatpak fix, beside the AppImage one it repeats.
Same group, because it is the same area and the same one-line cause - a gh call
without --repo in a job that does not check out. The subsection is 'bugs' now
rather than 'bug'.

Thanks to xet7 !
2026-08-09 15:25:28 +03:00
Lauri Ojansivu 04fb6e3381 Flatpak: attach the bundles to the release, and only the bundles.
The same one-line bug AppImage.yml had, and a second one beside it.

    gh release upload 'v10.77' assets/* --clobber
    failed to run git: fatal: not a git repository

Both flatpaks built and were uploaded as artifacts; the job that attaches them
does not check the repository out - it only downloads artifacts - so `gh` had no
git remote to infer the repository from. Every gh call in this workflow passes
--repo now, including the ones in jobs that DO check out.

The second: `assets/*` is not the bundles. The artifacts also carry the ostree
repository the flatpak was exported through - config, objects/, refs/,
summaries/, summary, summary.idx - which is build scaffolding, and attaching it
to a release would have put a few hundred directories there beside the two
files anyone wants. The upload names the bundles and their checksums instead.
TSC hit this exact thing and fixed it the same way.

No architectures to add, and the header now says why rather than leaving it to
be rediscovered. A flatpak runs against a RUNTIME, so an architecture exists
here only if freedesktop.org publishes org.freedesktop.Platform for it: x86_64
and aarch64, the i386 and arm runtimes having been discontinued. That is the
difference from AppImage.yml, which could grow to four - an AppImage carries its
own runtime binary, and those exist for i686 and armhf too.

Thanks to xet7 !
2026-08-09 15:13:39 +03:00
Lauri Ojansivu 5957fa0ad8 CHANGELOG: the two AppImage commits, under a new Upcoming section.
v10.77 was cut while they were being written, so they get their own Upcoming
above the release, and the links point at the hashes the rebase onto the version
bump produced rather than the ones that will be collected.

Thanks to xet7 !
2026-08-09 15:09:54 +03:00
Lauri Ojansivu 97e57e531b AppImage: add the other two architectures a runtime exists for.
x86_64 and aarch64 become four, with i686 and armhf. That is the whole set: an
AppImage BEGINS with a runtime binary the kernel executes, and runtimes exist
for exactly x86_64, aarch64, i686 and armhf. ppc64le, s390x, riscv64 and loong64
have none, so they cannot have an AppImage at all however the job is written -
they are served by the bundle zip and the .deb, and the matrix now says so where
somebody would otherwise try.

The two are not equally safe, and they are not treated as though they were.

i686 builds on the x86_64 runner, which runs 32-bit x86 natively: no emulation,
no multiarch, and appimagetool-i686 runs as-is. It is an ordinary job.

armhf builds on the aarch64 runner, which runs 32-bit ARM only if the kernel has
CONFIG_COMPAT and the 32-bit loader is installed. GitHub promises neither, so it
is continue-on-error - the same treatment TSC gives its emulated armhf job. If
it proves reliable, drop the flag; if it never works, drop the entry. What it
must not do is decide whether the other three reach the release.

The smoke test needed the distinction too. It starts the AppImage and waits for
WeKan to answer on port 8080, which a 64-bit runner cannot do for a 32-bit build
it cannot execute. A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK: when the
binary will not execute at all AND it is not this machine's architecture, the
AppImage is as good as anything here knows, so it warns, says it is uploaded
unchecked, and moves on. A build that DOES start and then does not answer still
fails - that is the bug the step exists for.

The publish job's two hardcoded architecture lists learned all four, so a
missing one warns instead of going unnoticed, and the base-bundle check looks
for wekan-<v>-{i386,armhf}.zip as well.

Untested here: this is a workflow, so the first real run is the test. The two
new jobs are the only ones that can fail, and armhf cannot take the release with
it.

Thanks to xet7 !
2026-08-09 15:08:23 +03:00
Lauri Ojansivu fa296d0c80 AppImage: attach the built AppImages to the release again.
Both AppImages built, both passed the smoke test that starts them and waits for
WeKan to answer on port 8080, both uploaded as artifacts, both downloaded by the
attach job with matching digests - and then nothing reached the release:

    gh release upload 'v10.77' assets/* --clobber
    failed to run git: fatal: not a git repository

The attach job does not check the repository out. It has no reason to - it only
downloads artifacts - so `gh` has no git remote to infer the repository from.
Every other gh call in this workflow already passes --repo; these two did not.

Every gh call passes it now, including the two in jobs that DO check out. A call
that relies on an inferred remote breaks the day somebody removes the checkout,
and the error it gives names neither gh nor the workflow.

Worth recording for whoever reads the failed run: the log looks far worse than
the bug. `::error::` lines appear in the build jobs' output for the bundle-file
check and the port-8080 check, and in the attach job for "no architecture
produced an AppImage" - none of which fired. They are the SCRIPT being echoed by
the ##[group] header, not output. The real failure is one line, in the last step
of the last job.

Thanks to xet7 !
2026-08-09 15:08:23 +03:00
github-actions[bot] 040c18bc23 Bump versions for v10.77 2026-08-09 11:16:31 +00:00
Lauri Ojansivu 7241570491 Prepare v10.77 release 2026-08-09 14:15:35 +03:00
Lauri Ojansivu 979ad3a5cd CHANGELOG: the two snap commits, under a new Upcoming section.
v10.76 was cut while they were being written, so they get their own Upcoming
above the release.

Thanks to xet7 !
2026-08-09 14:09:41 +03:00
Lauri Ojansivu 3c9291338c Snap CPU platforms: say which six, why not the other two, and all four channels.
The page listed five architectures and omitted armhf, which snapcraft.yaml has
built all along. It said the release publishes candidate, beta and edge and that
"the stable channel is published manually later" - which is no longer true and
was the reason a build reached three channels of four. And it said nothing about
the two architectures that are bundles but not snaps, so "missing" and "cannot
be there" looked identical.

The matrix is the six `build-for:` entries with the bundle name beside each,
because the two names are not always the same, and a new section explains the
three ways they differ:

  ppc64le / ppc64el   the same 64-bit POWER under two names - the bundles use
                      the kernel's, the store uses Debian's. The only rename.

  armhf / armv7       NOT a rename, and getting this wrong ships a snap that
                      crashes. node-patches builds armhf to the Debian baseline
                      (hard-float, VFPv3-D16, no NEON assumed) so it runs on any
                      ARMv7-A, and armv7 with NEON for boards that have it. The
                      store has ONE 32-bit ARM architecture serving every such
                      device, so it must carry the BASELINE build; the NEON one
                      would be an illegal instruction on a board without NEON.
                      armv7 ships as a bundle only.

  i386                cannot have a new snap at all, and this is categorically
                      different from a missing Node.js build. node-patches
                      patches SOURCE so a binary can be built; here the BASE SNAP
                      does not exist, because Ubuntu 24.04 has no i386 port. No
                      patch set can produce a base Canonical does not publish.
                      The last base with one was core18, end-of-life.

Then what to do about it: the three snaps, all four channels, and why stable is
possible - core24 is a released base, so the snap carries grade: stable, which a
grade: devel core26 snap cannot. A table of what each snap has in the store
today and what is still to upload, including the two fossils that channel
promotion cannot fix: wekan-ondra's armhf at 0.22 and its i386 at 0.X-ci, which
have no newer revision to promote.

And the caveat that matters when running it: without a pinned version the newest
revision of each architecture is promoted, and edge is often ahead of stable, so
a bare run publishes edge builds to stable users.

The armv7 note in models/lib/snapArchitectures.js said "different CPUs", which
was the right conclusion from the wrong reason. It now says what node-patches
actually builds, and a test cross-checks it against that repo's own workflow so
the explanation cannot drift from the binaries.

Thanks to xet7 !
2026-08-09 14:05:17 +03:00
Lauri Ojansivu a5d8a1fd97 Snap: release every snap, every architecture, to all four channels.
The helper that did this released ONE snap, ONE revision, to THREE channels:

    snapcraft release wekan $1 edge,beta,candidate

wrong three ways at once. It names only `wekan`, leaving wekan-ondra and
wekan-gantt-gpl to be done by hand. It takes ONE revision number, and revisions
are PER ARCHITECTURE - the store's own listing shows wekan at 3601 on amd64 and
3600 on arm64 for the same 10.76 - so one number can only ever be right for one
of them. And it leaves out `stable`, so a build reached three channels of four
and somebody had to remember the fourth.

releases/snap-release-all-channels.sh resolves the revision per (snap,
architecture) from the store itself, so no revision number is ever typed, and
releases it to all four channels in ONE call - a revision reaches all of them or
none. A (snap, architecture) with no revision is reported and skipped rather
than failing the run: the three snaps genuinely have different architecture
sets, and one architecture failing must not stop the rest.

THE MAPPING IS THE HAZARD, and it is now in one place with both directions
tested (models/lib/snapArchitectures.js):

  ppc64le and ppc64el ARE the same hardware. The bundles at
  https://github.com/wekan/wekan name it the way Node.js and the kernel do; the
  Snap Store names it the way Debian does. Nothing warns when the wrong one is
  used - an unrecognised --arch is simply an architecture the store has never
  heard of, so it looks like it worked.

  armhf and armv7 are NOT. armhf is 32-bit Raspberry Pi OS; armv7 is the
  ODroid-U3. Different CPUs, different bundles. The store has ONE 32-bit ARM
  architecture, armhf, so that is what is published; mapping armv7 onto it would
  hand Raspberry Pi users the ODroid build. The negative test for that is the
  most important one in the suite.

The six architectures are exactly snapcraft.yaml's `build-for` list, and a test
compares the two so the table cannot drift from what is actually built. i386 and
armv7 are recorded as not-built, with their reasons, and the script prints them
on every run - "it is missing" and "it cannot be there" look identical from the
outside.

tests/snapArchitectures.test.cjs: 23 checks including that the newest revision
for THAT architecture wins, that a version filter publishes what was built
rather than whatever was uploaded last, that `snapcraft list-revisions` parses
when a revision has no channels, and that junk never reaches snapcraft.

Thanks to xet7 !
2026-08-09 14:05:17 +03:00
github-actions[bot] ab29f74f6a Bump versions for v10.76 2026-08-09 02:36:55 +00:00
Lauri Ojansivu 48d8dc9de8 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-08-09 05:35:11 +03:00
Lauri Ojansivu 983aba8488 Prepare v10.76 release 2026-08-09 05:35:10 +03:00
Lauri Ojansivu 988e2f0794 CHANGELOG: the three round-trip fixes, under a new Upcoming section.
v10.75 was cut while they were being written, so they get their own Upcoming
above the release. The FerretDB one is summarised here and told in full in that
repo's CHANGELOG, since it is the database that changed.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
Lauri Ojansivu 90a41dbb22 Two queries per request instead of two per card.
Neither of these gave a wrong answer. They asked the right question one document
at a time, and each await is a full round-trip that starts only once the last
one finished - invisible on a board with four cards, and the whole cost of the
request on a real one.

The bulk label endpoint (PUT .../cards/labels) awaited a getCard per id and then
an update per id. At BULK_CARDS_MAX = 500 that is ONE THOUSAND sequential
round-trips for a single request. The reads are all the same question, so they
are one `$in` query indexed into a Map; the writes genuinely differ - each card
merges its own labelIds - so they stay individual updates, but they are issued
together and awaited once instead of each waiting for the last.

Two details that had to survive: the loop still iterates cardIds rather than the
query result, because a batch read comes back in the database's order and
without the ids that matched nothing - iterating it would silently reorder
`updated` and lose `notFound`. And every write is awaited before the 200 is
sent, so the response still means what it says.

The bulk DELETE beside it is deliberately left alone: its per-card work is real
(cardRemover runs the sub-item hooks, each card gets its own activity), so
batching the reads would save one query inside a loop that does far more than
query.

Global search resolved each `user:`/`member:`/`assignee:`/`creator:` name with
its own awaited findOne, so `member:ann member:bob member:carol` was three
serial round-trips before the search itself could start. They are all the same
question - which of these names is an account - so it is asked once, with `$in`,
and answered from a map. A name typed under two operators is one lookup now, not
two. Unknown names are still reported against the operator they were typed
under, because "ann is not a user" is not useful without saying where ann was
typed.

tests/queryBatching.test.cjs pins both, including the negatives: no per-card
read remains in the label handler, no per-username query remains in search, the
response order and notFound list are preserved, and the delete handler is still
per-card on purpose.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
github-actions[bot] 8da91adaa7 Bump versions for v10.75 2026-08-09 01:48:39 +00:00
Lauri Ojansivu 80830cd43e Prepare v10.75 release 2026-08-09 04:46:10 +03:00
Lauri Ojansivu f2edde0651 CHANGELOG: a new Upcoming section for the two commits after v10.74.
The release script renamed the old Upcoming to v10.74, and two commits landed
after it: the linked-card cache that could not see a card added later, and the
build-script parity fix. They get a fresh Upcoming section above the release,
which is where development entries go.

Both were rebased onto the version bump, so their hashes changed; the links
point at the ones that will be pushed, not the ones that will be collected.

Thanks to xet7 !
2026-08-09 04:44:59 +03:00
Lauri Ojansivu ada480c93a Linked-card ids must not be cached for the life of a subscription.
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: publishComposite re-runs a child's find() when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.

The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
Lauri Ojansivu 114d6c0599 Build script parity: git pull and git push, on Windows too.
The one red suite in the test run, and it was right twice over.

`Setup: "git pull" and "git push" that finish the job` added two entries to
build.sh's menu and left build.bat behind, so `build.bat git-pull` answered
nothing. It answers to both names now, with its own :gitpull/:gitpush labels -
the ones its interactive menu already used.

The guard also had to learn what a `!` entry is. It marks something build.sh
runs ITSELF rather than by executing a file in releases/. Most are raw commands
and the .bat runs those the same way, so those still compare as before; the
exception is an entry naming a build.sh FUNCTION, which is shell the .bat has no
way to call. Comparing those as scripts is what made this suite fail the moment
"Update git" was replaced. A function entry is now exempt from the script
comparison and checked separately: build.bat must implement a label of the same
name, so one dropping out of a menu still fails.

The node suites are 339 run, 0 failed for the first time in this branch.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
github-actions[bot] fc0ce5137b Bump versions for v10.74 2026-08-09 01:01:29 +00:00
Lauri Ojansivu c3c95cefb9 Prepare v10.74 release 2026-08-09 04:00:21 +03:00
Lauri Ojansivu aea9811a76 CHANGELOG: link the follow-up commit the ParentBleed entry describes.
The five linked-card cursors are its own commit, and its story is told inside
the ParentBleed entry - so a reader could read about the fix and had no way to
reach it.

Thanks to xet7 !
2026-08-09 03:54:58 +03:00
Lauri Ojansivu 5a1227da6c CHANGELOG: the dependency bumps and the two tooling commits, and the subsections in the order CLAUDE.md gives.
Six dependabot bumps and two developer-facing commits since v10.73 had no entry
at all. Adding them showed the subsections were also out of order - fixes came
before features - so they are now CRITICAL SECURITY, features, dependencies,
bug fixes, developer-facing, which is what CLAUDE.md specifies.

Thanks to dependabot and xet7 !
2026-08-09 03:50:24 +03:00
Lauri Ojansivu 8a5ca2c2de CHANGELOG: the canary tokens, the filesystem integrity check and the two Database problems fixes, under Upcoming.
Thanks to xet7 !
2026-08-09 03:44:21 +03:00
Lauri Ojansivu 30e8e38f6c Fix the two bugs Admin Panel / Problems / Database problems was reporting.
The page was doing its job and nobody had acted on it. Two real WeKan bugs were
sitting there as rows marked "unknown / unclassified", whose advice was to add a
rule to models/lib/databaseErrors.js. Both are fixed, and both now classify.

1. moveSwimlane, four times over a week:

     Error: update is not available on the server. Please use updateAsync()

   Board.ensureDefaultSwimlaneId() called the SYNCHRONOUS Swimlanes.upsert().
   Meteor 3 removed it on the server, so every move that reached the default
   swimlane self-heal threw. The async twin already existed one function below.
   This getter is synchronous and cannot await, so the upsert is now STARTED and
   not waited for: the self-heal still happens, and the call returns what is in
   the cache - which every caller already had to handle, because it was
   undefined before the self-heal existed at all. A caller that needs the
   swimlane back in the same tick uses ensureDefaultSwimlaneIdAsync().

2. moveList:

     ValidationError: Failed validation
     Cannot read properties of undefined (reading 'title')

   List.move() inserted a new list with `title: this.title`. When a list has no
   title, `title` is required by the schema, so the insert fails validation - and
   collection2's own error formatter then reads a property of the undefined field
   and throws, which is why the admin saw a crash naming neither the list nor the
   real problem. It now says what is wrong before the insert: "This list has no
   title, so it cannot be moved to another board. Give it a title first." The two
   console.log lines that used to sit there printed the title to a log nobody
   reads and did not stop the crash; they are gone.

And two classifier rules so neither reads as "unknown" again. Both say plainly
that this is WeKan's bug rather than the database's or the admin's - there is
nothing to configure - and send the reporter to the right place: upgrade, then
open an issue with the method name. Severity `warning`, the vocabulary this
stream uses.

Thanks to xet7 !
2026-08-09 03:43:24 +03:00
Lauri Ojansivu 2cc0d42ddf Filesystem integrity: is that still the file WeKan stored, and did this server stop cleanly?
Attachments and avatars are files under WRITABLE_PATH, and the database holds one
document per file. NOTHING checked that the two still agree. A file can be
replaced, truncated, back-dated or deleted by anything that reaches the
filesystem - a bad restore, a sync tool, a container rebuild, a shell on the
volume - and WeKan would keep serving whatever is there now, silently.

A baseline is recorded per file in the existing WeKan database (no new files
under WRITABLE_PATH): path, size, modification time, and md5, sha256 AND sha512.
Three digests, because they answer different questions. md5 is what other tools
print, so an admin can compare against a backup with what they already have - it
is never alone, its collision resistance is gone. sha256 is the working digest.
sha512 earns its place on the day the two DISAGREE: two digests over the same
bytes cannot, so when they do, the bytes were not read the same way twice - a
failing disk, a partial write, a truncated copy - which is a different problem
from a substitution and is its own critical finding.

ed25519 is the fourth check and it is NOT a hash - it is a signature, and it
answers the one thing three digests cannot: who says these are the right hashes?
Anybody who can rewrite a file can rewrite a row of hashes. Each entry is signed
over a canonical line (fixed field order, no JSON, whose escaping is not
guaranteed stable) and verified on every scan. A record that does not match its
signature is critical and is NEVER explained away by an ordinary edit. The key
comes from WEKAN_INTEGRITY_PRIVATE_KEY, which WeKan never stores - that is the
configuration that means something against an attacker who reaches the database
too - or is generated and kept in the database, which detects filesystem-only
tampering and is honest about being no more than that. A MALFORMED supplied key
is reported rather than silently replaced by a generated one, which would look
like it was working while checking nothing the operator meant.

The scan is paced by policy, not by hope: once a day, checked hourly; nothing
starts or continues at or above 60% CPU; 50 ms between files plus 20 ms per
megabyte; a fifteen-minute budget, after which it stops and continues tomorrow;
and ONE read per file for all three digests. A run that stopped early does not
report the files it never reached as missing.

The finding is a change WITH NO RECORD SAYING WHY. Files change when people use
WeKan, and those changes are accounted for, reported once at info and
re-baselined so they do not repeat daily. A change with nothing to explain it is
at least medium and is deliberately NOT re-baselined, so it keeps showing until
somebody looks. A modification time that moved BACKWARDS is called out
separately: nothing does that by accident.

Crashes and downtime, in the same stream, because a crash leaves nothing behind:
the process is gone, so it cannot write a message about being gone. A heartbeat
every minute is the only thing that works, and the NEXT start reads it. A first
run and a clean stop record nothing - reporting every deliberate restart is how
a Problems page becomes one nobody opens. A short gap with no clean-shutdown
mark is a note; a long one is a high-severity "it stopped without shutting down
cleanly, and here is how long it was down". Uncaught exceptions and unhandled
rejections land here too, with their cause, before the process goes.

Admin Panel / Problems / Filesystem integrity is the new stream, with the
Username / IP address / Attempts columns. The baseline itself is never published
and never client-writable: it is a map of every file on the server, and the key
document is a private key.

tests/fileIntegrity.test.cjs, 34 checks: every digest case including same-size
substitution and disagreeing digests, the ed25519 forgery attempt, the canonical
line, explained vs unexplained, and the pacing - including that an unreadable
clock does not start a scan, which it did until the test said so.

Thanks to xet7 !
2026-08-09 03:43:07 +03:00
Lauri Ojansivu d577f07700 Canary tokens: who tried to override permissions, and from where.
The security event log said what a guard DID - a request blocked, a filename
sanitized - which answers "is WeKan defending itself" and not the question an
admin asks next: who did that, and from where. It also could not tell a browser
that got confused from somebody working through the Hall of Fame one entry at a
time.

A CANARY is a tripwire at a point that only a permission-override attempt
reaches. Ordinary use never gets there - the code path runs when a request asks
for something the rules forbid - so a trip is not noise, and it is worth an
admin's attention with the actor attached. Design:
docs/Security/Remediation/WeKan.md §12.

Three properties, each enforced by a test:

SILENT. Tripping one changes NOTHING the caller can observe. tripCanary()
always returns false and tripCanaryDeny() always returns true, so a call site
reads as the refusal it replaces:

    if (forbidden) return false;                          // before
    if (forbidden) return tripCanary('card.vote-field', { userId });

The return value is decided before any reporting is attempted and the reporting
is wrapped, so a canary can never throw into, delay or disturb the request it
watches. The REST handler re-throws the ORIGINAL error object. This is not
politeness: a canary that announces itself is a map of which paths are watched,
and a probe would simply avoid them.

BOUNDED. A canary sits where an attacker can loop, so one row per attempt would
be a denial of service they get for free. models/lib/canaryTokens.js - pure, no
Meteor, no clock of its own - records the first trip of a (canary, actor) pair
at once, COUNTS the rest of the window, flushes one summary carrying the total
when the window closes, and stops writing after 60 summaries for a pair that
never gives up. The tracked-pair map is capped at 5000 and evicts the LEAST
RECENTLY seen, so a long-running attacker is not pushed out by a passing one and
a botnet varying its address cannot grow it. Idle pairs are swept on a timer. A
thousand attempts in a minute cost ONE row, and a suppressed trip costs one map
lookup. The username lookup is cached and capped, and neither it nor the insert
is awaited on the caller's path.

ATTRIBUTED. Every event carries userId, username, ip and count. The username is
denormalised at write time on purpose: it is what the account was CALLED when it
tried, so a rename does not rewrite history and a deletion does not erase it.
The address uses the same spoofing-safe resolver as the login throttle -
X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - or an
attacker could write a colleague's address into the security log by sending a
header. `detail` says what was attempted in the words of the feature, never the
payload.

Seventeen canaries are wired at the permission checks that refuse the attempts
behind BoardBleed, ParentBleed, ChecklistBleed, PathBleed and CommentBleed, plus
the direct vote/poker field writes and a stored path that points outside the
storage root. All server-side: no Playwright, no browser, nothing to install.

Admin Panel / Problems / Security gains Username, IP address and Attempts
columns, and both new columns are searchable - the thing an admin does with one
security event is pivot on it.

tests/canaryTokens.test.cjs (25 checks) drives the limiter through bursts, an
endless probe, eviction order and junk input; tests/canaryCoverage.test.cjs (18)
pins every canary to its call site and asserts none of them changes what the
caller gets back.

Thanks to xet7 !
2026-08-09 03:29:20 +03:00
Lauri Ojansivu 839d3a820c CHANGELOG: the audit findings and the security-test coverage, under Upcoming.
The five linked-card cursors and the streaming attachment read join the
ParentBleed and PathBleed entries they extend; the comment-reaction ownership
fix and the security-test coverage guard get subsections of their own.

Thanks to Alpastx and xet7 !
2026-08-09 02:58:35 +03:00
Lauri Ojansivu a4a9c03b1f Security tests: say which vulnerability you guard, and check the list.
"Are the security tests enough to prevent what is in the Hall of Fame" was
unanswerable. WeKan publishes 58 named vulnerabilities at
https://wekan.fi/hall-of-fame/ ; some suites name the one they belong to, most
did not, and the Hall of Fame lives in a different repository that CI never
checks out. Answering it meant reading 58 pages against 300 suites by hand,
which means it was never answered - and a regression test that quietly stops
existing is worth nothing, the failure mode tests/testsAreRegistered.test.cjs
already caught once when two *bleed suites drifted out of the mocha index.

So the list lives in the repository that has the tests, and
tests/securityRegressionCoverage.test.cjs keeps the two in step. Every published
vulnerability is either GUARDED - named by a suite that still exists - or
RECORDED, a gap with a written reason, which is the CHANGELOG's "TODO Later"
pattern applied to tests. The gap count is pinned, so a vulnerability cannot be
published with neither a test nor a note; a gap that turns out to be guarded
after all fails too, so coverage is never understated; and a file that merely
REGISTERS suites is not accepted as coverage, or the guard would pass itself.

Getting there meant naming vulnerabilities in the suites that already guarded
them, which is most of what this commit is:

- tests/securityMeifukun.test.cjs guards eight reports and named one. Its
  sections are RedirectBleed, SourceBleed, LiveBleed, CasBleed, OidcBleed,
  MetricsBleed, ImpersonateBleed and InviteBleed - seven vulnerabilities that
  looked untested and were not.
- tests/noIdentityReplacement.test.cjs guards IdentityBleed and PatternBleed,
  the same CodeQL finding twice.
- ExportBleed, CrashBleed, MimeBleed and the four LockoutBleed suites now say so.
- tests/crossBoardParentCardLeak.test.cjs also guards BoardBleed's cross-board
  move deny, and now checks it on Lists and Swimlanes too, not only Cards.

The count that comes out of it: 29 of 58 published vulnerabilities have a named
regression test, and 29 are recorded gaps - mostly older fixes from before WeKan
tested its security fixes at all. They are not known to be unprotected; they are
known to be unchecked, which is a different and more honest statement, and each
one now says what it would take to close it.

Thanks to xet7 !
2026-08-09 02:55:13 +03:00
Lauri Ojansivu 156121c4bc Comment reactions: react as yourself, not as somebody else.
Found while auditing for more of GHSA-pqr4-rxgp-hv2m (CommentBleed - the REST
comment delete that checked board membership and nothing about WHOSE comment it
was). A CardCommentReactions document holds

    { cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }

and the whole array is ONE field, whose allow rule was board membership for
insert, update and remove alike. So any member could $set `reactions` to
anything: add a colleague's userId to a reaction they never made, or remove one
they did. `toggleReaction()` only ever touches Meteor.userId(), so no legitimate
client sends anything else - the rule simply never said so.

Integrity rather than confidentiality: reactions are visible to the whole board
already. But it puts words in another person's mouth, and it is the same shape
as the advisory - a per-user object gated only by "are you on this board".

A deny rule now refuses an update that changes any OTHER user's presence in any
reaction. The decision is pure and lives in models/lib/reactionOwnership.js:
it compares MEMBERSHIP rather than array order, because the client rebuilds the
array on every toggle and a reordered array with the same membership is the same
set of reactions. The modifier forms that cannot be checked that way -
$push/$pull/$addToSet/$unset, or a dotted `reactions.0.userIds` - are refused
outright, since toggleReaction() always $sets the whole array and letting them
through would be a hole the size of the one being closed.

The membership rule it sits on top of is unchanged: read-only and no-comment
members still may not react at all.

tests/reactionOwnership.test.cjs covers both directions - adding and removing my
own reaction, the first reaction and the last one going away, a reordered array,
several of my own toggled at once - and the attacks: adding somebody else,
deleting somebody else's, wiping an array that was not only mine, moving
somebody else between reactions, an anonymous caller, and junk in either array.

Thanks to xet7 !
2026-08-09 02:54:56 +03:00
Lauri Ojansivu 2c4e81ae34 ParentBleed, five cursors further: linked cards leaked the same way.
GHSA-jvv9-498p-hxrg was reported against the ANCESTOR cursor of the `board`
publication - a cross-board `parentId` made it publish a private board's cards
to another board's subscribers. Auditing for more of the same found the
linked-card cursors beside it with the identical hole and a wider blast radius:
five of them, publishing the linked card, its comments, its attachments, its
checklists and its checklist items.

A `cardType-linkedCard` names a card by id exactly as `parentId` does, and that
card may live on any board. Creating one requires read access to that board -
but the person who created it is not the person the publication is sending to.
Every subscriber of the board carrying the linked card was getting the source
card's full document and all of its children, whether or not they may see the
board it lives on.

The same answer as the ancestor cursor: a linked card whose source board the
subscriber cannot see is not sent. Cards on THIS board are their own answer,
since they are already going to this subscriber. The five cursors repeated the
same fifteen-line preamble verbatim; they share one helper now, memoized per
board, which is also what stops the sixth from being written without the check.

Two more findings from the same audit, in the same file and its neighbour:

- Three further hand-written copies of the board-visibility `$or` (the boards
  publication, getAllBoardsPage, getAllBoardsTileData). All three were correct,
  which is exactly the state RevokeBleed's copy was in before it drifted. They
  call boardVisibilitySelectors() now, so there is no hand-written copy left in
  the file.

- The STREAMING attachment export read `versions.original.path` with nothing but
  an existsSync. The attachment allow rule refuses a client-supplied path, so
  this is not the PathBleed hole - but it was the last place a stored path was
  read without the containment check, and a path is only as trustworthy as every
  way it could have been written.

tests/crossBoardParentCardLeak.test.cjs grows six checks: all five cursors go
through the helper, the helper asks the same visibility question, no cursor
builds its own linkedId list any more, the assigned-only member narrowing
survived the de-duplication, the helper is memoized, and no hand-written
visibility array remains.

Thanks to Alpastx and xet7 !
2026-08-09 02:54:41 +03:00
Lauri Ojansivu b37adda8a4 CHANGELOG: the four Alpastx security reports, under Upcoming.
PathBleed, RevokeBleed, ParentBleed and CommentBleed, each linking its own fix
commit and its Hall of Fame page, under a CRITICAL SECURITY ISSUES header.

Thanks to Alpastx and xet7 !
2026-08-09 02:27:23 +03:00
Lauri Ojansivu a8fa6cfa3f CommentBleed: the REST API let any board member delete anyone's comment.
Over DDP, deleting a comment is author-or-board-admin, and a board that sets
`restrictCommentEditing` takes even the admin's ability away (#5906). That rule
was enforced in a CardComments collection hook keyed off the Meteor userId, with
an early `if (!userId) return` for the genuine server-internal callers - board
copy, cleanup, migrations - that run with no authenticated user.

An HTTP request carries no Meteor userId into the invocation context. So

    DELETE /api/boards/:boardId/cards/:cardId/comments/:commentId

which checked only `Authentication.checkBoardAccess` - board MEMBERSHIP - reached
the collection with no user, the hook took its "server-internal, trust it" path,
and any normal member could delete any comment on the board. HTTP 200, the
comment gone, restrictCommentEditing or not, while the same deletion over DDP
was correctly refused (CWE-639, CWE-863). Broken object-level authorization:
harassment and evidence destruction on a shared board.

The fix does not rely on the hook seeing something it cannot see. The handler
loads the comment - 404 when there is none - and applies the same rule itself,
with req.userId, before removing anything. That rule is now an exported function
(`assertCanMutateComment` in models/cardComments.js) that the hooks and the REST
handler both call, so DDP and HTTP cannot enforce different things; it carries a
403 statusCode so a refusal answers Forbidden rather than 500. The no-userId
path stays, documented for the internal callers it was written for.

tests/restCommentDeleteAcl.test.cjs pins that the handler loads the comment,
that the ACL runs BEFORE the removal, that membership alone is no longer
sufficient, that the DDP hooks still call the same function, and - so a future
edit route cannot repeat this - that no REST PUT for comments exists without the
same check.

Thanks to Alpastx and xet7 !

Fixes GHSA-pqr4-rxgp-hv2m.
2026-08-09 02:20:52 +03:00
Lauri Ojansivu 35daaaa279 ParentBleed: one card id bridged a private board into another board's feed.
A card's `parentId` may name a card on ANOTHER board, and setting it was
authorized only against the CHILD board's write ACL - nothing asked whether the
person setting it, or the people subscribed to the child board, were allowed to
see the other board at all.

The board publication walks the whole ancestor chain, because the
'prefix-with-full-path' subtask setting renders a subtask's complete path
(#3453), and it published the complete ancestor card DOCUMENTS to every
subscriber of the child board. So a member who could write on shared board B and
knew a card id on private board A could point a card at it and have A's card -
title, description, custom fields - delivered over DDP to people who are not
members of A (CWE-200, CWE-862).

Closed at both ends, because either alone leaves half the hole open.

The WRITE refuses a parent whose board the actor cannot see. That is one
question, asked in one place (server/lib/visibleBoardIds.js) with the same
selectors the publication and All Boards use - so an ACTIVE org, team or domain
share counts and a revoked one does not. It is enforced on the REST card create,
on the REST card update (the PUT the report used), and on DDP by a deny rule
covering both insert and update, beside the cross-board MOVE deny that
GHSA-gm7v-pc38-53jr added. A parent that does not exist is refused too. Clearing
or omitting a parent is untouched.

The PUBLICATION sends only the ancestors whose board the subscriber may see. The
board being published is its own answer, so a normal same-board subtask path is
unchanged; an ancestor on a board the subscriber cannot see is simply not sent,
and a chain that yields nothing publishes nothing.

This is the shape of the check the linked-card path already made - creating a
linked card requires read access to the source card's board - applied to the
field that did not have it.

tests/crossBoardParentCardLeak.test.cjs pins the filtered cursor, that the
unfiltered publish of every ancestor is gone, that both REST paths validate
before writing, that the deny rule covers insert as well as update, and that
clearing a parent and the pre-existing cross-board move deny still work.

Thanks to Alpastx and xet7 !

Fixes GHSA-jvv9-498p-hxrg.
2026-08-09 02:20:37 +03:00
Lauri Ojansivu 08baf7fd1b RevokeBleed: revoking an org, team or domain share did not revoke it.
`isActive: false` is how a board admin REVOKES a share with an organization, a
team or an email domain. The All Boards list honoured it - it matches shares
with `$elemMatch: { ..., isActive: true }` - so a revoked user watched the board
disappear from their list and everything looked correct.

The `board` publication did not. That is the publication that sends the board
document and its lists, swimlanes, cards, comments and attachments, and it
matched shares with a dotted selector instead:

    $or.push({ 'orgs.orgId': { $in: orgsIds } });
    $or.push({ 'teams.teamId': { $in: teamsIds } });
    $or.push({ 'domains.domain': { $in: emailDomains } });

A dotted path matches an element of the array without saying anything about
that element's other fields, so isActive was never consulted. Anyone who still
knew the boardId - a bookmark, a browser tab, a note - could subscribe and
receive the whole private board after their access had been taken away
(CWE-639, CWE-863). A revoke the primary data publication does not honour is
not a revoke.

The rule was written out twice and the two copies disagreed, so it is written
once now: models/lib/boardVisibilitySelectors.js builds the `$or`, both
Boards.userBoards and the `board` publication call it, and every share kind is
matched with `$elemMatch` requiring `isActive: true`. `includePublic: false`
still drops the public clause and only that clause, which is what the search
over all boards needs.

tests/boardShareRevokeBypass.test.cjs pins each share kind's selector shape,
that no clause anywhere matches a share without requiring isActive, that the
three dotted selectors are not produced at all, and that junk in the id lists is
dropped rather than handed to Mongo. tests/searchBoardScope.test.cjs asked its
question of the old array literal; it now asks it of the builder - the
behaviour it protects is unchanged.

Thanks to Alpastx and xet7 !

Fixes GHSA-gwc4-fw7p-gw58.
2026-08-09 02:20:13 +03:00
Lauri Ojansivu 0a9eb0ef4f PathBleed: an avatar could name any file on disk, and board export read it.
Attachments blocked client-supplied `versions.*.path`; avatars did not. The
avatar allow rule was the single line `update: isOwner` - being the owner let
you write ANY field of your own avatar document, `versions.original.path`
included. That field is server-managed metadata: it says where on disk the
bytes are.

Board export then read it. For every member whose avatar is a local WeKan file,
the exporter opens `versions.original.path` and embeds the bytes as base64 in
`profile.avatarFile`, so an authenticated user could point their own avatar at
/etc/passwd - or at anything else under WRITABLE_PATH - export a board they are
a member of, and decode the file out of the JSON. Arbitrary file read as the
WeKan OS user (CWE-22, CWE-73), from any account that can own an avatar.

Both halves are closed.

The WRITE: the guards that attachments had are now in one module,
models/lib/fileVersionFields.js, and BOTH permission files import it - avatars
had gone without them precisely because each collection wrote its own copy.
Avatars refuse an insert carrying versions.*.path or .storage, refuse any update
touching the versions subtree, and restrict updates to the same field whitelist
attachments use. Being the owner is still required; it is no longer sufficient.

The READ: nothing is read from a stored path unless the path RESOLVES to
somewhere inside WeKan's own storage (models/lib/storagePathContainment.js).
That is the half that also holds when a path is poisoned some other way - a
document written before this fix, a restored backup, a bad migration. The check
sits at getBase64Data, the one place the export turns a stored path into bytes
for attachments and avatars alike, and at both avatar call sites. The download
path in fileStoreStrategy.js has always checked containment this way; its
private copy of the function is gone and it uses the shared one, so the download
and the export ask the same question.

tests/avatarVersionPathTraversal.test.cjs pins the payload being refused on
insert and on update, that renaming an avatar still works, that ..-escapes are
resolved rather than string-matched, that a sibling directory with the root as a
prefix is not inside it, and that no unguarded read of a stored version path
remains in the exporter.

Thanks to Alpastx and xet7 !

Fixes GHSA-4mxf-m8pq-xc9p.
2026-08-09 02:19:15 +03:00
Lauri Ojansivu b2ef10d9d1 Merge pull request #6579 from wekan/dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 4 to 7
2026-08-08 23:41:43 +03:00
Lauri Ojansivu ad62101e10 Merge pull request #6578 from wekan/dependabot/npm_and_yarn/aws-sdk/lib-storage-3.1104.0
Bump @aws-sdk/lib-storage from 3.1085.0 to 3.1104.0
2026-08-08 23:41:27 +03:00
Lauri Ojansivu a1c500e33b Merge pull request #6577 from wekan/dependabot/npm_and_yarn/markdown-it-15.0.0
Bump markdown-it from 14.3.0 to 15.0.0
2026-08-08 23:41:09 +03:00
Lauri Ojansivu 454ec04b97 Merge pull request #6576 from wekan/dependabot/npm_and_yarn/tests/playwright/playwright/test-1.62.1
Bump @playwright/test from 1.62.0 to 1.62.1 in /tests/playwright
2026-08-08 23:40:53 +03:00
Lauri Ojansivu f4e5843016 Merge pull request #6575 from wekan/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 4 to 7
2026-08-08 23:40:40 +03:00
Lauri Ojansivu 0dbcfc44e0 Merge pull request #6574 from wekan/dependabot/github_actions/actions/download-artifact-8
Bump actions/download-artifact from 4 to 8
2026-08-08 23:40:26 +03:00
dependabot[bot] 0612630836 Bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:33:36 +00:00
dependabot[bot] eab75eab70 Bump @aws-sdk/lib-storage from 3.1085.0 to 3.1104.0
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.1085.0 to 3.1104.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1104.0/lib/lib-storage)

---
updated-dependencies:
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1104.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:33:15 +00:00
dependabot[bot] aa8f591839 Bump markdown-it from 14.3.0 to 15.0.0
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.0 to 15.0.0.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.0...15.0.0)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:49 +00:00
dependabot[bot] e16ffbc12b Bump @playwright/test from 1.62.0 to 1.62.1 in /tests/playwright
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:48 +00:00
dependabot[bot] d911d71e53 Bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:46 +00:00
dependabot[bot] 79b10a1b43 Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:35 +00:00
Lauri Ojansivu 8c2565e996 releases/: delete two superseded scripts, and two exemptions that outlived them.
An audit of releases/ against who actually calls each script. It holds 89 of
them, and they divide cleanly:

  20  invoked by .github/workflows - CI checks out the repo and runs the FILE, so
      these cannot move anywhere;
  65  reachable from the build.sh / build.bat Releases menu, which is what the
      menu is for;
   1  sourced by another script (ensure-tools.sh);
   3  referenced by nothing that runs.

Of those last three, wekan-entrypoint.sh is the Docker image's entrypoint - named
by the Dockerfile, not by a caller in this repo - so it stays.
old-build-bundle-arm64.sh and translations/old-pull-translations.sh are the other
two, and both were already recorded as superseded, by build-bundle-arm64.sh and
pull-translations.sh. Nothing referenced them but the exemption list that excused
them from being menu entries. They are deleted, and their exemptions with them.

Two more exemption notes were wrong rather than stale-adjacent:
fix-changelog-hashes.sh said "Setup -> Update git runs it", an option that no
longer exists - it is Setup -> git pull and git push now, both of which run it -
and resolve-fork-node-tag.sh excused a file that is not in the tree at all. An
exemption naming a missing script is an exemption nobody can check.

Checked after: every remaining releases/ script is either a menu entry or exempt
with a reason, and no exemption names a file that does not exist.

Thanks to xet7 !
2026-08-08 14:22:12 +03:00
Lauri Ojansivu 6def3a14dd Setup: "git pull" and "git push" that finish the job, replacing "Update git".
"Update git" fetched, rebased, ran the CHANGELOG hash repair, printed git status
and left the rest to the reader - and the rest is where the mistakes were. A
rebase rewrites local commits, so CHANGELOG links written before it name commits
that no longer exist; the repair fixes the file but commits nothing, so the next
push carries a dirty tree; and a push rejected as non-fast-forward leaves a repo
that needs a pull the caller has to know to run. Three manual steps after an
option whose name did not say what it did.

Two options now, one per direction, each ending in a state that is either correct
or unchanged:

  git pull  - fetch; fast-forward when there is nothing local to replay (no
              commit is rewritten, so no link can go stale); otherwise rebase,
              then repoint the CHANGELOG links the rebase moved AND commit that,
              so the tree is clean. A conflict is the one thing a script must not
              paper over - a half-applied rebase IS the wrong state - so the
              rebase is aborted, the repo is exactly as it was, and the message
              says what to run by hand.
  git push  - repair the links BEFORE publishing them, since a stale link that
              reaches GitHub 404s for every reader; say so when there is nothing
              to push; and on the one rejection with an obvious answer, origin
              moved, pull and retry ONCE. Never force: force-pushing a branch
              somebody has pulled rewrites their history too, and a second
              rejection is a different problem that retrying does not fix.

build.bat had a 41-line batch REIMPLEMENTATION of the old flow. It is gone: both
options there call `bash ./build.sh git-pull` / `git-push`, so there is one
implementation of this logic and Windows cannot drift from it. The same two are
CLI commands (./build.sh git-pull, git-push), so the menu, the .bat and the
command line are the same code.

Verified against throwaway repositories - an origin plus two clones - rather than
by reading: already-up-to-date does nothing; origin-only-moved fast-forwards and
rewrites no commit; both-sides-moved rebases, and a CHANGELOG link to a rewritten
commit is repointed to the replayed commit with the same subject and committed,
leaving the tree clean; a conflicting rebase aborts with HEAD, the working tree
and the index exactly as before and no rebase in progress; a rejected push pulls
and lands on the second try; and pushing with nothing to push says so.

Thanks to xet7 !
2026-08-08 14:22:12 +03:00
github-actions[bot] 0409b348af Bump versions for v10.73 2026-08-08 06:39:43 +00:00
Lauri Ojansivu 466bf21a57 Prepare v10.73 release 2026-08-08 09:38:41 +03:00
Lauri Ojansivu c2ded4a599 CHANGELOG: the CodeQL fix belongs in Upcoming, not inside the released v10.72.
v10.72 was tagged and published at 08:32; the PatternBleed fix was committed at
09:05. Its entry went into the section directly above the previous release, and
by then that section had been renamed from "Upcoming" to "v10.72" by the release
commit - so the changelog claimed a released version contains a fix that is not
in its tag. Checked rather than assumed: the v10.72 tag points at "Bump versions
for v10.72", and the fix is not an ancestor of it.

There is a "# Upcoming WeKan ® release" section above v10.72 again, holding the
security subsection, and v10.72's first subsection reads "This release updates
the following dependencies:" once more instead of the "and updates" it was left
with. The Upcoming section carries the shape CLAUDE.md prescribes: the
**In short:** paragraph, then the binaries table - v10.72's rows, since nothing
here rebuilds them, and the paragraph says so - then the subsection, then the
closing thanks line.

The entry's commit link is repointed. `git pull` rebased the three commits after
the release onto it, which gave them new hashes, so the link written before the
rebase named a commit that no longer exists.

And a real bug in the guard that was supposed to notice all this:

    /^(This release|and ) .*:$/

The space is INSIDE the alternative AND after it, so matching an "and" header
needs "and  updates" with two spaces. Every `and ...:` subsection header
therefore failed to match, and the grouping check never reset its current group
at a subsection boundary: entries under a later subsection were attributed to the
last group line above them, which is exactly the mistake that loop exists to
catch. With it fixed, the section reports 22 grouped entries and one loose - the
one-entry security subsection, which CLAUDE.md keeps flat.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:31:19 +03:00
Lauri Ojansivu 9dbdcbefc0 CHANGELOG: name the CodeQL finding PatternBleed and link its Hall of Fame page.
CLAUDE.md's security process is the Hall of Fame as well as the CHANGELOG: the
name, the row in hall-of-fame/index.html and the page under it. I had written the
entry without them.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:27:28 +03:00
Lauri Ojansivu 74223ce69f CHANGELOG: the CodeQL identity-replacement fix, under Upcoming.
Its own SECURITY ISSUES subsection, first in the release as CLAUDE.md prescribes,
so the dependency subsection that used to open it becomes an "and updates".

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:27:28 +03:00
Lauri Ojansivu f3a12533d7 CodeQL js/identity-replacement: a replacement that replaced a hyphen with itself.
Code scanning alert #431, tests/releaseNodeSources.test.cjs:

    new RegExp(`^\s*${p.replace('-', '-')}\)\s+nodename=`, 'm')

It reads as "escape the hyphen before interpolating this into a regex" and does
nothing whatsoever, so the value went in raw. Nothing failed, because a hyphen
outside a character class needs no escaping - but the guard it looked like was
not there, and a platform name carrying a `.` or a `+` would have matched the
wrong row or thrown. CWE-116, and CodeQL is right to call it out: the usual cause
of the shape is a mistyped backslash escape, where '\"' is silently just '"'.

The platform name is escaped for real now, with the same escapeRegExp the other
guards in tests/ use.

tests/noIdentityReplacement.test.cjs catches the class rather than the instance,
because code scanning reports these days later in a web UI and the node suites
report in fifteen seconds. Three things it took to make it honest:

  * it compares the two sides as VALUES, not as source text. '\"' IS '"' - that
    is the entire point of CodeQL's example - so a text comparison would call
    them different and miss the mistake the guard exists for.
  * the two quote styles are separate alternatives, not a backreference with a
    character class excluding both quotes: `replace('"', '\"')` contains a double
    quote inside a single-quoted literal, so the first shape of the pattern could
    not match the very example above. Caught by asserting the pattern against
    that line rather than trusting it.
  * comments are stripped and the guard skips its own file: this one and the file
    it was written for both QUOTE the bad line to explain it, and a guard that
    reports the sentence describing a bug as the bug gets switched off.

Verified in both directions: the repository is clean now, the same scan against
the previous commit reports the offending line, all three bad shapes are detected
and four working replacements - including a real escapeRegExp - are not.

Thanks to xet7 !
2026-08-08 09:27:28 +03:00
github-actions[bot] 0369b5042c Bump versions for v10.72 2026-08-08 05:24:23 +00:00
Lauri Ojansivu 70a3ac84c3 Prepare v10.72 release 2026-08-08 08:23:19 +03:00
Lauri Ojansivu 6234956437 CHANGELOG: the suite catching up, under Upcoming.
The two commits that fixed what the companion-repo move and the cross-board card
fix left failing, and the Playwright retry loop that could not reach its own
error. The run after them was green: 328 node suites, 518 mocha tests,
249/243/243 in the three browsers, 98 conformance cases with none differing, and
FerretDB's own suites.

Thanks to xet7 !
2026-08-08 08:14:22 +03:00
Lauri Ojansivu 96daaac3db The last two from the run: an ignore comment, and a retry loop that outlived its test.
Down from five failures to none. Both of these are the same kind of thing - a
check that described the world before a change, and a loop that could not reach
its own error.

swcLegacyHelpers still required the .gitignore comment block that named each
clone: `- node/ : clone of the Node.js ...`. That comment went with the entries
it described. What has to be explained now is the one directory that replaced
them, so .gitignore says what .tools/ holds - companion repos, cloned inside the
checkout so they work in a Flatpak sandbox that shares only this directory, with
the entries it replaced named so the change is legible - and the guard requires
THAT. An ignore of a whole directory with no explanation is the kind nobody dares
remove later. The rule about the shape of a per-repo entry stays: if one is ever
re-added it must be rooted and a directory, since a bare `node` matches anything
a package happens to call that.

The webkit failure was one test of 243, in the shared boardPage fixture rather
than in an assertion:

    Test timeout of 60000ms exceeded while setting up "boardPage".
    Error: page.waitForTimeout: Target page, context or browser has been closed

openBoard retries five times, waiting up to 20s for a list each time with a
second between - about 105 seconds, against a 60 second test timeout. So the loop
could never reach its own error: Playwright killed the test first, and the report
said the page had closed instead of saying the board never rendered. Retrying
past the point where the result can still be used is not resilience, it is a
worse error message.

The loop is bounded by a deadline now, 45s, leaving room for the rest of the
fixture inside the 60s timeout. The first attempt keeps the full 20s look, later
attempts get whatever is left, and it stops rather than starting a wait it cannot
finish - so a slow board still gets one long look, and a board that will not
render fails with "did not render any lists within 45s". Simulated across three
cases: never renders (gives up at 45s, 3 attempts), renders at 4s and at 19s
(both on the first attempt) - all inside the test timeout, where the old shape
overran it by 45 seconds.

Thanks to xet7 !
2026-08-08 06:52:35 +03:00
Lauri Ojansivu 5548029088 The five failures the .tools move and the card-move fix left behind.
An EVERYTHING run found four node suites and one mocha test failing. All five
are consequences of the two changes before them, and each one is a guard doing
its job.

Three broke on the companion-repo move:

  * meteorignoreScanScope listed the foreign checkouts by name - FerretDB, node,
    mongo-tools, TSC, gitea, the two WeKan variants - one .meteorignore entry
    each. They are in .tools/ now, one entry that covers all of them, so a list
    of names is a list of history: it fails for the ones that moved and says
    nothing about the next repo somebody clones. It asks the property instead:
    .tools is excluded, and nothing at the top of this checkout that is its own
    git repository is left for Meteor to walk. The variant check became the
    stronger version of itself - no directory with client/, server/ and models/
    is reachable from the top - since a second WeKan is loaded EAGERLY, not
    merely scanned.
  * swcLegacyHelpers checked .gitignore for the same list; one entry now.
  * cpuMonitorWiring reads FerretDB's Go source and still opened FerretDB/. It
    reads .tools/FerretDB, and skips with a note when the clone is not there -
    it is a separate repository, and not every checkout has cloned it.

dockerComposeBackends caught a real omission: the LDAP_GROUP_BASEDN block from
#5539 went into docker-compose.yml only, and every compose file's wekan service
must be identical - what a user reads when they edit their settings must not
depend on which backend they picked. The block is in all of them now.

The mocha failure is the one worth reading twice.
cards.dependencies.move.tests.js pinned move() issuing the inbound-dependency
cleanup itself: a multi-document update with a compound selector. That is
exactly what #6572 removed, because this helper runs in the client bundle where
Meteor allows updates only by id - it threw "Untrusted code may only updateAsync
documents by ID" on every cross-board move, before the move ran. So the test was
pinning the bug. It now asserts the contract that replaced it: move() clears the
moved card's own dependencies, and makes NO update that is not by id - not a
selector, not multi. `{ _id: x }` still counts as by id, since Meteor's rule is
"by id", not "not an object". The inbound half is the server hook, pinned by
tests/cardMoveUntrustedUpdate.test.cjs.

Verified against the tree: all four node suites' conditions hold, the four
compose files the guard compares are identical again, and the FerretDB source
the CPU guard reads is present at the new path with every pattern it asserts.

Thanks to xet7 !
2026-08-08 03:46:39 +03:00
Lauri Ojansivu e7e7a700c6 CHANGELOG: the webhook username, under Upcoming.
Thanks to xet7 !
2026-08-08 02:50:33 +03:00
Lauri Ojansivu 5b83e8e256 Webhooks send the username too, and TODO Later loses three settled entries.
An audit of the TODO Later backlog against the open issues and the current
source. Three no longer belong there.

#3113 - an outgoing webhook said who did something by DISPLAY name and had no
field for the login name. params.user comes from getActivityUserName(), which
prefers getName() because the same params feed the e-mail notification text,
where "Lauri Ojansivu commented on ..." is what a person wants to read; a webhook
consumer needs the identifier, received "Lauri Ojansivu" where it needed "xet7",
and matching users by display name is wrong the moment two people share one.
Changing what `user` means would break every consumer already reading it, which
is what left this recorded as needing a maintainer decision. It needs none: the
username travels as its OWN field. `user` is untouched, `username` is beside it
in the default WEBHOOKS_ATTRIBUTES list, and a deployment that pinned its own
list still gets exactly the fields it asked for.

#6542 - "Import finished. Users imported: 60, but only 25 are listed". The Admin
Panel People pane paginates and passes its total to the shared table page
(totalLabelKey: 'people-number'), so the 60 the importer reported is on screen
next to the 25 rows of the current page. That is what the report was about, and
it is already true in this source.

#6500 - Docker `latest` not loading in a browser: already closed upstream, so it
was only sitting in the backlog.

The rest of the backlog stays, each with its reason: they need an SMTP server, an
LDAP directory, a container, a browser to drag in, or a maintainer's decision on
an intended contract - none of which a source reading can settle.

tests/webhookUsername.test.cjs pins both halves of the webhook change: that the
username is there and set from the same user document as the display name, and
that `user` still means what it always did, including the notification text that
depends on it. Three of its seven assertions fail on the previous commit.

Thanks to xet7 !

Fixes #3113,
Fixes #6542.
2026-08-08 02:50:15 +03:00
Lauri Ojansivu ebd8a2d1d9 CHANGELOG: companion repos in .tools, under Upcoming.
Thanks to xet7 !
2026-08-08 02:41:43 +03:00
Lauri Ojansivu 40fff29d08 Companion repos live in .tools/, and the build scripts clone them there.
wekan/FerretDB is a separate git repository that WeKan's test runs need: the
conformance run builds it from source, and "Run all FerretDB tests" runs its own
suites. It was cloned as a subdirectory of the repo root, which is why
.gitignore and .meteorignore carried an entry per companion repo - nine of them,
each one a chance for a clone to reach a commit or a Meteor rebuild. They live
in .tools/ now, one directory both files already exclude.

build.sh gains ensure_tool_repo: it returns the path to .tools/<name>, cloning
wekan/<name> if it is not there - SSH first, since a maintainer pushes, HTTPS
after, so everyone else still gets a working clone. Its messages go to stderr,
because the path is what it prints. The directory comes from the script's own
location rather than the caller's cwd, so a clone cannot land in whatever
directory somebody happened to be in.

EVERYTHING's FerretDB stage calls it rather than assuming stage 3 already
cloned - a whole-run must not depend on the order of its own stages -
db-conformance.sh builds from .tools/FerretDB and makes .tools before cloning,
and build.bat does the same on Windows: it clones into .tools\FerretDB with the
same SSH-then-HTTPS fallback instead of printing instructions and stopping, and
says so plainly when git itself is missing.

CLAUDE.md's directory list describes the new layout, including that a companion
repo is cloned on demand, so a fresh checkout needs no manual setup.

tests/toolsDirectoryRepos.test.cjs pins both halves: that nothing still reaches
for the old path and both scripts clone, and that a repo INSIDE .tools still
finds its way back out - FerretDB's own build.sh used to reach WeKan's log
directory by counting ../, which lands one level short from .tools; it searches
for the checkout now (fixed in the FerretDB repo, same commit message).

Thanks to xet7 !
2026-08-08 02:41:24 +03:00
Lauri Ojansivu 909a5d9d5b From .gitignore and .meteorignore remove other repos that will be kept at .tools directory that is already ignored.
Thanks to xet7 !
2026-08-08 02:30:40 +03:00
Lauri Ojansivu e5d3935bc2 CHANGELOG: the LDAP group base and the list dates API, under Upcoming.
Thanks to leandro-cyberproject, mimZD and xet7 !
2026-08-08 02:20:03 +03:00
Lauri Ojansivu 4319fc72db CHANGELOG: TODO Later gains the card-drag flicker and the invite email.
#6430's remaining flicker and #5707's invitation email both need something this
environment does not have - a browser to drag in, and a real SMTP server - so
they are recorded with the reason and what was already established, rather than
guessed at.

Thanks to xet7 !
2026-08-08 02:19:08 +03:00
Lauri Ojansivu 50083104c7 API: a list says when it changed, and when its cards last did.
#5251 asked for the last change date of a list, to build an offline Android
client that syncs only what moved. Neither half existed: GET
/api/boards/:boardId/lists returned {_id, title} and no dates at all, and while
the single-list endpoint returns the whole document - modifiedAt included - that
date answers the wrong question. It moves when the LIST document changes (title,
sort, archived); a card being added, edited or archived does not touch it, and
those are the changes the client is asking about.

The list endpoint now reports both:

  modifiedAt      - the list document itself
  cardsModifiedAt - the newest change among the cards in it, null when none

One query for the board's cards and a reduction in memory, not a query per list -
a query per list is what made this expensive enough to ask about. Archived cards
are included on purpose: archiving is one of the changes named in the issue.

The reduction is models/lib/listActivityDates.js, a pure helper, so it is
testable without Meteor and so the handler stays readable. It reads
dateLastActivity as well as modifiedAt and takes whichever is newer, because
boards written by older WeKan carry only the former; a card with no usable date
is skipped rather than counted as now or as zero; and a list whose cards carry no
date is ABSENT from the result, so the endpoint reports null rather than an
invented time - a client polling on a wrong date either never syncs or syncs
forever.

tests/listActivityDates.test.cjs exercises the helper directly: newest wins, per
list, legacy field, string and numeric dates, an undated card cannot drag the
answer backwards, and a negative case feeding it nulls, strings, numbers, a card
with no listId and an Invalid Date - none of which may throw, since the handler
passes whatever the database holds. It also pins the endpoint's shape: both
fields, one cards query, only the three fields it reduces over, and no
`archived: false` on that query.

Thanks to mimZD and xet7 !

Fixes #5251,
2026-08-08 02:19:08 +03:00
Lauri Ojansivu 8b2e0038df LDAP: groups may live in their own subtree, not only under the user base.
#5539: WeKan could not authenticate LDAP users whose groups sit in a different
ou than the users themselves.

Both group searches - getUserGroups, which feeds the login restriction, admin
status sync, group->role sync and org/team sync, and isUserInGroup - searched
this.options.BaseDN. That is the USER base. A directory that keeps ou=groups
beside ou=people, which is a common layout and the reporter's, has no groups
under it, so every group search came back empty; with LDAP_GROUP_FILTER_ENABLE
on, isUserInGroup concluded "not a member" and refused the login. Nothing in the
package could say where the groups were - there was no setting for it.

LDAP_GROUP_BASEDN says where. Unset, or set to a blank string, it falls back to
BaseDN, so a directory that keeps users and groups in one subtree behaves exactly
as it did; a present-but-empty env var is one somebody meant to fill in, not a
search base, and searching "" would silently search the directory root.

The three USER searches - searchUsers, getUserById, getUserByUsername - keep
BaseDN. Pointing those at a group subtree would break login for everyone, so the
test pins which searches moved and which did not, in both directions.

Documented beside the settings it belongs with, in docs/Login/LDAP.md and
docker-compose.yml: a setting nobody can find is one that does not exist.

tests/ldapGroupBaseDn.test.cjs pins all of it, including the fallback and the
documentation. Six of its nine assertions fail on the previous commit; the three
that pass there are the user searches, which is the point of asserting them.

Not verified against a directory - this sandbox has none. The defect is read from
the source: two group searches, one user base, no way to say otherwise.

Thanks to leandro-cyberproject and xet7 !

Fixes #5539,
2026-08-08 02:16:04 +03:00
Lauri Ojansivu 244375ce13 CHANGELOG: the five misplaced controls, under Upcoming.
Thanks to Alishara and xet7 !
2026-08-08 02:05:34 +03:00
Lauri Ojansivu c687daadf4 Five controls that had drifted away from what they belong to.
The screenshots in #6465 (comment 5187985815, images only, each with the fault
circled):

"Checkbox is misplaced" - Admin Panel people table. The select-all box and the
"all users" it labels were two loose inline nodes in a centred table heading: the
box is replaced content with its own baseline, and at the width that column
happens to have, it landed on the word. They are one <label for> now, an
inline-flex row with a gap that cannot collapse - which also makes the word click
the box.

"This checkbox is crazy" - Member settings. The rescue-card-description row
carried a `b &nbsp;` spacer and `.left`, which is float: inline-start. A float
inside a flex container is ignored and the spacer became a stray flex item, so
the box drifted up beside the "Card settings" heading instead of sitting with its
own text. It is now the same shape every other checkbox row in that file uses:
.flex anchor, .materialCheckBox, span.

"(2/5) <-- move this up here" and "Make this same height as the lanes left and
right" - one cause, not two. The WIP counter is already inside the h2 right after
the title; the title is a `+viewer`, and .viewer is display: block with a 22px
min-height, so it pushed everything after it onto a second line AND reserved a
band under it, which is what made that list's header taller than its neighbours'.
Inline in the heading, the counter stays where the markup already puts it. Same
treatment the quick-access header and the table view already give their viewers.

"Please move the button down. This is too close ... panic ;-)" - the backup
schedule's Save sat directly under the 1..28 day buttons, close enough to hit
while aiming for a date. The gap goes on the form-group that FOLLOWS the day
grid, so it applies where that grid is and nowhere else.

"The alignment is out of place" - the attachment move button. This one is
normalisation rather than a measured fix: the row is align-items: flex-end, so a
margin of the button's own offsets it from the controls it acts on; that margin
is zeroed and the button aligns with the controls explicitly. Whether that is the
whole of what was circled needs a browser, and the test says so.

tests/uiSpacingReports.test.cjs pins each cause, including the premise behind the
WIP fix (.viewer really is a 22px block). Six of its nine assertions fail on the
previous commit; the three that pass there are the premises, which is the point
of stating them separately.

Not verified in a browser: this sandbox has none. Each fix is read off the markup
and CSS against what the screenshots show.

Thanks to Alishara and xet7 !
2026-08-08 02:05:14 +03:00
Lauri Ojansivu 0b7aed89bd CHANGELOG: the OIDC null userinfo, under Upcoming.
Thanks to Esther125 and xet7 !
2026-08-08 02:00:00 +03:00
Lauri Ojansivu a50b483ff9 OIDC: a provider that refuses the request says so, instead of "'ocs' of null".
#5174: an OIDC login against a provider that rejected the scopes failed with
"Error in OAuth Server: Cannot read property 'ocs' of null", which says nothing
about the refusal that caused it.

getTokenContent() returns NULL for a token it cannot parse, and the ADFS/B2C
branch assigns that straight into userinfo. The next line was the Nextcloud hack,
`if (userinfo.ocs)`, so the first thing to touch the failed response was a
property read on null. Every claim read after it had the same problem - that line
was only first by accident of ordering, which is why the fix is not one null
check but an order: the provider's answer is validated once, as a whole, before
anything reads a field off it.

Checked, in this order, each naming what failed:

  * the token response is an object at all;
  * it carries an access_token or an id_token - a 200 with neither is the
    provider answering with a complaint, so the error lists the fields that DID
    come back (keys only: the values are secrets, and JSON.stringify of a token
    response in a log is how refresh tokens leak);
  * userinfo is a non-null object, saying WHICH path produced nothing (claims
    expected in the access token, or the userinfo endpoint) and pointing at
    OAUTH2_REQUEST_PERMISSIONS, which is what was actually wrong in #5174;
  * the ocs and metadata hacks unwrap to something - `ocs` present with no
    `ocs.data` used to set userinfo to undefined and fail on the next line;
  * B2C's userinfo["emails"][0] is an array before it is indexed: a tenant that
    does not release the claim made that undefined[0];
  * expires_in parses to a finite number - parseInt(undefined) is NaN, and NaN
    propagated into the account's expiry silently. Falls back to the OAuth2
    default hour.

tests/oidcNullUserinfo.test.cjs pins the order rather than the individual checks:
it asserts each guard sits BEFORE the read it protects, since an edit that reads
a claim earlier would restore the original bug without touching a check. It also
pins that the no-token error names keys and never stringifies the response. All
eight assertions fail on the previous commit.

Not reproduced against a real provider - this sandbox has none. The null path is
read from the source: getTokenContent returns null for a falsy token, and the
ADFS/B2C branch passes its result to the line that threw.

Thanks to Esther125 and xet7 !

Fixes #5174,
2026-08-08 01:59:42 +03:00
Lauri Ojansivu de8814a7cd CHANGELOG: the board type scale, under Upcoming.
Thanks to xet7 !
2026-08-08 01:52:15 +03:00
Lauri Ojansivu a95451c67b Board type scale: a list heading outranks its cards again.
Reported by email with a screenshot: the "Add card" link and the "Add" button
are too big, and things should be sized in relation to each other the way an h1
is bigger than an h2.

The measurements agreed. A list heading was 14px, while a minicard title and the
"Add card" link both set no size at all and inherited the document's 16px, and
the composer's Add button was a 50px-tall slab. So the column was labelled in
fine print, the affordance for making a card was as loud as the cards
themselves, and the biggest thing on screen while typing was a button.

The scale is now heading 16 > card title 14 >= add-card link 13, each stating
its own size instead of inheriting, and the Add button is a 34px control rather
than a slab. Mobile keeps its own larger sizes - the .mobile-view rules with
!important, and the @media blocks - because a phone is read at arm's length.

tests/listTypeScale.test.cjs pins the ORDER rather than the numbers, since that
is what "in relation to each other" means, plus that each of the three states a
size of its own: an inherited size is exactly how the link and the card title
both landed on 16px, level with each other and above their heading. It reads the
base scale only, skipping @media blocks, and takes the LAST rule for a selector -
.list-header .list-header-name has two rules, and reading the first one finds the
one that sets no size at all. Verified against the previous commit: heading 14,
card title and add-card link unset, button 50px - it fails on all four counts.

Not verified in a browser: this sandbox has none. The sizes are read from the
source, and the relative order is what the guard holds.

Thanks to xet7 !
2026-08-08 01:52:00 +03:00
Lauri Ojansivu f049503ee5 CHANGELOG: the cross-board card move, under Upcoming.
Thanks to jullbo and xet7 !
2026-08-08 01:24:11 +03:00
Lauri Ojansivu 24927c3a7a Moving a card to another board works again, and the REST route cannot corrupt one.
#6572: every cross-board move failed immediately with "Not permitted. Untrusted
code may only updateAsync documents by ID. [403]", whether or not the card had
any dependencies, and before the move itself ran - so the card stayed put.

models/cards.js is isomorphic, so its helpers run in the client bundle, and
client code calls card.move() directly (list.js, swimlanes.js, minicard.js,
globalSearch.js, sidebarFilters.js). Meteor lets untrusted code update only BY ID
- a bare id or an { _id: ... } selector. The cross-board branch of move() cleaned
up inbound "Red Strings" (#3392) with a compound selector and multi: true, which
is rejected on the client every time, including when it would have matched
nothing. addDependency, setDependencyProps and removeDependency in the same file
each carry a comment saying updates must be by _id only; this was the one place
that did not follow it.

The card's own dependencies are still cleared by the move (that rides along in
the by-id update). The inbound half - the links pointing AT the card from the
board it left - is now a Cards.after.update hook in server/models/cards.js, where
a selector is allowed, and being server-side it also covers the REST API and
import paths that never called the helper at all. It pulls both stored shapes:
{ cardId, ... } objects, and the bare id strings older data still holds, which
normalizeDependencies hides on read.

The issue also reports that the REST workaround corrupts the card, and it does.
A PUT of boardId/listId/swimlaneId naming the DESTINATION board is not a board
move - isBoardMove needs newBoardId, newSwimlaneId and newListId - so that branch
never ran, while the same-board swimlane and list branches did: the card kept its
old boardId and got the other board's listId and swimlaneId written onto it,
belonging to a list and a swimlane on a board it was not on, showing on neither,
undone only by a hand-written database update. Those two branches now verify the
target belongs to the board in the URL and otherwise refuse with 400 naming the
parameters to use, and they are skipped during a board move - they would rewrite
listId before the isBoardMove update, whose selector pins the card's original
listId, so that update would match nothing and silently do nothing: the same
broken card by another route.

tests/cardMoveUntrustedUpdate.test.cjs pins both halves: every Cards update in
the isomorphic model is by _id and none is multi-document (it reads the whole
call, because multi is the third argument and a check that stopped at the first
one could never fail), the inbound cleanup is server-side and keyed on a real
board change, and the REST branches are board-checked and skipped during a move.
Verified against the previous commit: the model guard reports the compound
selector and the multi update, and 6 of the 8 REST assertions fail.

Thanks to jullbo and xet7 !

Fixes #6572,
2026-08-08 01:23:22 +03:00
Lauri Ojansivu 75d1e74715 CHANGELOG: the Windows Playwright ALL logs, under Upcoming.
Thanks to xet7 !
2026-08-08 00:39:07 +03:00
Lauri Ojansivu f138b3124f build.bat: the "Playwright ALL browsers" option writes logs again.
The EVERYTHING run's WeKan stage failed on one node suite:
dbConformanceWiring, "build.bat: playwright-all must be logged". It is a real
gap, and splitting the whole-suite browser job into three uncovered it rather
than caused it.

`call :onelog playwright-all` existed in exactly one place: inside the combined
three-project browser line of the sequential all-tests flow, which is gone now
that each browser is its own job with its own log. Where it did exist it never
worked - the call sat inside the `cmd /c` string of a STARTED child process,
which has no build.bat labels to call, so %ONELOG% was empty and Tee-Object was
handed an empty path.

Meanwhile the option the name was supposed to cover, Tests -> 11 "Playwright ALL
browsers", wrote no log at all: one `playwright test --project=chromium
--project=firefox --project=webkit` call straight to the terminal, nothing left
to read afterwards. That is the exact thing the guard is about, and build.sh's
same option has always written one log per browser.

So the option now runs the three browsers one at a time - unchanged, and still
sequential because three at once against one dev server exhausts RAM on smaller
machines - each through the same :onelog helper as every other Tests option, and
each with its own --output so Playwright does not clear another browser's traces
at startup. The guard drops `playwright-all`, which named an implementation that
is gone and was broken, and gains what it was reaching for: that the ALL-browsers
option logs, and that it covers all three.

Verified: every build.bat label reference resolves, all seven menus still print
exactly what they dispatch, the build.sh menu entries all still match a case arm,
and the whole-suite per-browser jobs are untouched. Mocha ran in this run for the
first time since it was fixed - 518 passing - and the other stages were green:
320 node suites with this one failure, 249/243/243 in the three browsers, the
conformance run and all of FerretDB's own tests.

Thanks to xet7 !
2026-08-08 00:38:50 +03:00
Lauri Ojansivu fdc85961a2 CHANGELOG: the menu option that quit the script, under Upcoming.
Thanks to xet7 !
2026-08-07 23:37:10 +03:00
Lauri Ojansivu b55cbda20d build.sh: Tests -> EVERYTHING runs again instead of quitting the script.
Renaming the menu entry broke it. choose() shows the SHORT labels and sets $opt
to the FULL DESCRIPTION of the one picked, and the dispatcher hundreds of lines
below matches that description as a case arm - so the description is an
identifier written twice, and the rename in the previous commit changed only one
of them. With no arm matching and no catch-all, the case fell through, the
`for _once` loop ended and the script EXITED: choosing Tests -> 1 printed nothing
and ran nothing.

The arm now carries the same string as the menu entry, so EVERYTHING runs its
four stages again. Two guards so it cannot come back:

  * tests/buildScriptParity.test.cjs checks every one of the 25 choose() entries
    against the case arms, and fails on a description that matches none. It fails
    on the previous commit's build.sh.
  * the dispatcher has a *) arm that says which option has no handler and that
    nothing was run, so if one ever does get past the test, the person in front
    of the menu is told rather than dropped back to the shell.

build.bat was checked for the same thing and cannot fail this way: its menus
dispatch on the NUMBER typed, not on a sentence, and its EVERYTHING hands the run
to releases/run-everything.sh rather than reimplementing it. Verified anyway,
both directions: every printed menu number has an if-dispatch in all seven menus,
and every goto/call target resolves to a label. That is now pinned too.

Thanks to xet7 !
2026-08-07 23:36:26 +03:00
Lauri Ojansivu 94940ada13 CHANGELOG: the card-drag list resize and the build-script run, under Upcoming.
Also repoints six entries at the commits that exist on this branch. The board
export and mocha entries were written against a commit that was amended after
they were filled in, and the four snap entries against one that was amended the
same way, so each of them linked a commit the amend had orphaned. A CHANGELOG
entry cannot carry the hash of the commit it is in; it goes in its own commit
afterwards, linking the code commit.

Thanks to xet7 !
2026-08-07 19:21:46 +03:00
Lauri Ojansivu c6b0af16eb Card drag no longer resizes every list, and EVERYTHING runs every check.
#6573: picking up a card made every list on the board expand horizontally to fill
the window, and dropping it snapped them back, which makes landing a card in the
intended list a guess. Two correct changes met. A list's custom width is an
inline --list-width custom property, and what turns it into a width is a rule in
list.css whose selector needs an ancestor: .js-swimlane, .dragscroll, or a
swimlane-<id> id. In lists view the container is
.swimlane.list-group.js-lists.dragscroll - no js-swimlane class, no such id - so
.dragscroll was the only one of the three that matched, and every list's width
there hung on that one class. #6558 then taught a card drag to stop the board
panning under the same pointer, and it does that by REMOVING the dragscroll class
for the duration of the drag. So the drag deleted the class the width rule
matched on: flex: none and the width declarations switched off and the lists
re-flowed, for exactly as long as the drag lasted. It also explains the
workaround in the report - dragging one list's edge first sets that list's width
through the resize path, so that column stopped jumping while the others did not.

A layout rule may not hang on a class that an interaction removes. .js-lists is
on the container in both views and nothing takes it off, so the width rules,
their mobile-mode counterpart and the resize rules name it now.
tests/listWidthDuringDrag.test.cjs pins that every width rule still matches in
lists view with dragscroll gone - it fails on the previous CSS - and pins the
premise that suspending the pan really does remove that class.

build.sh -> Tests -> EVERYTHING is what a release is tested with, so a check that
is in the Tests menu and in no whole-run is a check that runs when somebody
remembers it. The floating-promises guard was that: menu entry 12, in no run. It
is the first of four stages now - seconds, so an unawaited permission check is
reported before an hour of browsers - and its return code is in the verdict like
the others. What runs there is checks only: the menu entry may install ripgrep
and the @typescript-eslint packages and write the rule into .eslintrc.json, which
is a person setting the guard up, while EVERYTHING runs unattended and must
modify nothing - a run that rewrites .eslintrc.json is no longer testing the
commit it started from. The two checks are one shared function using grep rather
than ripgrep, so they also work where nothing may be installed.

build.bat ran the three browsers as one Playwright invocation into one
wekan-alltests-browsers.log, where neither "which browser failed" nor "what did
WebKit print" was answerable afterwards, and CLAUDE.md's "check the newest test
logs" names the per-browser files. It starts chromium, firefox and webkit as
three jobs with a log, a status and a summary row each now, with a per-browser
--output because Playwright clears its output directory at startup. EVERYTHING is
not reimplemented there: Windows hands the run to releases/run-everything.sh,
which calls build.sh --run-everything, so the new stage arrives on both platforms
at once. The parity guard pins all of it, including that the shared checks
install nothing.

Verified without node or meteor, which this sandbox has neither of: the extracted
checks were run for real against the tree (clean) and against fixtures with an
unawaited auth call, a missing rule and no .eslintrc.json at all (all three fail
as they should); the new and changed guards were ported assertion-for-assertion
to Python and run against the same files, including that the #6573 guard fails on
the pre-fix CSS; build.sh passes bash -n and every build.bat call now resolves to
a label.

Thanks to yulqen and xet7 !

Fixes #6573,
2026-08-07 19:21:24 +03:00
Lauri Ojansivu ee16a41a99 Board export: pin every section, and fix the mocha stage that stopped running.
The newest test run had two failures.

tests/changelogFormat.test.cjs failed on "the binaries table follows the
summary": the Upcoming section written after v10.71 had lost the binaries table
that every release section carries. It is restored from what v10.71 shipped, and
two group lines that had wrapped onto a second line are one line again, which is
what the grouping guard requires.

The mocha stage did not run at all. client/lib/tests/boardTriggersClass.tests.js
reads boardTriggers.jade and boardTriggers.js off disk with fs and path, which
worked only while node-polyfill-webpack-plugin's browser shims were in the client
bundle; the @meteorjs/rspack 2.1.0 update dropped that plugin and the client
build stopped with "Cannot find module 'path' for matched aliased key 'path'".
That is not one suite failing - the bundle does not build, so every mocha suite
is skipped and the stage reports a crash where its results should be. The suite
was on the wrong side rather than wrong: it now lives in server/lib/tests, and
tests/clientTestsNoNodeBuiltins.test.cjs is the mirror of the existing guard that
keeps browser-only packages out of the server bundle.

Issue #6274, "export includes only comments from current year", and its fix in
PR #6275 were then checked against the current source. The fix is intact and
survived the streaming-export rewrite that came after it: both exporters select
comments by cardId and activities by boardId OR cardId. The follow-up its
reporter left on that PR - "the export is now missing the lists part" - is not
reproducible: result.lists has been written by the JSON exporter continuously
since the CSV/TSV export was added, both JSON writers emit the same fourteen
sections, and the flat formats carry each list's name on the card's own row. So
no export code changed; what was missing was the guard, and
tests/exportBoardSections.test.cjs pins the section list, that the two JSON
writers agree on it, that no exporter selects comments by board again, and the
two smaller fixes from that PR.

Not run here: this sandbox has no node, meteor or any other JS runtime, so the
new and changed guards were verified by porting them assertion-for-assertion to
Python and running that against the same files - changelogFormat 16/16, the
export and client-bundle guards clean. The mocha suites behind the fixed build
have not executed yet; the next run is what shows them.

Thanks to Tuphal, KhaoulaMaleh and xet7 !
2026-08-07 18:20:23 +03:00
Lauri Ojansivu 3c7926662c Snap: fix the three failures that left v10.71 without armhf, s390x, ppc64el, riscv64 and gantt-gpl snaps. 2026-08-07 15:04:39 +03:00
Lauri Ojansivu 9773312176 Updated ChangeLog. 2026-08-07 13:58:47 +03:00
Lauri Ojansivu 6cada892d9 Upgrade to jquery 4.
Thanks to jquery developers !
2026-08-07 13:44:12 +03:00
Lauri Ojansivu f053619d80 Merge remote-tracking branch 'remotes/origin/dependabot/npm_and_yarn/jquery-4.0.0' 2026-08-07 13:34:14 +03:00
Lauri Ojansivu 4063cd20ee Merge remote-tracking branch 'remotes/origin/dependabot/npm_and_yarn/babel/parser-8.0.4' 2026-08-07 13:30:46 +03:00
Lauri Ojansivu a74aee2e45 Updated translations. 2026-08-07 13:27:14 +03:00
Lauri Ojansivu e30eb57e1a Updated dependencies.
Thanks to developers of dependencies !
2026-08-07 13:22:20 +03:00
Lauri Ojansivu 0878de010e Updated rspack and other dependencies.
Thanks to developers of dependencies !
2026-08-07 13:15:19 +03:00
Lauri Ojansivu 65984b912e Upgraded to Meteor 3.5.1.
Thanks to Meteor developers !
2026-08-07 13:10:42 +03:00
Lauri Ojansivu a7f840be48 CLAUDE.md: drop the do-not-push standing rule.
Remove the 'commit only, do not push' guidance from both the maintainer-mode
intro and General practices; the maintainer decides when to push, directly on
the branch (no PR, no branches).
2026-08-06 08:02:35 +03:00
Lauri Ojansivu 2f29b56f36 CHANGELOG: record the snap-launchpad LP_CREDENTIALS false-positive fix under Upcoming. 2026-08-06 08:01:35 +03:00
Lauri Ojansivu 8ebeb85832 snap-launchpad: stop misreporting working LP_CREDENTIALS on every Stopped Launchpad build.
When a Launchpad remote build ends as 'Stopped' with no .snap, the job scans
the build log to tell a real credential failure from a build cancelled mid-run
(an OOM in the Meteor npm install, a build-farm reset). The scan pattern was
'unauthoriz|401|403', and a bare 401/403 matches any three digits in a large
build log - a package size, a hash, an 'attempt 4/6' line - so it fired on
almost every Stopped build and told the maintainer to re-create credentials
that were working: the build had reached 'Building:', which needs valid
credentials. The pattern now matches those codes only in an HTTP-error context
(HTTP Error 401, 403 Forbidden) or an explicit phrase (invalid credentials,
not logged in, bad credentials), so the credential hint appears only for a real
authorization failure.
2026-08-06 08:00:37 +03:00
dependabot[bot] 459ea80d04 Bump jquery from 3.7.1 to 4.0.0
Bumps [jquery](https://github.com/jquery/jquery) from 3.7.1 to 4.0.0.
- [Release notes](https://github.com/jquery/jquery/releases)
- [Changelog](https://github.com/jquery/jquery/blob/main/changelog.md)
- [Commits](https://github.com/jquery/jquery/compare/3.7.1...4.0.0)

---
updated-dependencies:
- dependency-name: jquery
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 02:58:41 +00:00
dependabot[bot] 7727e51296 Bump @babel/parser from 7.29.7 to 8.0.4
Bumps [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) from 7.29.7 to 8.0.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.4/packages/babel-parser)

---
updated-dependencies:
- dependency-name: "@babel/parser"
  dependency-version: 8.0.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-19 01:11:02 +00:00
158 changed files with 14726 additions and 2868 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Project-scoped Codex configuration.
#
# Claude has no non-default tool permissions or MCP servers configured for this
# repository, so there are no settings to translate here. Project instructions
# live in AGENTS.md and the nested AGENTS.md files.
+69 -10
View File
@@ -84,7 +84,7 @@ jobs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: find
env:
@@ -92,7 +92,12 @@ jobs:
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
# --repo on every gh call, whether or not the job happens to have
# checked out: a call that relies on an inferred remote breaks the day
# somebody removes the checkout, and the error it gives - "fatal: not
# a git repository" - names neither gh nor the workflow.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
@@ -107,6 +112,9 @@ jobs:
name: AppImage ${{ matrix.appimage_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
# A 32-bit job that cannot run on the runner it was given must not fail the
# release for the architectures that built fine.
continue-on-error: ${{ matrix.tolerate_failure || false }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -118,6 +126,28 @@ jobs:
- appimage_arch: aarch64
bundle_arch: arm64
runs_on: ubuntu-24.04-arm
# The other two architectures an AppImage runtime EXISTS for. There
# are four in total - x86_64, aarch64, i686, armhf - and that is the
# whole limit: the runtime is the ELF the kernel executes, so an
# architecture with no runtime cannot have an AppImage at all. That is
# why ppc64le, s390x, riscv64 and loong64 are not here and never will
# be; they are served by the bundle zip and the .deb.
#
# i686 builds on the x86_64 runner, which runs 32-bit x86 natively -
# no emulation, no multiarch, and appimagetool-i686 runs as-is.
- appimage_arch: i686
bundle_arch: i386
runs_on: ubuntu-24.04
# armhf builds on the aarch64 runner, which runs 32-bit ARM only if
# the kernel was built with CONFIG_COMPAT and the 32-bit loader is
# installed. GitHub does not promise either, so this one is allowed to
# fail without taking the release with it - the same treatment TSC
# gives its emulated armhf job. If it turns out to work reliably,
# drop the flag; if it never does, drop the entry.
- appimage_arch: armhf
bundle_arch: armhf
runs_on: ubuntu-24.04-arm
tolerate_failure: true
# WHICH entries this run wants, decided here because it cannot be decided
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
# that context to runs-on, env, name, steps and a few others, but not to a
@@ -130,7 +160,7 @@ jobs:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.appimage_arch) }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: ${{ env.BUILD_THIS == 'true' }}
# An AppImage only WRAPS an existing release bundle
@@ -163,7 +193,8 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
# A zip that exists is not a bundle that runs; these are the two files
# the AppImage is built around.
@@ -281,6 +312,22 @@ jobs:
# for WeKan to answer on its port, then stop it - so a broken bundle
# is caught here rather than by whoever downloads it.
export WRITABLE_PATH="$PWD/smoke-data" PORT=8080
# A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK. The 32-bit builds are
# packed on a 64-bit runner, which executes them only if the kernel and
# loader for that word size are present - GitHub promises neither. If
# the binary cannot be executed AT ALL, the AppImage is as good as
# anything here knows and the release must not be withheld for it; say
# so and move on. Anything else - it starts and does not answer - is
# the bug this step exists for and still fails.
if ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --appimage-help >/dev/null 2>&1 \
&& ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --help >/dev/null 2>&1; then
if ! file "$app" | grep -q "$(uname -m)"; then
echo "::warning::this runner cannot execute a ${{ matrix.appimage_arch }} binary, so the AppImage was packed but not started. It is uploaded unchecked."
exit 0
fi
fi
APPIMAGE_EXTRACT_AND_RUN=1 "$app" > smoke.log 2>&1 &
pid=$!
ok=false
@@ -298,7 +345,7 @@ jobs:
fi
echo "WeKan answered on port 8080."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: appimage-${{ matrix.appimage_arch }}
@@ -311,7 +358,7 @@ jobs:
if: ${{ always() && inputs.publish != false }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
pattern: appimage-*
path: assets
@@ -324,7 +371,11 @@ jobs:
set -euo pipefail
ls -lh assets 2>/dev/null || true
if ls assets/*.AppImage >/dev/null 2>&1; then
for want in x86_64 aarch64; do
# Every architecture the matrix builds. A missing one WARNS rather
# than fails: the 32-bit pair is allowed not to build, and an
# architecture whose base bundle was never published has nothing to
# wrap. What must not happen silently is one going missing unnoticed.
for want in x86_64 aarch64 i686 armhf; do
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
echo "::warning::no AppImage for $want - see that build job's log"
done
@@ -334,7 +385,7 @@ jobs:
# build jobs skip (not fail) when the base bundle is not published yet.
published="$(gh release view '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
base_present=false
for arch in amd64 arm64; do
for arch in amd64 arm64 i386 armhf; do
printf '%s\n' "$published" | grep -qx "wekan-${{ needs.release.outputs.version }}-${arch}.zip" && base_present=true
done
if [ "$base_present" = false ]; then
@@ -354,5 +405,13 @@ jobs:
echo "Nothing to upload (no base bundle was published to wrap)."
exit 0
fi
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# --repo, because this job does NOT check the repository out - it only
# downloads artifacts - so `gh` has no git remote to infer the repo
# from and fails with "fatal: not a git repository". Every other gh
# call in this workflow already passes it; these two did not, so the
# AppImages were built, smoke-tested and uploaded as artifacts, and
# then nothing was attached to the release.
gh release upload '${{ needs.release.outputs.tag }}' assets/* \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
+38 -11
View File
@@ -24,6 +24,15 @@ name: Flatpak
# and WeKan publishes Linux bundles for amd64 and arm64, so those are the two.
# Each builds on a runner of its own CPU rather than under emulation.
#
# AND THAT IS THE WHOLE LIST - there is no third to add. A flatpak runs against
# a RUNTIME, not against the host's libraries, so an architecture exists here
# only if freedesktop.org publishes org.freedesktop.Platform for it. It does so
# for x86_64 and aarch64; the i386 and arm runtimes were discontinued. That is
# the difference from AppImage.yml, which was able to grow from two
# architectures to four: an AppImage carries its own runtime binary, and
# runtimes exist for i686 and armhf as well. WeKan's ppc64le, s390x, riscv64,
# loong64 and 32-bit bundles are served by the zip and the .deb.
#
# THE SANDBOX. WeKan is a server you reach in a browser, so it needs the network
# and a place to keep its data, and nothing else - no X11, no audio, no access
# to your files. --share=network and --persist=.wekan are the whole permission
@@ -89,14 +98,18 @@ jobs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: find
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
# --repo on every gh call, whether or not the job happens to have
# checked out: one that relies on an inferred remote breaks the day
# somebody removes the checkout.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
@@ -133,7 +146,7 @@ jobs:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: ${{ env.BUILD_THIS == 'true' }}
- name: Install flatpak
@@ -154,7 +167,8 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
if [ ! -e "$f" ]; then
@@ -273,14 +287,14 @@ jobs:
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
ls -lh dist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-${{ matrix.flatpak_arch }}
path: dist/
if-no-files-found: error
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-repo-${{ matrix.flatpak_arch }}
@@ -293,7 +307,7 @@ jobs:
if: ${{ always() }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
pattern: flatpak-[0-9a-z]*
path: assets
@@ -318,11 +332,24 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# --repo, because this job does NOT check the repository out - it
# only downloads artifacts - so `gh` has no git remote to infer the
# repo from and fails with "fatal: not a git repository". The same
# line was wrong in AppImage.yml for the same reason.
#
# And the bundles BY NAME, not assets/*: the artifacts also carry the
# ostree repository the flatpak was exported through - config,
# objects/, refs/, summaries/, summary, summary.idx - which is build
# scaffolding, not something to attach to a release. TSC hit this
# exact thing ("upload only the bundle files, not the ostree repo").
gh release upload '${{ needs.release.outputs.tag }}' \
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
# ---- the repository -------------------------------------------------
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
if: ${{ inputs.publish_repo != false }}
with:
pattern: flatpak-repo-*
@@ -395,7 +422,7 @@ jobs:
TXT
du -sh flatpak-repo
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ inputs.publish_repo != false }}
with:
name: flatpak-repository
+4
View File
@@ -87,3 +87,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Full build provenance (SLSA), not the minimal default that BuildKit
# attaches on its own. See release-all.yml for what it is and why the
# manifest list gains an `unknown/unknown` entry per platform.
provenance: mode=max
+3 -3
View File
@@ -68,7 +68,7 @@ jobs:
manual: ${{ steps.plan.outputs.manual }}
total: ${{ steps.plan.outputs.total }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: plan
env:
@@ -199,7 +199,7 @@ jobs:
uses: docker/setup-qemu-action@v4
# Before the download, because actions/checkout cleans the workspace.
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: ${{ steps.want.outputs.build == 'yes' }}
# THE DIFFERENCE from release-all.yml, and the whole point of this
@@ -285,7 +285,7 @@ jobs:
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
V: ${{ needs.plan.outputs.version }}
+130 -25
View File
@@ -622,13 +622,23 @@ jobs:
sha256sum "wekan-${VERSION}-amd64.zip" > "wekan-${VERSION}-amd64.zip.sha256sum"
cat "wekan-${VERSION}-amd64.zip.sha256sum"
# Where this bundle's binaries came from, for the release notes.
mkdir -p provenance
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
#
# $GITHUB_WORKSPACE, not a relative path: this step ran `cd .build`
# above, so `mkdir -p provenance` made .build/provenance while the
# upload-artifact below looks at provenance/ from the workspace root.
# It found nothing - "No files were found with the provided path:
# provenance/" - and amd64, the platform most people download, was the
# one bundle missing from the v10.77 release-notes table. Every other
# build job records from the workspace root, which is why only this one
# was affected.
mkdir -p "$GITHUB_WORKSPACE/provenance"
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
'amd64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
"${NODE_URL:-}" \
"${NODE_SHA256:-}" || true
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
'amd64' 'FerretDB' 'wekan/FerretDB' 'latest' \
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
'amd64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64' \
"${FERRET_SHA:-}" || true
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
@@ -659,7 +669,7 @@ jobs:
done
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: provenance-amd64
path: provenance/
@@ -832,8 +842,9 @@ jobs:
'arm64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
"${NODE_URL:-}" \
"${NODE_SHA256:-}" || true
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/arm64.tsv bash releases/record-provenance.sh \
'arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64' \
"${FERRET_SHA:-}" || true
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
@@ -864,7 +875,7 @@ jobs:
done
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: steps.preflight.outputs.skip != 'true'
with:
name: provenance-arm64
@@ -921,7 +932,7 @@ jobs:
# 404'd on wekan-<version>-amd64.zip - v10.63 and v10.64 both. Checking out
# first, then downloading on top, keeps the bundles. (The checkout is here
# for the provenance script under releases/.)
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download all bundles
uses: actions/download-artifact@v8
@@ -931,7 +942,7 @@ jobs:
- name: Collect where the binaries came from
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: provenance-*
path: provenance
@@ -1219,8 +1230,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb.exe ]; then
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/win64.tsv bash src/releases/record-provenance.sh \
'win64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'win64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe' \
"${FERRET_SHA:-}" || true
fi
@@ -1561,8 +1573,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb.exe ]; then
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/win32.tsv bash src/releases/record-provenance.sh \
'win32' 'FerretDB' 'wekan/FerretDB' 'latest' \
'win32' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe' \
"${FERRET_SHA:-}" || true
fi
@@ -1805,8 +1818,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb ]; then
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/mac-arm64.tsv bash releases/record-provenance.sh \
'mac-arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'mac-arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64' \
"${FERRET_SHA:-}" || true
fi
@@ -2057,8 +2071,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb ]; then
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/mac-x64.tsv bash releases/record-provenance.sh \
'mac-x64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'mac-x64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64' \
"${FERRET_SHA:-}" || true
fi
@@ -2290,7 +2305,7 @@ jobs:
# checkout - the job would stop at "No such file or directory". It goes
# first because actions/checkout cleans the workspace, which would remove
# the bundle the next step downloads.
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download amd64 bundle
uses: actions/download-artifact@v8
@@ -2421,7 +2436,7 @@ jobs:
else
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${{ matrix.ferretdb_arch }} on this release, so it is unverified."
fi
FERRET_TAG="$(curl -fsSL https://api.github.com/repos/wekan/FerretDB/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tag_name",""))' || true)"
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
bash releases/record-provenance.sh '${{ matrix.arch }}' 'FerretDB' \
'wekan/FerretDB' "${FERRET_TAG:-latest}" "$FERRET_URL" "$FERRET_SHA"
# Overwrite the inherited amd64 MongoDB Database Tools with this arch's ones.
@@ -2490,7 +2505,7 @@ jobs:
mkdir -p provenance
[ -f provenance.tsv ] && mv provenance.tsv "provenance/${{ matrix.arch }}.tsv" || true
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: steps.preflight.outputs.skip != 'true'
with:
name: provenance-${{ matrix.arch }}
@@ -2966,7 +2981,21 @@ jobs:
# manifest yet, so it ships as a .zip bundle only. Every candidate arch
# is in debian:trixie's manifest AND has a fork Node the Dockerfile can
# install.
# --provenance=mode=max: full build provenance (SLSA), not the
# minimal default - what built this image, from which commit, with
# which base and build arguments. It rides in the manifest list as an
# `unknown/unknown` entry per platform, the OCI marker for "not a
# runnable image" so `docker pull` never selects it, which is why Quay
# lists one beside each real platform. mode=max makes those entries
# BIGGER, not fewer; `--provenance=false` is what removes them, at the
# cost of the evidence.
#
# The comment is here and not among the arguments: a `#` after a line
# continuation comments out the rest of the joined line, leaving
# `docker buildx build` with no platforms and no tags. This block is a
# shell script like any other, and it was written the wrong way first.
docker buildx build \
--provenance=mode=max \
--platform "${PLATFORMS}" \
--build-arg VERSION=${VERSION} \
-t wekanteam/wekan:v${VERSION} \
@@ -3337,7 +3366,14 @@ jobs:
# clear message instead of GitHub's silent 6h cancel. fail-fast: false so one arch
# never cancels the others - each publishes the moment it finishes.
continue-on-error: true
timeout-minutes: 180
# 180 minutes was one attempt on the slow arches, not three. In v10.71 the
# riscv64 leg spent 2h24m on attempt 1 - almost all of it queueing for a
# riscv64 builder - failed it, and was cancelled by this timeout 35 minutes
# into attempt 2, which is the "The operation was canceled." in that job and
# not a Launchpad problem at all. The retry loop is only worth having if the
# job outlives the retries, so allow for two full slow-arch attempts. 350 is
# under GitHub's 360-minute per-job ceiling.
timeout-minutes: 350
strategy:
fail-fast: false
matrix:
@@ -3477,7 +3513,15 @@ jobs:
# when snapcraft's exit code is non-zero, which is how v10.48 fetched it
# and threw it away.
for log in snapcraft-wekan-*.txt; do
echo "::group::Launchpad build log: $log (last 150 lines)"
echo "::group::Launchpad build log: $log (what failed, then the last 150 lines)"
# The tail alone is not where the cause is. snapcraft's own error is
# followed by lpbuildd's Python traceback, the proxy-token revocation
# and the process scan, so on v10.71 the 150 lines held the
# IsADirectoryError but NOT the mongodb part's build output that
# would have said what `bin` actually was. Print the lines around
# every failure marker first, and keep the tail after it.
grep -n -E -B 5 -A 20 'Build failed|failed with code|Detailed information|Stage package not found|Failed to run snapcraft|error:' "$log" | tail -n 250 || true
echo "--- last 150 lines ---"
tail -n 150 "$log"
echo "::endgroup::"
done
@@ -3506,6 +3550,19 @@ jobs:
fi
done
echo "::error::Remote build ${{ matrix.arch }} failed after $attempts attempts (no wekan_${VERSION}_${{ matrix.arch }}.snap produced). The Launchpad build log printed above says why; the builds are also at https://launchpad.net/~xet7/+snaps" >&2
# A DETERMINISTIC failure, named before the guesses below. Three
# identical attempts on ONE architecture while the others build on
# attempt 1 is not the build farm having a bad day - and the guidance
# below ("often an OOM, or a transient reset; re-run") sent v10.77's
# armhf failure down exactly that path. snapcraft ends a missing
# stage-package as a "Stopped" build with no artifact, which looks
# identical to a cancelled one from the outside; the difference is one
# line in the log, so say it here rather than leave it to be found.
if grep -qs 'Stage package not found' snapcraft-wekan-*.txt; then
missingpkg="$(grep -hs -o "Stage package not found in part '[^']*': [^ ]*" snapcraft-wekan-*.txt | sort -u | tr '\n' ' ')"
echo "::error::This is NOT a transient failure and re-running will not fix it: ${missingpkg}. A stage-package that resolves on the 64-bit architectures can be missing on ${{ matrix.arch }} - Ubuntu 24.04's 64-bit time_t transition renamed libraries to a t64 suffix, and the renamed package carries 'Provides: <old name>' on amd64/arm64 but NOT on armhf, where the ABI really changed. Name the real package in snapcraft.yaml (libssl3 -> libssl3t64, libcurl4 -> libcurl4t64, libgoogle-perftools4 -> libgoogle-perftools4t64). Check a name against the archive: curl -fsSL http://ports.ubuntu.com/ubuntu-ports/dists/noble/main/binary-armhf/Packages.gz | gunzip -c | grep '^Package: <name>$'" >&2
exit 1
fi
# A "Stopped" build with no artifact is Launchpad ENDING the build
# itself, not a compile error in it. Two causes, in the order to check:
# 1. The auto-generated Launchpad PROJECT (xet7-craft-remote-build)
@@ -3523,14 +3580,61 @@ jobs:
# (The large-history push that also broke these builds is fixed
# separately, by flattening the repo before the push.)
echo "::error::If the build ended as 'Stopped' with no artifact: the Launchpad project https://launchpad.net/xet7-craft-remote-build should be MIT / X/Expat (it is set now) - re-set it only if it shows 'None specified'. Otherwise Launchpad cancelled the build as it ran (often an OOM in the Meteor npm install, or a transient build-farm reset - the printed build log ends mid-step); re-run, and if it persists on this arch open a Launchpad question. Builds: https://launchpad.net/~xet7/+snaps" >&2
# The commonest cause after a plain build error is the credential: Launchpad
# answers 401/403 for an expired or wrong LP_CREDENTIALS, which reads as an
# ordinary build failure unless the log says so.
if grep -qiE 'unauthoriz|401|403|invalid credentials|not logged in' snapcraft-wekan-*.txt 2>/dev/null; then
echo "::error::LP_CREDENTIALS may not work: Launchpad answered unauthorized. Re-create it (snapcraft remote-build once locally) and set: base64 -w0 ~/.local/share/snapcraft/launchpad-credentials" >&2
# A wrong or expired LP_CREDENTIALS makes Launchpad answer 401/403, which
# reads as an ordinary build failure unless the log is checked. But the
# pattern must be SPECIFIC: a bare `401`/`403` matches any three digits in
# the build log - a package size, a hash, "attempt 4/6" - so the old
# `401|403` fired on almost every large log and told the maintainer to
# re-create working credentials on every Stopped build. A build that
# reached "Building:" authenticated fine; the giveaway of a real auth
# failure is those codes in an HTTP-error context, or an explicit phrase.
if grep -qiE 'HTTP Error 40[13]|(^|[^0-9])40[13] (Unauthorized|Forbidden)|\bunauthoriz(ed|ation)\b|invalid credentials|bad credentials|not logged in|need to (log|sign) in|macaroon' snapcraft-wekan-*.txt 2>/dev/null; then
echo "::error::LP_CREDENTIALS may not work: the Launchpad build log shows an authorization failure. Re-create it (snapcraft remote-build once locally) and set: base64 -w0 ~/.local/share/snapcraft/launchpad-credentials" >&2
fi
exit 1
- name: Collect the FULL build logs for ${{ matrix.arch }}
# An excerpt in the job log answers "what failed"; it does not answer
# "what did the mongodb part print an hour earlier", which is what the
# v10.71 staging failure needed and nobody could get afterwards - the
# Launchpad build log is deleted with the temporary snap recipe, so once
# the job is over the only copy is the one kept here. always(), not
# failure(): a build that succeeded on attempt 2 hides why attempt 1 did
# not, and these are a few hundred KB of text.
if: always()
run: |
set -uo pipefail
mkdir -p snap-logs
# RENAMED, not just copied. snapcraft names a remote-build log after the
# recipe and an ISO timestamp:
# snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt
# and upload-artifact REFUSES a colon - "Invalid characters include:
# Double quote, Colon, Less than, ..." - because NTFS cannot hold one.
# That failed the whole job for s390x, ppc64el and riscv64 in v10.77
# AFTER all three snaps had built and downloaded on attempt 1, so three
# good builds were reported as FAILED by the step that saves their logs.
# The timestamp is worth keeping, so the colons become dashes rather
# than the name being thrown away.
for f in snapcraft-wekan-*.txt "$HOME"/.local/state/snapcraft/log/*.log; do
[ -f "$f" ] || continue
base="$(basename "$f")"
cp -f "$f" "snap-logs/${base//:/-}" || true
done
ls -la snap-logs || true
- uses: actions/upload-artifact@v7
# A DIAGNOSTIC upload can never fail the build it is diagnosing. The snap
# is the deliverable; these logs are here so a failure can be read
# afterwards, and the renaming above is not the last thing that could
# ever make one of them unuploadable.
continue-on-error: true
if: always()
with:
name: snap-launchpad-logs-${{ matrix.arch }}
path: snap-logs/
if-no-files-found: ignore
retention-days: 30
- name: Push ${{ matrix.arch }} to the Snap Store (stable + candidate + beta + edge)
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
@@ -3958,6 +4062,7 @@ jobs:
set -euo pipefail
cd exapp
docker buildx build --push \
--provenance=mode=max \
-t ghcr.io/wekan/wekan-exapp:v${VERSION} \
-t ghcr.io/wekan/wekan-exapp:latest \
--build-arg WEKAN_VERSION=${VERSION} .
@@ -4045,9 +4150,9 @@ jobs:
if: ${{ always() && needs.release.result == 'success' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
continue-on-error: true
with:
pattern: provenance-*
+8 -23
View File
@@ -67,7 +67,14 @@ test-results/
# Claude AI
.claude/
# Tools like mongosh etc so that they work inside Flatpak sandbox
# Companion repositories and tools, cloned INSIDE this checkout so they work in a
# Flatpak sandbox that shares only this directory. One ignored directory for all
# of them - it replaced an entry per clone (/FerretDB/, /node/, /mongo-tools/,
# /mongosh/, /sandstorm/, /projects/, and the WeKan variants), which had to be
# extended by hand for every new one. build.sh's ensure_tool_repo clones what a
# test run needs into here: .tools/FerretDB, .tools/node-patches,
# .tools/mongo-tools-patches. Nothing in here is WeKan's source, and Meteor is
# told the same by /.tools/ in .meteorignore.
.tools/
# Meteor custom local directory (METEOR_LOCAL_DIR)
@@ -75,28 +82,6 @@ _build-local-test
*/build-assets-local-test
*/build-chunks-local-test
# Local-only working copies: other wekan repos cloned under this repo for editing,
# and build inputs. These are separate git repos / large machine-specific dirs;
# never commit them.
# - projects/ : unarchived meteor-spk fork (Node/Mongo/FerretDB binaries)
# - sandstorm/ : clone of sandstorm-io/sandstorm (platform source, for reference)
# - FerretDB/ : clone of wekan/FerretDB
# - mongo-tools/ : clone of wekan/mongo-tools
# - mongosh/ : clone of wekan/mongosh
# - node/ : clone of the Node.js 24.x fork WeKan builds its runtime from
# - TSC/ : clone of Secretchronicles/TSC
# - secretchronicles.github.io/ : clone of the TSC website of the same name
# - gitea/ : clone of wekan/gitea, the fork of go-gitea/gitea
/projects/
/sandstorm/
/FerretDB/
/mongo-tools/
/mongosh/
/node/
/TSC/
/secretchronicles.github.io/
/gitea/
# Test and build logs, when they land INSIDE the repo. build.sh writes to
# ../log/<datetime>/ by preference - one level up, so a test run does not show
# up in git status - but the parent of the repo is not writable in a Flatpak
+4 -4
View File
@@ -6,9 +6,9 @@
meteor-base@1.5.2
# Build system
ecmascript@0.19.0-beta351.0
ecmascript@0.19.0
standard-minifier-js@3.2.0
rspack@1.2.0-beta351.0
rspack@1.2.0
# Declared explicitly because server/modernBrowsers.js imports it, to say that
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
modern-browsers@0.2.3
@@ -19,10 +19,10 @@ blaze@3.0.2
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.5.0-beta351.0
mongo@2.5.0
# Account system
accounts-password@3.3.1-beta351.0
accounts-password@3.3.1
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
+1 -1
View File
@@ -1 +1 @@
METEOR@3.5.1-beta.0
METEOR@3.5.1
+14 -14
View File
@@ -1,14 +1,14 @@
accounts-base@3.3.0
accounts-express@1.0.0
accounts-oauth@1.4.7
accounts-password@3.3.1-beta351.0
accounts-password@3.3.1
aldeed:collection2@4.2.1
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.0
audit-argument-checks@1.0.8
autoupdate@2.0.1
babel-compiler@7.15.0-beta351.0
babel-compiler@7.15.0
babel-runtime@1.5.2
base64@1.0.14
binary-heap@1.0.13
@@ -24,13 +24,13 @@ communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.2
ddp-client@3.4.0-beta351.0
ddp-client@3.4.0
ddp-common@1.4.5
ddp-rate-limiter@1.3.0
ddp-server@3.4.0-beta351.0
ddp-server@3.4.0
diff-sequence@1.1.3
dynamic-import@0.7.4
ecmascript@0.19.0-beta351.0
ecmascript@0.19.0
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
@@ -44,7 +44,7 @@ hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.0
inter-process-messaging@0.1.3-beta351.0
inter-process-messaging@0.1.3
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
@@ -56,16 +56,16 @@ meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.3.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2
minifier-js@3.3.0-beta351.0
minifier-js@3.3.0
minimongo@2.2.0
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
mongo@2.5.0-beta351.0
mongo@2.5.0
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
npm-mongo@6.16.2-beta351.0
npm-mongo@6.16.2
oauth@3.0.4
oauth2@1.3.3
observe-sequence@2.0.1
@@ -87,12 +87,12 @@ reload@1.3.2
retry@1.1.1
reywood:publish-composite@1.9.0
routepolicy@1.1.2
rspack@1.2.0-beta351.0
rspack@1.2.0
service-configuration@1.3.5
session@1.2.3
sha@1.0.10
shell-server@0.7.0
socket-stream-client@0.7.1-beta351.0
socket-stream-client@0.7.1
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.2.0
@@ -100,11 +100,11 @@ templating@1.4.5
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.2.0-beta351.0
tools-core@1.2.0
tracker@1.3.4
typescript@5.11.0-beta351.0
typescript@5.11.0
url@1.3.6
webapp@2.3.0-beta351.0
webapp@2.3.0
webapp-hashing@1.1.2
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
-65
View File
@@ -19,71 +19,6 @@ npm-packages/
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
/.tools/
# The FerretDB v1 fork checkout: a complete Go project + its docs, 9,148 more
# directories. Nothing in WeKan imports from it - the FerretDB binary is
# downloaded from its GitHub releases at build time - so Meteor has no reason to
# watch or scan it. Both are already in .gitignore, but the watcher only honours
# .meteorignore.
/FerretDB/
# ── The other checkouts that have accumulated beside the app ─────────────────
#
# Everything below is the SAME problem as /.tools/ and /FerretDB/ above, and it
# is why `meteor build` ran out of memory: Meteor scans the app directory, and
# these are in .gitignore only, which the scanner does not read.
#
# The count is what makes it fatal. Meteor builds one ignore matcher per
# directory it descends into, each carrying the whole accumulated pattern list,
# and a heap snapshot taken at the moment the build died showed 945 of those
# matchers holding 14.3 million rule objects - 980 MB of IgnoreRule plus 667 MB
# of sliced strings, 308 MB of concatenated strings and 274 MB in 945 copies of
# one 296 KB pattern list: about 2.5 GB of the 4.1 GB heap, and still only 945
# directories in. There are 6,867 directories under this one, of which 5,860 are
# in the trees named here; WeKan's own code is the remaining ~1,000. So the cost
# was very nearly SEVEN TIMES what the snapshot caught, which is why raising
# --max-old-space-size never helped - the ceiling was not the variable.
#
# Excluding them takes the scan back to WeKan itself.
# The Node.js fork checkout (github.com/wekan/node): 4,132 directories, 45,527
# files, 2.3 GB - by far the largest of these. WeKan uses its RELEASE BINARIES,
# downloaded per architecture at build time; the source is here to build them,
# and nothing in the app imports it.
/node/
# The mongo-tools checkout: another Go project, 738 directories. Same story -
# WeKan ships the built binaries, not this source.
/mongo-tools/
# TSC (the game) and its website, cloned here for the release work. 472 and 512
# directories, 810 MB together, and not WeKan at all.
/TSC/
/secretchronicles.github.io/
# The Gitea fork (wekan/gitea, forked from go-gitea/gitea): a whole Go + web
# application, 1,401 directories and 384 MB - about a fifth again of everything
# Meteor legitimately walks here. Nothing in WeKan imports it.
/gitea/
# Two more WeKan checkouts, 296 directories each. These are worse than the rest:
# they contain client/, server/ and models/, so they are not merely scanned -
# Meteor loads server/ and client/ EAGERLY, and it would be pulling a second and
# third copy of the whole app into this build.
/wekan-gantt-gpl/
/wekan-ondra/
# The node-patches checkout (wekan/node-patches): the PATCHES to upstream
# Node.js that WeKan's third Node.js source is built from - .patch files, their
# checksums and their docs. Nothing in WeKan imports it; it is here because it is
# worked on beside WeKan, and it is gitignored for the same reason.
/node-patches/
# The mongo-tools-patches checkout (wekan/mongo-tools-patches): the PATCHES to
# the upstream MongoDB Database Tools the bundled bsondump/mongodump/... are
# built from - .patch files, their checksums, their docs and the build. Nothing
# in WeKan imports it, and it replaces the /mongo-tools/ fork excluded above.
/mongo-tools-patches/
# DO NOT ADD /_build/ OR /_build-local-test/ HERE. They look like build output
# that should be ignored - they are gitignored, and they are the first two
# entries of the 296 KB pattern list above - and excluding them breaks the
+453
View File
@@ -0,0 +1,453 @@
# AGENTS.md — instructions Codex reads first
Codex reads this file at the repo root before doing work here. Follow it.
## First: are you the maintainer or a contributor?
Check the current git identity before committing or releasing:
```
git config user.name && git config user.email
```
- **Maintainer mode** — ONLY when the identity is exactly
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available.
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
the branch and do **not** run any release/publishing step. Make changes on a branch
and open a **pull request** for the maintainer to review. The "commit as Lauri
Ojansivu", "commit directly", and all release instructions below are **maintainer-only
and do not apply to you**.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
When asked to fix an open issue (one issue at a time):
1. **Read the whole issue** — the description AND all comments (e.g.
`gh issue view <n> --comments`, or the GitHub API `.../issues/<n>/comments`).
2. **If it is a bug, first check whether it is already fixed** in the current WeKan
AND FerretDB source code (read the actual code, not just changelogs). It may have
been fixed already.
3. **If it is NOT fixed yet, fix it**, using the newest documentation and issues of
the dependencies involved, checking where the newest MAINTAINED dependency lives and
reading its source and issues:
- Meteor 3 docs — https://docs.meteor.com/
- Meteor 3 source + issues — https://github.com/meteor/meteor/issues
- other Meteor software docs/code (e.g. Rocket.Chat) for how they solved it
- Meteor forums — https://forums.meteor.com
- AtmosphereJS — https://atmospherejs.com/ ; Packosphere — https://packosphere.com/
- Meteor Community Packages — https://github.com/meteor-community-packages
- Meteor-Files — https://github.com/veliovgroup/Meteor-Files
- npm — https://www.npmjs.com
4. **After fixing, add tests** — unit tests, negative tests, and UI tests where
appropriate — and **run the new tests to verify they pass** (see Tests / the sandbox
build+test instructions in `docs/Security/Sandboxes/vscode/README.md`).
5. **Commit** (maintainer only — when the current git user is `Lauri Ojansivu
<x@xet7.org>`), with no "Co-Authored" / AI trailer, a message body ending:
```
Thanks to (GitHub nickname of the issue creator) and xet7 !
Fixes #1234,
```
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
whose message ends `Fixes #1234,`), noting where/when it was fixed.
Fix from source and test — do not guess. If the environment cannot run the relevant
test (e.g. Docker is unavailable in this sandbox), reproduce as closely as possible
from source and say clearly what was and was not verified.
## Translations (Transifex ↔ direct LLM fill, no external service)
WeKan translations live in `imports/i18n/data/<lang>.i18n.json` (flat
`key -> string`, 2-space indent, key order matches `en.i18n.json`). Transifex holds
the human translations. **The policy is: never overwrite a human translation with a
filled (or English) one, but always take the newest translations from Transifex.**
- Pull with `releases/translations/pull-translations.sh`. It runs `tx pull -a -f`
(which fills every string that is UNtranslated on Transifex with the English source)
and then a **per-key merge** (`releases/translations/merge-translations.mjs`) that,
for every language file and every key:
- Transifex has a real translation (pulled value differs from English) → **keep it**
(the newest human translation always wins);
- the pull reverted it to English but a human translation is committed in git →
**restore the committed translation** (a pull never reverts a human translation,
even in files that also received real new Transifex translations);
- **no translation anywhere** (untranslated on Transifex AND never committed) → leave
the English source as a placeholder. **This is the only case a non-human value is
used.** A separate fill step may fill *only* these English placeholders, so a filled
string can never overwrite a human translation.
- Restored languages are pushed back to Transifex so they stop reverting.
### Filling the remaining untranslated strings — directly, no translation service
The strings still equal to the English source after the merge are the ones untranslated
**everywhere** (Transifex + git). Translate these **directly** — the maintainer or the
assistant (an LLM) writes the translation itself, using that language's **existing
translations** and general **kanban terminology** for the language as the reference.
**Do NOT wire up any external translation service, API, endpoint, key or password** — an
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
strings still needing translation (English + `en-*` variants are skipped: English by
design). Also printed at the end of `pull-translations.sh`.
- `node releases/translations/fill-translations.mjs --list <lang> [--limit N]` — dump the
untranslated keys of a language as `{ key: englishSource }` for the translator to fill.
- `node releases/translations/fill-translations.mjs --apply <lang> <translated.json>` —
merge the translations back. It writes **only** into placeholder keys, so it can
**never** overwrite a human translation, and a value still equal to English/empty is
ignored. Key order and 2-space indent are preserved.
**Both directions are safe, and it is verified:**
`node releases/translations/verify-human-preference.mjs` proves (pure-logic, no network)
that the pull-merge keeps/restores human translations and that a fill only touches
placeholders. Filled strings stay **local** — they are **NOT** pushed to Transifex (only
the merge-restored human languages are pushed), so a filled string can never masquerade
as human there. So: **only missing strings are ever filled, only when missing
everywhere, human strings are always preferred and merged**, and nothing you fill is
pushed to Transifex as if it were human.
## General practices (from ../log/v10/Claude.txt)
- **[maintainer only]** Commit as `Lauri Ojansivu <x@xet7.org>`, with **no**
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
do the opposite: work on a branch and open a pull request — see the top section.)
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
- `../w/wekan.fi` — the WeKan website.
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
use `wekan/wekan#NNNN`.
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
### CHANGELOG
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`). Do **not** hand-edit
`package.json` or any other version reference — the release workflow bumps those.
- **The file's shape, top to bottom** — keep it exactly as it is now:
1. `# Platforms` — the line `Newest WeKan at these platforms:` and the Install /
Upgrade / Docs / Mac ChangeLog bullets, then a `<details>` whose `<summary>` is
`Version` holding "which WeKan version uses what". There is no `# Version`
heading of its own.
2. `# TODO Later` — a `<details>` whose `<summary>` is `Carried to a future
release.` explaining the list, then one `<details>` per category (below).
3. The releases, newest first, each `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release`.
Nothing else is an `#` heading. A `##`/`###` inside a release would break the
version list, and a wrapped line that BEGINS with `#` (e.g. an issue number such
as `#6514`) becomes a heading too — escape it `\#6514` or keep it off the line
start.
- **A release's order of subsections** — CRITICAL SECURITY ISSUES first, then new
features, then the Admin Panel / UI reorganisation if there is one, then dependency
updates (`Thanks to dependabot.`), then bug fixes, then developer-facing changes,
documentation and translations. The section ends with the line `Thanks to above
GitHub users for their contributions and translators for their translations.`
- **Every entry is a `<details>` block, and they are what a release is made of.**
The `<summary>` is the SHORT description of what was done and IS the link to the
commit — the hash lives in the `href` and is never on the page — followed by the
`Thanks to …` sentence. Clicking it reveals the long description:
```
<details>
<summary><a href="https://github.com/wekan/wekan/commit/<hash>">Short description of
what was done</a>. Thanks to (issue creator's GitHub nickname) and xet7.</summary>
The long description: what was wrong, why, what it does now, what the test pins.
Word-wrapped at 80 characters, ordinary markdown - links, `code`, emphasis.
</details>
```
Exactly as above: `<details>` and `</details>` each on their own line, the whole
`<summary>…</summary>` on ONE line, a blank line under it, the body, a blank line,
the close — and a blank line between two blocks.
- **The summary is one line at a glance** — aim for ≤ 110 characters — and PLAIN text:
no `[text](url)`, no `**bold**`, no backticks inside `<summary>`, because a link
cannot nest inside the `<a>` and the rest renders literally. It ends with a full
stop, then `Thanks to …`. Every summary in a release links its own commit.
- **A change with nothing more to say stays a plain bullet** —
`- [Short description](https://github.com/wekan/wekan/commit/<hash>). Thanks to xet7.`
— and a dependency batch keeps its `- **package 1.2.3 → 1.2.4** — one line on what
it is` bullets, closing with `Thanks to dependabot.` A `<details>` whose body only
repeats its summary is noise; use one when there IS a longer story to reveal, which
is most fixes.
- **`# TODO Later` blocks are the same shape with two differences:** the `<summary>` is
the short category text (no `<a>`, because nothing was committed), and there is **no
`Thanks to`** — nothing is done yet, so there is nobody to thank. The body lists the
issues as `[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)`.
- **The hash is never the link text.** `[f1c89548e](…)` shows a hash to a reader who
cannot do anything with it; the link text says what changed. Same for `[merge
commit](…)` and for a bare URL — see the next rule.
- **Never show a long URL as visible text.** A link is always
`[short text](url)` — an issue is `[#6524](…/issues/6524)`, an advisory is
`[GHSA-xxxx](…)`, a security page is `[ZipBleed](https://wekan.fi/hall-of-fame/zipbleed/)`.
- **Subsection headers read as ONE flowing sentence.** The FIRST subsection of a release
starts with `This release ` (e.g. `This release fixes the following bugs:`); every LATER
subsection in the SAME release starts with a lowercase `and ` instead of repeating
`This release ` — e.g.
`This release fixes the following bugs:` … `and updates the following dependencies:` …
`and has the following developer-tooling fix:` … `and improves the translation workflow:`
… then the closing `Thanks to above GitHub users …` line. A release with only one
subsection just keeps its single `This release …:` header. Use `and adds the following
new features:`, `and fixes the following bugs:`, `and updates the following
dependencies:`, etc., matching the verb to the subsection.
- **The Upcoming section opens with an `**In short:**` paragraph.** One paragraph,
right under the `# Upcoming WeKan ® release` line and above the first
`This release …:` header, saying what the whole release amounts to — the areas
that changed and what changed about them, with the notable names in `**bold**`
so it can be skimmed. It is a SUMMARY, not a list: it does not link commits, it
does not repeat an entry's wording, and it ends by accounting for the rest in a
clause (`Below that: dependency updates, nine bug fixes … and the usual
documentation and translation work.`). Keep it current as entries are added —
it is the first thing a reader sees, so a stale one misdescribes the release.
A finished release keeps the paragraph it was written with.
- **Under the summary comes the BINARIES TABLE: what each platform ships.** So the
top of a release section is, in order, (1) the `**In short:**` paragraph and
(2) this table, and only then the `This release …:` subsections. A WeKan bundle
is not only WeKan — it carries a Node.js, a FerretDB and the MongoDB Database
Tools that other projects publish, and WHICH source has a given CPU changes
from release to release: nodejs.org builds some architectures,
unofficial-builds others, and [wekan/node-patches](https://github.com/wekan/node-patches)
the ones neither of them does. "Which Node.js is in the arm64 bundle of 10.69,
and was it checked" must be answerable from the CHANGELOG, not from a build log
that expires.
```
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `a1b2…` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.24.0/ferretdb-amd64) | v1.24.0 | `c3d4…` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `e5f6…` |
```
**GROUPED BY PLATFORM**: rows are sorted by platform and then by binary, so one
platform's binaries stay together and the table is read a platform at a time.
The URL is the LINK ON THE "From" CELL — never a bare URL as visible text, the
same rule as everywhere else — and it is the exact file that was downloaded, not
the project's front page. The SHA256 is the checksum the source published and the
build verified, in backticks; a source that publishes none says *no checksum
published*, which is not a failed check but a source worth fixing. Table rows
carry links, so the 80-character wrap does not apply to them.
It is the same table `releases/provenance-table.sh` puts at the top of the GitHub
release notes, from the `provenance.tsv` rows each build job records — so the two
are filled from the same data and cannot disagree. **A platform that was NOT built
has no rows**, which is how the table also answers "why is there no i386 bundle
this time": no source published a Node.js for it (see
`releases/resolve-node-source.sh`).
- **Inside a subsection, entries are GROUPED BY AREA.** A release touches a handful
of areas and repeating the area's name in every summary is the noise this
removes — twelve entries that each begin "All Boards:" say "All Boards" twelve
times and the part that differs starts halfway through the line. Instead the area
is named ONCE, as a bold line with a short description of what the group covers,
and every entry under it drops the prefix:
```
and fixes the following bugs:
**The first header bar** - how it lays itself out, and what sits under it.
<details>
<summary><a href="…">It fills each row before starting the next one</a>. Thanks to xet7.</summary>
…
</details>
<details>
<summary><a href="…">It wraps to a second row instead of hiding the buttons that do not fit</a>. Thanks to xet7.</summary>
…
</details>
**All Boards** - the overview and its search.
<details>
…
```
The group line is `**Area** - short description.` on ONE line, wrapped at 80 like
everything else, with a blank line under it. It is NOT a heading: a `##` inside a
release breaks the version list (see above). Group labels are the areas of the
app — `All Boards`, `The first header bar`, `The left menus`, `The Admin Panel`,
`Board views`, `Public Boards`, `Member Settings`, `Board roles` — and the same
label is reused across subsections when an area has both a feature and a fix.
EVERY entry of a grouped subsection belongs to a group, including a group with
one entry: a section that is half grouped and half loose reads as a mistake. A
subsection with only one entry, and the dependency bullets, stay flat.
- **CRITICAL security header — match the previous releases' wording.** A security release
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
`This release fixes the following CRITICAL SECURITY ISSUES:` for several — NOT
`CRITICAL VULNERABILITIES`. Each security bullet still links its own
`https://wekan.fi/hall-of-fame/...bleed/`. CodeQL-only releases use the established
`This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:`.
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
subsection becomes `and …` per the rule above.
- **`# TODO Later` section** — a triage backlog near the TOP of `CHANGELOG.md` (above the
version sections), for open issues that were **investigated but not fixed here**, each
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
ending `Fixes #NNNN,`), **close it** if already fixed in current code (commit `Close #NNNN` /
`Fixes #NNNN,`), or — when it can't be fixed/verified in this environment — **add it to `TODO
Later` under the matching category with the reason**. Group issues by category bullet, e.g.:
*Need specific infrastructure / a running server stack* (LDAP/WebHooks/Sandstorm/proxy —
environment owners), *Need the running app to reproduce/verify* (runtime UI / publication /
mergebox / router state, not unit-testable), *Already correct in the current code* (verified by
reading; could not reproduce), *Feature requests / behaviour-by-design rather than bugs*, and
*Needs a maintainer decision on the intended contract*. Each category is a `<details>`
whose `<summary>` is the SHORT category text and whose body lists the issues with their
reasons — and with **no `Thanks to` line**, because nothing is done yet, so there is
nobody to thank. Each entry is a normal issue link
`[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)` — issue links here, NOT
commit links (nothing was committed). **Keep it current:** when an issue in `TODO Later` gets
fixed, REMOVE it from the list (its fix commit's `Fixes #NNNN` closes it); do not leave fixed
issues in the backlog. This is the only CHANGELOG place that uses issue links and lives above
the releases; everything else uses the per-release commit-link bullets above.
- FerretDB Upcoming structure — `### New Features 🎉`, `### Fixed 🐛`, `### Other Changes
🤖`; entries end `... by @xet7. Thanks to xet7.`
- Word-wrap both CHANGELOGs at 80 chars, but never break a long link across lines (a
`<summary>` line, or any other line carrying a link, may be longer). Continuation
lines of a plain bullet are indented by two spaces.
### Commit message structure
```
Do something.
Thanks to (original creator of issue) and xet7 !
Fixes #1234,
Fixes #1235.
```
### Making a release — no version number needed **[maintainer only]**
All publishing / release steps below are maintainer-only. Contributors never run them.
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
9.99 → 10.00) dated today, commits + pushes, and triggers
`.github/workflows/release-all.yml` (its `bump` job bumps `package.json` and every
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
is the only hand step.
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
version). It renames `## Upcoming FerretDB release` to the next version with the
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
already-tagged version.
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `../w/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
- **A Hall of Fame row has EIGHT cells**, in this order: CVE, Icon, Vulnerability
name, Date, Responsible Security Disclosure by, Stars, Process, Vulnerabilities.
One thing per cell — the name without its icons, the Font Awesome icons alone in
Icon (still wrapped in `<h2>`), the reporter alone in "by", the `GoldStar.png`
images alone in Stars, and the sentence about how the report arrived (coordinated
disclosure, GitHub advisory, code scanning, found while reviewing …) in Process.
In "by", the reporter's NICKNAME is the link —
`<b><a href="https://github.com/nick">nick</a></b>`, never the name followed by a
separate "(GitHub)" link. **Check the account exists before linking it** (a 404 or,
worse, somebody else's profile is the failure here); a real name, a company or a
tool such as GitHub CodeQL is not a GitHub user and stays plain text. Do not write
a role note after a name — no `(fix)`, `(found)` or `(found and fix)`: this column
is the credit, and the Process cell beside it already says how the report arrived.
Stars is a column so it can be compared down the page; do not put a star, an icon
or the process sentence back into another cell. A page-local `<style>` in
`index.html` keeps a row's stars on ONE line from 900px up (they are a count) and
sizes them at 24px, and keeps the icon and its red drop on one line at every width
(they are one icon) — the table itself stays percentage-sized with no fixed or
minimum width, so it fills the page at every browser width.
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
collapsed.** Those two columns carry the prose — how the report arrived, and the
whole story of the vulnerability — so the page would be one wall of text and the
columns that identify a row (CVE, name, date, reporter, stars) would be far apart.
In `hall-of-fame/index.html`, and ONLY in those two `<td>`s, the cell's content
sits inside a `<details>` whose summary is the column's own name — `Process` for
the Process cell, `Details` for the Vulnerabilities cell:
```
<td valign="top">
<details>
<summary>Details</summary>
<ul>
<li>… the same list as before …</li>
</ul>
</details>
</td>
```
The summary is that plain word, there is a blank line under it, and the cell's
contents are otherwise unchanged. The other six cells of the row stay as they are
— they are what a reader scans — and so does the header row.
- **The vuln-name subdirectory page is NOT collapsed.** `hall-of-fame/<name>bleed/index.html`
is the page a reader opened on purpose, for one vulnerability: its table cell stays
open and its `<h2 class="hof">Details</h2>` prose section below the table stays as
it is. Only the index, which lists them all, hides them behind the summary.
### Tests
- `build.sh` / `build.bat` have a menu to install dependencies, build
WeKan and run tests.
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
have tests. When adding a test, run or validate it and fix it until it works.
- **"Check newest test logs":** test logs go into a `log/<datetime>/` directory,
one per run (e.g. `2026-07-21_20-58-09/`). That is **`../log/`**, one level up
from the repo, so a run does not show up in `git status` - **unless the parent
is not writable**, as in a Flatpak sandbox that shares only the repository;
there it is **`./log/`** inside the repo (gitignored). `build.sh` prints which
one when a run starts. The
newest datetime directory is the latest run. Each holds the Playwright per-browser
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
(the WeKan test server + database output). A whole-run directory also holds the
database-conformance logs (`db-conformance-*.log`, `-report.md`, `-summary.txt`)
and FerretDB's own (`ferretdb-unit.log`, `-vet.log`, `-integration.log`). "Check
the newest test logs" means: open the most recent `log/<datetime>/` - look in
`../log/` first, then `./log/` - and read those.
- **Check and fix WHILE the tests are still running.** A full run takes a long time
(three browsers, then every database with an image for this CPU, then FerretDB's
own suites), and its stages finish one at a time. Do not wait for the end: read
the logs that are already written, fix what they show, and then look again for the
stages that have finished since — repeat until every stage has run and everything
found is fixed. Two things make this work:
- A stage's log file is complete when its `===== ... finished` line is there; a
file whose mtime is still moving is a stage in progress, and its failures so far
are already real and worth fixing.
- The node suites are run by `tests/run-node-suites.cjs`, which runs ALL of them
and lists every failure at the end (`===== node suites: N run, M failed`). They
are the fastest signal - about 15 seconds for 260 suites - so they are usually
what to fix first while the browsers are still going.
- **A failing guard is not automatically a broken app.** Most of these suites read
the source and pin a behaviour. When one fails, decide which side is wrong: fix
the CODE when the guard still describes what WeKan should do, and fix the GUARD
when the behaviour deliberately changed - and then say in the test WHY, so the
next reader knows it was a decision and not a slip. Every fixed test keeps the
assertion that made it valuable; do not delete a test to make a run green.
## Environment
- The editor (VSCode) runs inside a **Flatpak sandbox**, launched by
`docs/Security/Sandboxes/vscode/vscode-sandbox.sh`. What the sandbox allows/blocks and
how it is set up is documented in `docs/Security/Sandboxes/vscode/README.md` in that
same directory — read it when something behaves differently than a normal host (file
access, network, running services).
### Always validate from the actual code
- When doing anything, check how it actually works in the code first.
+1980 -19
View File
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -14,8 +14,7 @@ git config user.name && git config user.email
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available. (Per the standing rule you still **commit only; do not push** unless
explicitly asked.)
are available.
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
the branch and do **not** run any release/publishing step. Make changes on a branch
and open a **pull request** for the maintainer to review. The "commit as Lauri
@@ -122,17 +121,21 @@ pushed to Transifex as if it were human.
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
do the opposite: work on a branch and open a pull request — see the top section.)
- **[maintainer only]** **Commit only. Do not push** (unless explicitly asked).
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
- `../w/wekan.fi` — the WeKan website.
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
- `.tools/` — companion repos cloned INSIDE this checkout, in one directory
that `.gitignore` and `.meteorignore` already exclude (they used to be one
ignored subdirectory each at the repo root). `build.sh`'s `ensure_tool_repo`
clones one on demand, so a fresh checkout needs no manual setup:
`.tools/FerretDB`, `.tools/node-patches`, `.tools/mongo-tools-patches`.
- `.tools/FerretDB` (cloned on demand) — https://github.com/wekan/FerretDB, its own
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
use `wekan/wekan#NNNN`.
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
- `.tools/sandstorm` (when present) — https://github.com/sandstorm-io/sandstorm.
### CHANGELOG
@@ -344,7 +347,7 @@ All publishing / release steps below are maintainer-only. Contributors never run
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
is the only hand step.
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
- FerretDB: run `./build.sh release-ferretdb` from `.tools/FerretDB` (no
version). It renames `## Upcoming FerretDB release` to the next version with the
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
+1 -1
View File
@@ -12,7 +12,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=10.71
ARG VERSION=10.78
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.71.0"
appVersion: "v10.78.0"
files:
userUploads:
- README.md
+171 -87
View File
@@ -100,12 +100,14 @@ echo.
echo -- Setup -- ^(0 = Back^)
echo 1^) Install dependencies
echo 2^) Build WeKan
echo 3^) Update git ^(fetch + rebase onto origin, fix CHANGELOG hashes, status^)
echo 3^) git pull ^(fetch, fast-forward or rebase, repoint moved CHANGELOG links^)
echo 4^) git push ^(check the CHANGELOG links, push, pull-and-retry once if rejected^)
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" goto install
if "%choice%"=="2" goto build
if "%choice%"=="3" goto updategit
if "%choice%"=="3" goto gitpull
if "%choice%"=="4" goto gitpush
if "%choice%"=="0" goto menu
goto menu_setup
@@ -138,8 +140,10 @@ REM ===========================================================================
:menu_tests
echo.
echo -- Tests -- ^(0 = Back^)
echo 1^) EVERYTHING ^(sequential^): WeKan's own tests, then every database with an
echo image for this CPU, then all FerretDB tests. Logs in ..\log\^<datetime^>\
echo 1^) EVERYTHING ^(sequential^): the floating-promises guard, then WeKan's own
echo tests ^(mocha, node suites, import, node E2E, three browsers^), then every
echo database with an image for this CPU, then all FerretDB tests ^(unit, vet,
echo integration^). Logs in ..\log\^<datetime^>\
echo 2^) WeKan's own tests only, parallel: Mocha, node unit suites, import, node
echo E2E and all three browsers, concurrently. No databases, no FerretDB
echo 3^) WeKan's own tests only, sequential: the same suite, one job at a time
@@ -299,6 +303,7 @@ if "%choice%"=="3" call :rel_run "releases/snap-push-to-store.sh" "Path to the .
if "%choice%"=="4" call :rel_run "releases/release-snap.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="5" call :rel_run "releases/snap-store-revisions.sh" ""
if "%choice%"=="6" call :rel_run "releases/snap-store-release-revision-to-channels.sh" "Snap Store revision number"
if "%choice%"=="7" call :rel_run "releases/snap-release-all-channels.sh" "Version, or empty for the newest"
if "%choice%"=="7" call :rel_run "releases/snap-edge.sh" ""
if "%choice%"=="8" call :rel_run "releases/snap-stable.sh" ""
if "%choice%"=="9" call :rel_run "releases/snapcraft-help.sh" ""
@@ -556,6 +561,7 @@ echo snap-push-to-store Push one .snap to the Snap Store ^<P
echo release-snap Release the snap for one version ^<WeKan version, e.g. 10.50^>
echo snap-store-revisions List the newest Snap Store revisions
echo snap-store-release-revision-to-channels Release one store revision to edge, beta and candidate ^<Snap Store revision number^>
echo snap-release-all-channels Release every snap, every architecture, to all four channels ^<Version, or empty for the newest^>
echo snap-edge Switch the installed snap to the edge channel
echo snap-stable Switch the installed snap to the stable channel
echo snapcraft-help snapcraft help topics
@@ -638,6 +644,7 @@ if /I "%K%"=="snap-push-to-store" (set "CMD=bash releases/snap-push-to-store.sh"
if /I "%K%"=="release-snap" (set "CMD=bash releases/release-snap.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-revisions" (set "CMD=bash releases/snap-store-revisions.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-release-revision-to-channels" (set "CMD=bash releases/snap-store-release-revision-to-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-release-all-channels" (set "CMD=bash releases/snap-release-all-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-edge" (set "CMD=bash releases/snap-edge.sh" ^& goto cli_go)
if /I "%K%"=="snap-stable" (set "CMD=bash releases/snap-stable.sh" ^& goto cli_go)
if /I "%K%"=="snapcraft-help" (set "CMD=bash releases/snapcraft-help.sh" ^& goto cli_go)
@@ -672,6 +679,11 @@ if /I "%K%"=="push-copy-en-gb-translation" (set "CMD=bash releases/translations/
if /I "%K%"=="report-english-regressions" (set "CMD=node releases/translations/report-english-regressions.mjs" ^& goto cli_go)
if /I "%K%"=="verify-human-preference" (set "CMD=node releases/translations/verify-human-preference.mjs" ^& goto cli_go)
if /I "%K%"=="merge-translations" (set "CMD=node releases/translations/merge-translations.mjs" ^& goto cli_go)
REM `git pull` and `git push` are shell functions in build.sh, not scripts in
REM releases/, so this answers to the same two names with its own labels - the
REM ones the interactive menu already uses.
if /I "%K%"=="git-pull" (goto gitpull)
if /I "%K%"=="git-push" (goto gitpush)
if /I "%K%"=="commit" (set "CMD=bash releases/commit.sh" ^& goto cli_go)
if /I "%K%"=="git-add-revert" (set "SHCMD=git restore --staged" ^& goto cli_go)
if /I "%K%"=="delete-branch-local-and-remote" (set "CMD=bash releases/delete-branch-local-and-remote.sh" ^& goto cli_go)
@@ -799,51 +811,32 @@ echo Done.
goto end
REM ===========================================================================
REM Make the working copy current in one step: fetch + rebase the current branch
REM onto its upstream, repoint any CHANGELOG commit links the rebase made stale
REM (the same shared releases\fix-changelog-hashes.sh release-all.sh uses), and
REM show the status. Parity with build.sh's Setup -> "Update git ...".
:updategit
echo Updating git: fetch + rebase onto origin, fix CHANGELOG hashes, show status.
git rev-parse --git-dir >nul 2>&1
if errorlevel 1 ( echo Not a git repository. & goto end )
set "BRANCH="
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "BRANCH=%%b"
echo Branch: %BRANCH%
echo (A dirty tree is auto-stashed for the rebase and re-applied after.)
echo --- git fetch --all --prune ---
git fetch --all --prune
git rev-parse --verify --quiet "origin/%BRANCH%" >nul 2>&1
if errorlevel 1 (
echo No upstream origin/%BRANCH% - skipping rebase.
) else (
echo --- git pull --rebase --autostash origin %BRANCH% ---
git pull --rebase --autostash origin "%BRANCH%"
if errorlevel 1 (
echo.
echo Rebase stopped ^(conflicts^). Resolve them, then:
echo git rebase --continue ^(or: git rebase --abort^)
echo and run this option again to finish the hash fix + status.
goto end
)
)
echo --- Fixing CHANGELOG commit links ^(releases\fix-changelog-hashes.sh^) ---
echo ^(whole file, including released sections: bash releases/fix-changelog-hashes.sh --all-sections^)
:gitpull
REM Both git actions are build.sh's git_pull / git_push, run through bash rather
REM than written a second time here. What they do - fast-forward or rebase,
REM repoint the CHANGELOG links a rebase moved, abort a conflicting rebase so the
REM repo is left exactly as it was, pull-and-retry once when a push is rejected -
REM is shell logic, and the batch copy of the old "Update git" that used to live
REM here is why this file drifted: it reimplemented the same steps, so a fix to
REM one never reached the other.
where bash >nul 2>&1
if errorlevel 1 (
echo bash not found - the CHANGELOG hash fix needs Git Bash, bundled with Git for Windows.
echo Skipping it. Run it yourself in Git Bash: bash releases/fix-changelog-hashes.sh
) else (
bash releases/fix-changelog-hashes.sh
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
echo --- git status ---
git status
echo.
echo NOTE: if CHANGELOG.md was changed above, review 'git diff CHANGELOG.md' and
echo commit it - this script never commits for you.
bash ./build.sh git-pull
goto end
REM ===========================================================================
:gitpush
where bash >nul 2>&1
if errorlevel 1 (
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
bash ./build.sh git-push
goto end
:dev_local
call :ensure_dirs
call :set_dev_env
@@ -949,8 +942,10 @@ call :rebuild_for_tests
if errorlevel 1 goto end
set "FAILED=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN" & set "S_browsers=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0" & set "C_browsers=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN"
set "S_chromium=RUN" & set "S_firefox=RUN" & set "S_webkit=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0"
set "C_chromium=0" & set "C_firefox=0" & set "C_webkit=0"
REM Each run gets its own ..\log\<timestamp>\ dir (stamped once at run start), so
REM logs are never overwritten and previous runs are kept. PowerShell gives a
REM locale-independent yyyy-MM-dd_HH-mm-ss; %RUN_LOGDIR% is absolute so it works
@@ -960,7 +955,7 @@ set "RUN_LOGDIR=%REPO%\..\log\%RUN_TS%"
if not exist "%RUN_LOGDIR%" md "%RUN_LOGDIR%"
echo Logs for this run: %RUN_LOGDIR%\ - previous runs are kept
REM Clear completion flags from any previous run.
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-browsers" 2>nul
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-chromium" ".done-firefox" ".done-webkit" 2>nul
REM Start the :3000 server FIRST and let it build alone. Mocha runs its own
REM Meteor build (.meteor\local-test); launching it here would make two full
@@ -982,11 +977,19 @@ start "Wekan import" /MIN /D "%REPO%" cmd /c "(echo ===== Import regression [no
if "!SERVER_READY!"=="0" (
echo FAIL: server did not become ready on http://localhost:3000 ^(see %RUN_LOGDIR%\wekan-test-server.log^)
set "S_e2e=SKIP" & set "S_browsers=SKIP" & set "FAILED=1"
set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=SKIP" & set "FAILED=1"
) else (
echo ==^> Server is up: starting Node E2E and Playwright ^(Chromium + Firefox + WebKit, --workers=3^) in parallel.
echo ==^> Server is up: starting Node E2E and Playwright ^(Chromium, Firefox and WebKit as three separate jobs^) in parallel.
start "Wekan e2e" /MIN /D "%REPO%" cmd /c "(echo ===== Node E2E [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & call meteor npm run test:e2e 1>>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & if errorlevel 1 (echo FAIL>.done-e2e) else (echo PASS>.done-e2e)"
start "Wekan browsers" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright browsers [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=3 --reporter=list 1>>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-browsers) else (echo PASS>..\..\.done-browsers)"
REM One job, one log, one summary row PER BROWSER - the same shape build.sh
REM produces. A single combined "browsers" job wrote one wekan-alltests-browsers.log
REM for all three, so "which browser failed, and what did WebKit print" could not
REM be answered from the logs, and CLAUDE.md's "check the newest test logs" names
REM the per-browser files. --output keeps each browser's artifacts in its own
REM directory, or the three would clear each other's at startup.
call :start_browser_job chromium
call :start_browser_job firefox
call :start_browser_job webkit
)
REM Live progress: re-print a status line every ~3s until all expected jobs
@@ -1001,18 +1004,25 @@ call :jcount C_unit unit "%RUN_LOGDIR%\wekan-alltests-unit.log"
call :jcount C_import check "%RUN_LOGDIR%\wekan-alltests-import.log"
if "!SERVER_READY!"=="1" (
call :jstate e2e
call :jstate browsers
call :jstate chromium
call :jstate firefox
call :jstate webkit
call :jcount C_e2e e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
call :jcount C_browsers check "%RUN_LOGDIR%\wekan-alltests-browsers.log"
call :jcount C_chromium check "%RUN_LOGDIR%\wekan-alltests-chromium.log"
call :jcount C_firefox check "%RUN_LOGDIR%\wekan-alltests-firefox.log"
call :jcount C_webkit check "%RUN_LOGDIR%\wekan-alltests-webkit.log"
)
echo mocha [M2 :3100/db:3101] !S_mocha! tests:!C_mocha! ^| unit [no server] !S_unit! tests:!C_unit! ^| import [no server] !S_import! tests:!C_import! ^| e2e [M1 :3000/db:3001] !S_e2e! tests:!C_e2e! ^| browsers [M1 :3000/db:3001] !S_browsers! tests:!C_browsers!
echo mocha [M2 :3100/db:3101] !S_mocha! tests:!C_mocha! ^| unit [no server] !S_unit! tests:!C_unit! ^| import [no server] !S_import! tests:!C_import! ^| e2e [M1 :3000/db:3001] !S_e2e! tests:!C_e2e!
echo chromium !S_chromium! tests:!C_chromium! ^| firefox !S_firefox! tests:!C_firefox! ^| webkit !S_webkit! tests:!C_webkit! [all three on M1 :3000/db:3001]
set "ALLDONE=1"
if not exist ".done-mocha" set "ALLDONE=0"
if not exist ".done-unit" set "ALLDONE=0"
if not exist ".done-import" set "ALLDONE=0"
if "!SERVER_READY!"=="1" (
if not exist ".done-e2e" set "ALLDONE=0"
if not exist ".done-browsers" set "ALLDONE=0"
if not exist ".done-chromium" set "ALLDONE=0"
if not exist ".done-firefox" set "ALLDONE=0"
if not exist ".done-webkit" set "ALLDONE=0"
)
if "!ALLDONE!"=="0" (
ping -n 4 127.0.0.1 >nul
@@ -1029,7 +1039,9 @@ if "!S_mocha!"=="FAIL" set "FAILED=1"
if "!S_unit!"=="FAIL" set "FAILED=1"
if "!S_import!"=="FAIL" set "FAILED=1"
if "!S_e2e!"=="FAIL" set "FAILED=1"
if "!S_browsers!"=="FAIL" set "FAILED=1"
if "!S_chromium!"=="FAIL" set "FAILED=1"
if "!S_firefox!"=="FAIL" set "FAILED=1"
if "!S_webkit!"=="FAIL" set "FAILED=1"
echo.
echo ==================== TEST SUMMARY ====================
@@ -1038,9 +1050,11 @@ call :report "!S_unit!" "Unit tests (node)" "[no server]
call :report "!S_import!" "Import regression" "[no server] tests:!C_import!"
if "!SERVER_READY!"=="1" ( call :report "PASS" "Server startup" "[M1 :3000/db:3001]" ) else ( call :report "FAIL" "Server startup" "[M1 :3000/db:3001]" )
call :report "!S_e2e!" "Node E2E regressions" "[M1 :3000/db:3001] tests:!C_e2e!"
call :report "!S_browsers!" "Playwright (Chromium+Firefox+WebKit)" "[M1 :3000/db:3001] tests:!C_browsers!"
call :report "!S_chromium!" "Playwright Chromium" "[M1 :3000/db:3001] tests:!C_chromium!"
call :report "!S_firefox!" "Playwright Firefox" "[M1 :3000/db:3001] tests:!C_firefox!"
call :report "!S_webkit!" "Playwright WebKit" "[M1 :3000/db:3001] tests:!C_webkit!"
echo =====================================================
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|browsers^>.log and wekan-test-server.log)
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|chromium^|firefox^|webkit^>.log and wekan-test-server.log)
if "!FAILED!"=="0" ( echo RESULT: All tests passed. ) else ( echo RESULT: Some tests FAILED ^(see details above^). )
goto end
@@ -1066,8 +1080,10 @@ call :rebuild_for_tests
if errorlevel 1 goto end
set "FAILED=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN" & set "S_browsers=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0" & set "C_browsers=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN"
set "S_chromium=RUN" & set "S_firefox=RUN" & set "S_webkit=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0"
set "C_chromium=0" & set "C_firefox=0" & set "C_webkit=0"
REM Each run gets its own ..\log\<timestamp>\ dir (stamped once at run start), so
REM logs are never overwritten and previous runs are kept. PowerShell gives a
REM locale-independent yyyy-MM-dd_HH-mm-ss; %RUN_LOGDIR% is absolute so it works
@@ -1077,7 +1093,7 @@ set "RUN_LOGDIR=%REPO%\..\log\%RUN_TS%"
if not exist "%RUN_LOGDIR%" md "%RUN_LOGDIR%"
echo Logs for this run: %RUN_LOGDIR%\ - previous runs are kept
REM Clear completion flags from any previous run.
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-browsers" 2>nul
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-chromium" ".done-firefox" ".done-webkit" 2>nul
REM Start the :3000 server FIRST and let it build alone. Mocha runs its own
REM Meteor build (.meteor\local-test); launching it here would make two full
@@ -1116,15 +1132,23 @@ echo ==^> Running Node E2E regressions on Meteor #1 [Node.js :3000, MongoDB :300
start "Wekan e2e" /MIN /D "%REPO%" cmd /c "(echo ===== Node E2E [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & call meteor npm run test:e2e 1>>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & if errorlevel 1 (echo FAIL>.done-e2e) else (echo PASS>.done-e2e)"
call :seq_run_wait e2e e2e C_e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
echo ==^> Running Playwright Chromium, Firefox and WebKit one at a time ^(--workers=1^) on Meteor #1 [Node.js :3000, MongoDB :3001]. Full log: %RUN_LOGDIR%\wekan-alltests-browsers.log
start "Wekan browsers" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright browsers [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & call :onelog playwright-all
call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=1 --reporter=list 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%ONELOG%'" 1>>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-browsers) else (echo PASS>..\..\.done-browsers)"
call :seq_run_wait browsers check C_browsers "%RUN_LOGDIR%\wekan-alltests-browsers.log"
echo ==^> Running Playwright Chromium, Firefox and WebKit one browser at a time on Meteor #1 [Node.js :3000, MongoDB :3001].
REM One job, one log and one summary row per browser, as build.sh does. The
REM combined run wrote a single wekan-alltests-browsers.log for all three, so
REM neither "which browser failed" nor "what did WebKit print" was answerable
REM from the logs afterwards - and CLAUDE.md's "check the newest test logs"
REM names wekan-alltests-chromium.log, -firefox.log and -webkit.log.
call :start_browser_job chromium
call :seq_run_wait chromium check C_chromium "%RUN_LOGDIR%\wekan-alltests-chromium.log"
call :start_browser_job firefox
call :seq_run_wait firefox check C_firefox "%RUN_LOGDIR%\wekan-alltests-firefox.log"
call :start_browser_job webkit
call :seq_run_wait webkit check C_webkit "%RUN_LOGDIR%\wekan-alltests-webkit.log"
goto server_jobs_done
:skip_server_jobs
echo FAIL: server did not become ready on http://localhost:3000 ^(see %RUN_LOGDIR%\wekan-test-server.log^)
set "S_e2e=SKIP" & set "S_browsers=SKIP" & set "FAILED=1"
set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=SKIP" & set "FAILED=1"
:server_jobs_done
@@ -1138,14 +1162,18 @@ if "!S_mocha!"=="FAIL" set "FAILED=1"
if "!S_unit!"=="FAIL" set "FAILED=1"
if "!S_import!"=="FAIL" set "FAILED=1"
if "!S_e2e!"=="FAIL" set "FAILED=1"
if "!S_browsers!"=="FAIL" set "FAILED=1"
if "!S_chromium!"=="FAIL" set "FAILED=1"
if "!S_firefox!"=="FAIL" set "FAILED=1"
if "!S_webkit!"=="FAIL" set "FAILED=1"
REM Count passing tests per job from each log (advances shown in the summary).
call :jcount C_mocha check "%RUN_LOGDIR%\wekan-alltests-mocha.log"
call :jcount C_unit unit "%RUN_LOGDIR%\wekan-alltests-unit.log"
call :jcount C_import check "%RUN_LOGDIR%\wekan-alltests-import.log"
call :jcount C_e2e e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
call :jcount C_browsers check "%RUN_LOGDIR%\wekan-alltests-browsers.log"
call :jcount C_chromium check "%RUN_LOGDIR%\wekan-alltests-chromium.log"
call :jcount C_firefox check "%RUN_LOGDIR%\wekan-alltests-firefox.log"
call :jcount C_webkit check "%RUN_LOGDIR%\wekan-alltests-webkit.log"
echo.
echo ==================== TEST SUMMARY ====================
@@ -1154,9 +1182,11 @@ call :report "!S_unit!" "Unit tests (node)" "[no server]
call :report "!S_import!" "Import regression" "[no server] tests:!C_import!"
if "!SERVER_READY!"=="1" ( call :report "PASS" "Server startup" "[M1 :3000/db:3001]" ) else ( call :report "FAIL" "Server startup" "[M1 :3000/db:3001]" )
call :report "!S_e2e!" "Node E2E regressions" "[M1 :3000/db:3001] tests:!C_e2e!"
call :report "!S_browsers!" "Playwright (Chromium+Firefox+WebKit)" "[M1 :3000/db:3001] tests:!C_browsers!"
call :report "!S_chromium!" "Playwright Chromium" "[M1 :3000/db:3001] tests:!C_chromium!"
call :report "!S_firefox!" "Playwright Firefox" "[M1 :3000/db:3001] tests:!C_firefox!"
call :report "!S_webkit!" "Playwright WebKit" "[M1 :3000/db:3001] tests:!C_webkit!"
echo =====================================================
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|browsers^>.log and wekan-test-server.log)
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|chromium^|firefox^|webkit^>.log and wekan-test-server.log)
if "!FAILED!"=="0" ( echo RESULT: All tests passed. ) else ( echo RESULT: Some tests FAILED ^(see details above^). )
goto end
@@ -1252,17 +1282,34 @@ if /i "%INSTALL_DEPS%"=="y" (
popd
)
REM All three browsers run natively on Windows. Use --workers=1 so a single
REM Playwright run executes Chromium, Firefox and WebKit tests one at a time
REM (sequentially) rather than concurrently: running all three at once uses too
REM much RAM/swap and can crash the machine. WEKAN_PLAYWRIGHT_ALL=1 enables all
REM projects.
cd /d "%REPO%\tests\playwright"
set "WEKAN_PLAYWRIGHT_ALL=1"
call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=1 --reporter=list
if errorlevel 1 ( echo RESULT: Some Playwright browsers FAILED ^(see details above^). ) else ( echo RESULT: All Playwright browsers passed. )
REM One browser at a time, each with its own log - the same as build.sh's
REM run_playwright_parallel, and the same per-browser logs the whole-suite runs
REM write. Running all three concurrently against one dev server uses too much
REM RAM/swap on smaller machines and can crash it, so this stays sequential.
REM
REM It used to be a single `playwright test --project=... --project=...` call
REM with no log at all: everything went to the terminal and there was nothing
REM left to read afterwards, which is what tests/dbConformanceWiring.test.cjs
REM means by "every Tests option writes its log to ../log/<datetime>/".
set "PW_ALL_FAILED=0"
for %%B in (chromium firefox webkit) do call :pw_one_browser %%B
if "%PW_ALL_FAILED%"=="1" ( echo RESULT: Some Playwright browsers FAILED ^(see the logs above^). ) else ( echo RESULT: All Playwright browsers passed. )
goto end
:pw_one_browser
REM One browser of the "ALL browsers" option: %1 = chromium^|firefox^|webkit.
REM Streams live AND writes wekan-playwright-^<browser^>.log, via the same
REM :onelog helper every other Tests option uses.
set "PW_PROJECT=%~1"
call :onelog playwright-%PW_PROJECT%
echo ==^> Playwright %PW_PROJECT% ^(one browser at a time^). Log: %ONELOG%
pushd "%REPO%\tests\playwright"
set "WEKAN_PLAYWRIGHT_ALL=1"
call meteor npm exec playwright test -- --project=%PW_PROJECT% --output=test-results\%PW_PROJECT% --reporter=list 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%ONELOG%'"
if errorlevel 1 set "PW_ALL_FAILED=1"
popd
exit /b 0
REM ===========================================================================
:check_floating
echo Ensuring missing ESLint dependencies for the no-floating-promises rule.
@@ -1724,6 +1771,25 @@ REM 6) Wait for :3000 to answer (bundle boots in seconds; curl-timeout poll).
call :wait_server_ready
exit /b 0
:start_browser_job
REM Start ONE Playwright browser as its own job: %1 = chromium^|firefox^|webkit.
REM
REM One job, one log, one .done flag and one summary row per browser - the same
REM shape build.sh's run_pw_all_browser produces, and what CLAUDE.md means by the
REM per-browser logs in log\^<datetime^>\. The combined three-project run this
REM replaced wrote everything into one wekan-alltests-browsers.log, where a
REM failure could not be attributed to a browser without reading the whole file.
REM
REM --output is per browser as well: Playwright CLEARS its output directory at
REM startup, so three jobs sharing test-results\ would delete each other's traces
REM and screenshots - exactly the artifacts wanted after a failure. The job runs
REM in tests\playwright, so the .done flag is written two levels up, in the repo
REM root, where the wait loops look for it.
if "%~1"=="" exit /b 1
echo ==^> Playwright %~1 on Meteor #1 [Node.js :3000, MongoDB :3001]. Log: %RUN_LOGDIR%\wekan-alltests-%~1.log
start "Wekan %~1" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright %~1 [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-%~1.log 2>&1 & call meteor npm exec playwright test -- --project=%~1 --output=test-results\%~1 --reporter=list 1>>%RUN_LOGDIR%\wekan-alltests-%~1.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-%~1) else (echo PASS>..\..\.done-%~1)"
exit /b 0
:seq_run_wait
REM Live progress for a sequential job that runs in its own minimized window and
REM writes .done-<key> when finished. %1=key %2=count kind (check^|e2e) %3=count var
@@ -1761,8 +1827,8 @@ exit /b 0
REM ===========================================================================
:test_all_databases
REM The same thing build.sh's "All databases (sequential)" runs: build the newest
REM FerretDB v1 from the FerretDB subdirectory (cloning wekan/FerretDB if it is
REM not there, and installing Go and the module dependencies if they are
REM FerretDB v1 from .tools\FerretDB (cloning wekan/FerretDB there if it is not
REM there, and installing Go and the module dependencies if they are
REM missing), then run the whole FerretDB v1 query catalogue against every
REM database that has a Docker image for THIS CPU - one at a time, because they
REM all use the same FerretDB port - and compare that they all answered the same.
@@ -1782,9 +1848,13 @@ goto end
REM ===========================================================================
:test_ferretdb
REM All of FerretDB's own tests, one at a time: unit, vet, integration. FerretDB
REM is expected to be a subdirectory of this repo - the "All databases" option
REM clones it if it is not there. Its build.sh installs Go and the Go modules
REM All of FerretDB's own tests, one at a time: unit, vet, integration.
REM
REM FerretDB lives in .tools\FerretDB - companion repos are kept in one directory
REM that .gitignore and .meteorignore already exclude, instead of one ignored
REM subdirectory each at the repo root. It is cloned here when it is not there,
REM the same as build.sh's ensure_tool_repo does, so neither script depends on
REM the other having been run first. Its build.sh installs Go and the Go modules
REM when they are missing, and writes its logs to ..\log\<datetime>\ with every
REM other test run's.
where bash >nul 2>&1
@@ -1792,13 +1862,27 @@ if errorlevel 1 (
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
if not exist "FerretDB\build.sh" (
echo FerretDB\build.sh is missing. Clone it first:
echo git clone git@github.com:wekan/FerretDB
echo ^(or run Tests - All databases, which clones it before building.^)
if not exist "%REPO%\.tools\FerretDB\build.sh" (
where git >nul 2>&1
if errorlevel 1 (
echo ERROR: git was not found, so .tools\FerretDB cannot be cloned.
echo Install Git for Windows, or clone it by hand:
echo git clone git@github.com:wekan/FerretDB .tools/FerretDB
goto end
)
if not exist "%REPO%\.tools" md "%REPO%\.tools"
echo ==^> FerretDB is not in .tools\ yet; cloning wekan/FerretDB
git clone git@github.com:wekan/FerretDB "%REPO%\.tools\FerretDB"
if errorlevel 1 (
echo ==^> SSH clone failed ^(no key for github.com?^); trying HTTPS.
git clone https://github.com/wekan/FerretDB "%REPO%\.tools\FerretDB"
)
)
if not exist "%REPO%\.tools\FerretDB\build.sh" (
echo ERROR: .tools\FerretDB\build.sh is still missing after cloning.
goto end
)
bash -c "cd FerretDB && ./build.sh test-all"
bash -c "cd .tools/FerretDB && ./build.sh test-all"
goto end
REM ===========================================================================
+315 -105
View File
@@ -83,6 +83,52 @@ fi
export WEKAN_LOG_ROOT
mkdir -p "$WEKAN_LOG_ROOT"
# ── Companion repositories: wekan/.tools/<name> ──────────────────────────────
#
# The repos WeKan needs to run everything - wekan/FerretDB for the database
# conformance run and FerretDB's own suites, and the patch repos - are separate
# git repositories, not part of this one. They live in .tools/ inside this
# checkout, which .gitignore and .meteorignore already exclude, so a clone of
# them can never end up in a commit or in a Meteor rebuild.
#
# They used to be cloned as subdirectories of the repo root (wekan/FerretDB),
# each needing its own ignore entry; one ignored directory holds all of them.
# The repo directory, from the script's own path rather than the caller's cwd:
# ./build.sh is normally run from the checkout, but a clone must not land in
# whatever directory somebody happened to be in.
WEKAN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEKAN_TOOLS_DIR="$WEKAN_DIR/.tools"
export WEKAN_DIR WEKAN_TOOLS_DIR
# ensure_tool_repo <name> [git-url] - the path to .tools/<name>, cloning it if it
# is not there yet. Prints the path on stdout; everything else goes to stderr, so
# a caller can do `dir="$(ensure_tool_repo FerretDB)"`.
#
# SSH first, HTTPS second: a maintainer has a key and wants to push, and anyone
# else still gets a working clone. Returns non-zero when neither works, so the
# caller can say what it cannot do rather than cd into nothing.
function ensure_tool_repo(){
local name="$1"
local url="${2:-git@github.com:wekan/$name}"
local https_url="${url/git@github.com:/https://github.com/}"
local dir="$WEKAN_TOOLS_DIR/$name"
if [ -d "$dir/.git" ]; then
printf '%s\n' "$dir"
return 0
fi
mkdir -p "$WEKAN_TOOLS_DIR" || { echo "ERROR: cannot create $WEKAN_TOOLS_DIR" >&2; return 1; }
echo "==> $name is not in .tools/ yet; cloning $url" >&2
if ! git clone "$url" "$dir" >&2; then
echo "==> SSH clone failed (no key for github.com?); trying HTTPS." >&2
if ! git clone "$https_url" "$dir" >&2; then
echo "ERROR: could not clone $name from $url or $https_url" >&2
return 1
fi
fi
printf '%s\n' "$dir"
}
function pause(){
read -p "$*"
}
@@ -1126,17 +1172,67 @@ function run_all_tests(){
return "$FAILED"
}
# floating_promises_checks — the guard's CHECKS, with nothing that installs or
# edits anything.
#
# The menu option of the same name may install ripgrep and the @typescript-eslint
# packages and write the rule into .eslintrc.json: that is a person choosing to
# set the guard up, interactively. EVERYTHING runs unattended, must not call
# sudo, and must not modify the tree it is testing - a run that edits
# .eslintrc.json is no longer testing the commit it started from. So the checks
# themselves live here, are pure reads, and are what both callers run:
#
# * the no-floating-promises rule is configured in .eslintrc.json;
# * every Authentication.checkBoardAccess / checkBoardWriteAccess in
# server/models is awaited. An unawaited permission check returns a pending
# promise, and a promise is truthy, so the call site passes a check that
# never ran - which is a permission bug that looks like working code.
#
# grep, not ripgrep: this must work on a machine where rg is not installed and
# where nothing may be installed to make it work.
function floating_promises_checks(){
local rc=0 calls
echo "Checking that @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
if grep -q '"@typescript-eslint/no-floating-promises"' .eslintrc.json 2>/dev/null; then
echo "OK: the rule is configured."
else
echo "FAIL: @typescript-eslint/no-floating-promises is NOT configured in .eslintrc.json."
echo " Tests -> 'Floating-promises guard' installs the packages and writes it."
rc=1
fi
echo
echo "Scanning server/models for unawaited Authentication.checkBoardAccess / checkBoardWriteAccess"
calls="$(grep -RInE 'Authentication\.(checkBoardAccess|checkBoardWriteAccess)\(' server/models 2>/dev/null \
| grep -vE 'await Authentication\.(checkBoardAccess|checkBoardWriteAccess)\(' || true)"
if [ -n "$calls" ]; then
echo "FAIL: these board auth checks are not awaited:"
printf '%s\n' "$calls"
rc=1
else
echo "OK: every board auth check in server/models is awaited."
fi
return $rc
}
# run_everything — every test WeKan and FerretDB have, one after another.
#
# Three stages, sequential on purpose: the WeKan suite (which builds a fresh
# bundle and starts a server), then the database conformance run (which builds
# FerretDB from source and runs one query catalogue against every database with an
# image for this CPU), then FerretDB's own tests (unit, vet, integration). They
# share one log/<datetime>/ directory, so "the newest test logs" is one place.
# Four stages, sequential on purpose: the static guard (seconds, so a broken
# permission check is reported before an hour of browsers), then the WeKan suite
# (which builds a fresh bundle and starts a server, and runs mocha, the node
# suites, the import regression, the node E2E and all three browsers), then the
# database conformance run (which builds FerretDB from source and runs one query
# catalogue against every database with an image for this CPU), then FerretDB's
# own tests (unit, vet - which includes its no-LFS guard - and integration).
# They share one log/<datetime>/ directory, so "the newest test logs" is one
# place.
#
# FerretDB is expected to be a subdirectory of this repo. releases/db-conformance.sh
# clones it if it is not there, so the only thing this checks is that the clone
# worked before handing it the FerretDB stage.
# Everything in the Tests menu that is a TEST is in here. What is deliberately
# not: "Install Playwright browsers" (setup, and the browser stages install what
# they need), and "Count tests by category" (a report, not a check).
#
# FerretDB lives in .tools/FerretDB, cloned on demand by ensure_tool_repo (and by
# releases/db-conformance.sh, which needs it a stage earlier). .tools/ is ignored
# by git and by Meteor, so a companion repo cannot reach a commit or a rebuild.
function run_everything(){
local RUN_TS RUN_LOGDIR FAILED=0
RUN_TS="$(date '+%Y-%m-%d_%H-%M-%S')"
@@ -1147,20 +1243,30 @@ function run_everything(){
echo "=============================================================================="
echo "EVERYTHING, one stage at a time. Logs: $RUN_LOGDIR/"
echo " 1/3 WeKan's own tests (builds the bundle, starts a server)"
echo " 2/3 Database conformance (builds FerretDB, every database this CPU runs)"
echo " 3/3 FerretDB's own tests (unit, vet, integration)"
echo " 1/4 Floating-promises guard (seconds: the rule, and unawaited auth checks)"
echo " 2/4 WeKan's own tests (builds the bundle, starts a server; mocha, the"
echo " node suites, import, node E2E, three browsers)"
echo " 3/4 Database conformance (builds FerretDB, every database this CPU runs)"
echo " 4/4 FerretDB's own tests (unit, vet, integration)"
echo "This takes a long time. Nothing runs concurrently, so a failure is readable."
echo "=============================================================================="
echo
local wekan_rc=0 conf_rc=0 ferret_rc=0
local guard_rc=0 wekan_rc=0 conf_rc=0 ferret_rc=0
echo "### 1/3 WeKan tests ###########################################################"
echo "### 1/4 Floating-promises guard ###############################################"
# Checks only - no installing, no editing of .eslintrc.json. It takes seconds,
# so it runs first: an unawaited permission check should not be reported after
# an hour of browser tests.
floating_promises_checks 2>&1 | tee "$RUN_LOGDIR/wekan-floating-promises.log"
guard_rc=${PIPESTATUS[0]}
echo
echo "### 2/4 WeKan tests ###########################################################"
run_all_tests sequential || wekan_rc=$?
echo
echo "### 2/3 Database conformance ##################################################"
echo "### 3/4 Database conformance ##################################################"
if [ -x ./releases/db-conformance.sh ]; then
./releases/db-conformance.sh || conf_rc=$?
else
@@ -1168,24 +1274,29 @@ function run_everything(){
fi
echo
echo "### 3/3 FerretDB tests ########################################################"
if [ -x FerretDB/build.sh ]; then
( cd FerretDB && WEKAN_LOGDIR="$RUN_LOGDIR" ./build.sh test-all ) || ferret_rc=$?
echo "### 4/4 FerretDB tests ########################################################"
# .tools/FerretDB, cloned here if this is the first run on this machine -
# stage 3 usually got there first, but EVERYTHING must not depend on the
# order of its own stages.
ferret_dir="$(ensure_tool_repo FerretDB)" || ferret_dir=""
if [ -n "$ferret_dir" ] && [ -x "$ferret_dir/build.sh" ]; then
( cd "$ferret_dir" && WEKAN_LOGDIR="$RUN_LOGDIR" ./build.sh test-all ) || ferret_rc=$?
else
echo "ERROR: FerretDB/build.sh is missing - db-conformance.sh clones wekan/FerretDB"
echo " into this repo; run stage 2 first, or clone it by hand."
echo "ERROR: .tools/FerretDB/build.sh is missing and could not be cloned."
echo " Clone it by hand: git clone git@github.com:wekan/FerretDB .tools/FerretDB"
ferret_rc=1
fi
echo
echo "=============================== EVERYTHING ==================================="
printf ' %-6s %s\n' "$([ $wekan_rc -eq 0 ] && echo PASS || echo FAIL)" "WeKan tests (sequential)"
printf ' %-6s %s\n' "$([ $guard_rc -eq 0 ] && echo PASS || echo FAIL)" "Floating-promises guard (rule + unawaited board auth checks)"
printf ' %-6s %s\n' "$([ $wekan_rc -eq 0 ] && echo PASS || echo FAIL)" "WeKan tests (mocha, node suites, import, E2E, three browsers)"
printf ' %-6s %s\n' "$([ $conf_rc -eq 0 ] && echo PASS || echo FAIL)" "Database conformance (all databases for this CPU)"
printf ' %-6s %s\n' "$([ $ferret_rc -eq 0 ] && echo PASS || echo FAIL)" "FerretDB tests (unit, vet, integration)"
echo "=============================================================================="
echo "All logs: $RUN_LOGDIR/"
unset WEKAN_LOGDIR
[ $wekan_rc -eq 0 ] && [ $conf_rc -eq 0 ] && [ $ferret_rc -eq 0 ] || FAILED=1
[ $guard_rc -eq 0 ] && [ $wekan_rc -eq 0 ] && [ $conf_rc -eq 0 ] && [ $ferret_rc -eq 0 ] || FAILED=1
if [ "$FAILED" -eq 0 ]; then echo "RESULT: EVERYTHING passed."; else echo "RESULT: something FAILED - see the stage summaries above."; fi
return "$FAILED"
}
@@ -1526,63 +1637,161 @@ function ask_dev_url(){
esac
}
# ── Update git: make the working copy current in one step ────────────────────
# ── Keeping the checkout in step with origin ─────────────────────────────────
# Fetch + rebase the current branch onto its upstream, then repoint any CHANGELOG
# commit links the rebase made stale (same shared script release-all.sh uses), and
# show the resulting status. One menu action instead of the fetch/pull/rebase/
# hash-fix/status dance by hand.
function update_git(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 0; }
local branch upstream here
here="$(cd "$(dirname "$0")" && pwd)"
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
echo "== Update git — branch: ${branch:-?} =="
# ── git pull / git push, doing the whole job ─────────────────────────────────
#
# These replace the old "Update git", which did a fetch+rebase, ran the CHANGELOG
# hash repair, printed `git status` and left the rest to the reader. The rest is
# where the mistakes were: a rebase rewrites local commits, so the CHANGELOG links
# written before it point at commits that no longer exist; the repair fixes the
# file but commits nothing, so the next push carries a dirty tree; and a push
# rejected as non-fast-forward leaves a repo that needs a pull the caller has to
# know to run.
#
# So: one action per direction, each ending in a state that is either correct or
# unchanged. Conflicts are the one thing a script must not paper over - a rebase
# that stops half-applied IS the wrong state - so on conflict the rebase is
# aborted, the working tree is exactly what it was, and the message says what to
# do by hand.
#
# Nothing here force-pushes, ever.
# Do not surprise anyone: a dirty tree is stashed by --autostash below, but say
# so first and let the user opt out.
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo "Working tree has uncommitted changes:"
git status --short
printf "They will be auto-stashed for the rebase and re-applied after. Continue? [y/N] "
read -r ans
case "$ans" in y|Y|yes|YES) : ;; *) echo "Aborted (nothing changed)."; return 0 ;; esac
fi
echo "--- git fetch --all --prune ---"
git fetch --all --prune || { echo "fetch failed - check network/remote and retry."; return 0; }
upstream="origin/$branch"
if git rev-parse --verify --quiet "$upstream" >/dev/null 2>&1; then
echo "--- git pull --rebase --autostash origin $branch ---"
if ! git pull --rebase --autostash origin "$branch"; then
echo
echo "Rebase stopped (conflicts or autostash conflict). Resolve them, then:"
echo " git rebase --continue # or: git rebase --abort"
echo "and run this option again to finish the hash fix + status."
return 0
fi
else
echo "No upstream '$upstream' - skipping rebase (nothing to pull for this branch)."
fi
# The rebase may have rewritten commits the CHANGELOG links to; repoint the
# stale links in the unreleased section (shared with releases/release-all.sh).
# A link is only repointed when NO ref in this clone reaches its commit, so a
# link into an old release tag - which GitHub serves fine - is left alone.
echo "--- Fixing CHANGELOG commit links (releases/fix-changelog-hashes.sh) ---"
bash "$here/releases/fix-changelog-hashes.sh" || true
echo " (whole file, including released sections:"
echo " bash releases/fix-changelog-hashes.sh --all-sections [--dry-run])"
echo "--- git status ---"
git status
# The CHANGELOG links after history moved. The repair only repoints a link whose
# commit is reachable from NO ref in this clone (a link into an old release tag
# is fine and is left alone), so running it when nothing moved is a no-op.
# Returns 0 when the file was changed and committed, 1 when there was nothing to do.
function git_fix_changelog_links(){
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
[ -f "$script" ] || return 1
bash "$script" || true
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
echo
echo "NOTE: CHANGELOG.md was updated by the hash fix above."
echo " Review 'git diff CHANGELOG.md' and commit it (this script never commits for you)."
git add CHANGELOG.md
git commit -q -m "CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.
Thanks to xet7 !"
echo "==> CHANGELOG commit links repointed and committed."
return 0
fi
return 1
}
# git pull: fast-forward when that is all it takes, rebase when the branch has
# diverged, and never end half-way through either.
function git_pull(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
local branch upstream before ahead behind
branch="$(git rev-parse --abbrev-ref HEAD)"
upstream="origin/$branch"
echo "== git pull - branch $branch =="
# The exact commit to return to if anything goes wrong.
before="$(git rev-parse HEAD)"
if [ -n "$(git status --porcelain)" ]; then
echo "==> Working tree is not clean; the changes are stashed for the pull and"
echo " re-applied afterwards (git's own --autostash)."
fi
echo "--- git fetch origin $branch ---"
git fetch origin "$branch" || { echo "ERROR: fetch failed - network or remote. Nothing changed."; return 1; }
git rev-parse --verify --quiet "$upstream" >/dev/null || {
echo "No $upstream yet - nothing to pull. Push first to create it."; return 0; }
behind="$(git rev-list --count HEAD.."$upstream")"
ahead="$(git rev-list --count "$upstream"..HEAD)"
echo " $ahead commit(s) here that origin does not have, $behind the other way."
if [ "$behind" -eq 0 ]; then
echo "==> Already up to date with $upstream."
elif [ "$ahead" -eq 0 ]; then
# Nothing of ours to replay: a fast-forward moves the branch pointer and
# rewrites no commit, so no link can go stale.
echo "--- fast-forward (no local commits to replay) ---"
git merge --ff-only "$upstream" || { echo "ERROR: fast-forward failed. Nothing changed."; return 1; }
else
echo "--- rebase: replaying $ahead local commit(s) onto $upstream ---"
echo " This gives them NEW hashes, which is why the CHANGELOG links are"
echo " repaired straight after."
if ! git -c rebase.autoStash=true rebase "$upstream"; then
git rebase --abort 2>/dev/null
git stash list >/dev/null 2>&1
echo
echo "ERROR: the rebase hit a conflict, so it was ABORTED - this repo is"
echo " exactly as it was before ($(git rev-parse --short "$before"))."
echo " Resolve it by hand:"
echo " git rebase $upstream # then fix the conflicts"
echo " git rebase --continue # or: git rebase --abort"
echo " and run this option again afterwards."
return 1
fi
fi
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve; nothing to repoint."
echo "--- git status ---"
git status --short --branch
return 0
}
# git push: make sure what is about to be published is correct first, and turn
# the one rejection that has an obvious answer into that answer.
function git_push(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
local branch ahead
branch="$(git rev-parse --abbrev-ref HEAD)"
echo "== git push - branch $branch =="
if [ -n "$(git status --porcelain)" ]; then
echo "==> NOTE: there are uncommitted changes; a push publishes commits only."
git status --short
fi
# Before publishing, not after: a stale link that reaches GitHub 404s for
# everyone who reads the release notes.
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve."
git fetch origin "$branch" >/dev/null 2>&1 || true
if git rev-parse --verify --quiet "origin/$branch" >/dev/null; then
ahead="$(git rev-list --count "origin/$branch"..HEAD)"
[ "$ahead" -eq 0 ] && { echo "==> Nothing to push; origin/$branch is already at this commit."; return 0; }
echo " $ahead commit(s) to push."
fi
echo "--- git push origin $branch ---"
if git push origin "$branch"; then
echo "==> Pushed."
return 0
fi
# The common rejection: origin moved while this was being written. That has
# one right answer - pull, then push again - so do it once, rather than
# printing the advice and leaving the caller to it. Once only: a second
# rejection is something else, and retrying a loop is not a fix.
echo
echo "==> Push was rejected. Pulling first, then trying once more."
git_pull || { echo "ERROR: the pull did not finish, so nothing was pushed."; return 1; }
echo "--- git push origin $branch (retry) ---"
if git push origin "$branch"; then
echo "==> Pushed."
return 0
fi
echo
echo "ERROR: still rejected. This is NOT a case to force - force-pushing a"
echo " branch somebody has pulled rewrites their history too. Read what"
echo " git said above; if origin has commits this clone should keep,"
echo " run this option's pull first and look at 'git log --oneline'."
return 1
}
echo
PS3='Please enter your choice: '
@@ -1664,6 +1873,7 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Snap|Release the snap for one version|releases/release-snap.sh|WeKan version, e.g. 10.50||"
"Snap|List the newest Snap Store revisions|releases/snap-store-revisions.sh|||"
"Snap|Release one store revision to edge, beta and candidate|releases/snap-store-release-revision-to-channels.sh|Snap Store revision number||"
"Snap|Release every snap, every architecture, to all four channels|releases/snap-release-all-channels.sh|Version, or empty for the newest||snap-release-all-channels"
"Snap|Switch the installed snap to the edge channel|releases/snap-edge.sh|||"
"Snap|Switch the installed snap to the stable channel|releases/snap-stable.sh|||"
"Snap|snapcraft help topics|releases/snapcraft-help.sh|||"
@@ -1702,6 +1912,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Translations|Report English strings that regressed|releases/translations/report-english-regressions.mjs|||"
"Translations|Prove a pull keeps human translations (no network)|releases/translations/verify-human-preference.mjs|||"
"Translations|Merge a finished pull by hand|releases/translations/merge-translations.mjs|||"
"Git and repo|git pull - fetch, fast-forward or rebase, repoint moved CHANGELOG links|!git_pull|||git-pull"
"Git and repo|git push - verify the CHANGELOG links, push, pull-and-retry once if origin moved|!git_push|||git-push"
"Git and repo|Commit with the editor open for a multi-line message|releases/commit.sh|||"
"Git and repo|Add everything, then revert it again|!git restore --staged|Path to unstage||git-add-revert"
"Git and repo|Delete a branch, locally and on the remote|releases/delete-branch-local-and-remote.sh|Branch name||"
@@ -1910,7 +2122,8 @@ while [ -z "$opt" ]; do
choose "Setup" \
"Install dependencies|Install WeKan dependencies" \
"Build WeKan|Build WeKan" \
"Update git|Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status" ;;
"git pull|git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts" \
"git push|git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile" ;;
"Dev server")
choose "Dev server" \
"localhost:3000|Run Meteor for dev on http://localhost:3000" \
@@ -1923,7 +2136,7 @@ while [ -z "$opt" ]; do
"Kill all dev servers|Kill all dev servers (free ports 3000/3001/3100/3101/4000/4001/8080)" ;;
"Tests")
choose "Tests" \
"EVERYTHING (sequential): WeKan tests + all databases + FerretDB tests|Run EVERYTHING sequentially: WeKan's own tests, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/" \
"EVERYTHING (sequential): the guard + WeKan tests + all databases + FerretDB tests|Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/" \
"WeKan's own tests only, parallel|Run WeKan's own tests in parallel on http://localhost:3000: Mocha, the node unit suites, import regression, node E2E and all three browsers, concurrently. No database conformance and no FerretDB tests - logs in log/<datetime>/" \
"WeKan's own tests only, sequential|Run WeKan's own tests sequentially on http://localhost:3000: Mocha, the node unit suites, import regression, node E2E and all three browsers, one job at a time. No database conformance and no FerretDB tests - logs in log/<datetime>/" \
"Mocha (server-side)|Test Mocha unit + security + API-logic tests (server-side only, no browser)" \
@@ -2052,8 +2265,13 @@ for _once in 1; do
break
;;
"Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status")
update_git
"git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts")
git_pull
break
;;
"git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile")
git_push
break
;;
@@ -2261,39 +2479,15 @@ for _once in 1; do
echo "Ensuring .eslintrc.json includes @typescript-eslint plugin and no-floating-promises rule"
node -e "const fs=require('fs');const p='.eslintrc.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c.plugins=Array.isArray(c.plugins)?c.plugins:[];if(!c.plugins.includes('@typescript-eslint'))c.plugins.push('@typescript-eslint');c.rules=c.rules||{};c.rules['@typescript-eslint/no-floating-promises']='error';fs.writeFileSync(p,JSON.stringify(c,null,2)+'\\n');"
echo "Checking whether @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
if command -v rg >/dev/null 2>&1; then
RULE_CHECK_CMD='rg -n'
else
RULE_CHECK_CMD='grep -nE'
fi
if $RULE_CHECK_CMD '"@typescript-eslint/no-floating-promises"' .eslintrc.json >/dev/null 2>&1; then
echo "OK: Rule @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
else
echo "WARNING: Rule @typescript-eslint/no-floating-promises is NOT configured in .eslintrc.json"
echo "Suggested: add @typescript-eslint/eslint-plugin and set '@typescript-eslint/no-floating-promises': 'error'"
fi
echo "Quick note: @typescript-eslint/no-floating-promises is a type-aware rule and may require further parser/project setup for full enforcement in all files."
echo
echo "Scanning for unawaited Authentication.checkBoardAccess/checkBoardWriteAccess in server/models"
AUTH_CALL_PATTERN='Authentication\.checkBoardAccess\(|Authentication\.checkBoardWriteAccess\('
AUTH_AWAIT_PATTERN='await Authentication\.checkBoardAccess\(|await Authentication\.checkBoardWriteAccess\('
if command -v rg >/dev/null 2>&1; then
if rg -n "$AUTH_CALL_PATTERN" server/models | rg -v "$AUTH_AWAIT_PATTERN"; then
echo "WARNING: Found possible unawaited board auth checks above"
else
echo "OK: No unawaited board auth checks found"
fi
else
if grep -RInE "$AUTH_CALL_PATTERN" server/models | grep -vE "$AUTH_AWAIT_PATTERN"; then
echo "WARNING: Found possible unawaited board auth checks above"
else
echo "OK: No unawaited board auth checks found"
fi
fi
# The checks themselves - the same function EVERYTHING runs, so the
# menu option and the whole-run stage can never drift apart. Above
# this line is the setting-up half, which only a person asking for
# this option gets: it installs packages and writes the rule into
# .eslintrc.json, and a test run must do neither.
floating_promises_checks
} 2>&1 | tee "$LOG"
break
;;
@@ -2320,7 +2514,7 @@ for _once in 1; do
fi
;;
"Run EVERYTHING sequentially: WeKan's own tests, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
"Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
run_everything
;;
@@ -2410,5 +2604,21 @@ for _once in 1; do
break
;;
# No arm matched. $opt is the menu entry's FULL DESCRIPTION, written twice -
# once in the choose() list far above, once as the arm here - so an edit to
# the menu text that misses the arm leaves an option that matches nothing.
# Without this, the case simply falls through, the `for _once` loop ends and
# the script EXITS: the option looks like it worked, printed nothing and ran
# nothing. That is what renaming "EVERYTHING (sequential)" did to it.
# tests/buildScriptParity.test.cjs fails on the mismatch itself; this is what
# the person in front of the menu sees if one ever gets past it.
*)
echo "build.sh: no handler for this menu option - that is a bug in build.sh." >&2
echo " The menu entry's description and its case arm must be the SAME string:" >&2
echo " $opt" >&2
echo " Nothing was run. Please report it, or run the same thing from the command line:" >&2
echo " ./build.sh --list" >&2
;;
esac
done
+5
View File
@@ -249,6 +249,11 @@
text-align: center;
}
.minicard .minicard-title {
/* #6465: one step below the list heading above it (16px), and above the
"Add card" affordance below (13px). It used to inherit the 16px document
size, which made a card title exactly as loud as the heading of the column
holding it. See the note on .list-header-name in lists/list.css. */
font-size: 14px;
margin-inline-end: 21px;
/* #6444: isolate each title's bidirectional context. The title is a
dir="auto" .viewer; under a global dir="rtl" document, non-isolated
+51 -3
View File
@@ -119,6 +119,7 @@ body.list-resizing-active * {
}
/* More aggressive override for any container that might interfere */
.js-lists .list.list-resizing,
.js-swimlane .list.list-resizing,
.dragscroll .list.list-resizing,
[id^="swimlane-"] .list.list-resizing {
@@ -140,6 +141,18 @@ body.list-resizing-active * {
property, and without :not() this !important rule overrode the 30px collapsed
width, so collapsing a list that had a custom width left it full width.
See https://github.com/wekan/wekan/issues/5892 */
/* #6573: `.js-lists` is the selector that has to be here, and `.dragscroll` is
the one that cannot be relied on. In LISTS view the container is
`.swimlane.list-group.js-lists.dragscroll` - no `js-swimlane` class and no
`swimlane-<id>` id - so `.dragscroll` was the only one of the three that
matched, and the width of every list hung on it. Starting a card drag calls
suspendBoardDragscroll(), which REMOVES the dragscroll class from the board so
the same pointer cannot pan and drag at once (#6558); this rule stopped
matching with it, every list fell back to the flex rules below, and the whole
board re-flowed to fill the window the instant a card was picked up - then
snapped back on drop, when the class returned. Both views' containers carry
`js-lists`, and nothing removes it. */
.js-lists .list[style*="--list-width"]:not(.list-collapsed),
.js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
.dragscroll .list[style*="--list-width"]:not(.list-collapsed),
[id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
@@ -163,6 +176,7 @@ body.list-resizing-active * {
in mobile mode on a desktop browser the inline width was there and the lists stayed
at their desktop width while everything else was in mobile layout. Same three
selectors, prefixed with body.mobile-mode so this one is the more specific. */
body.mobile-mode .js-lists .list[style*="--list-width"]:not(.list-collapsed),
body.mobile-mode .js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
body.mobile-mode .dragscroll .list[style*="--list-width"]:not(.list-collapsed),
body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
@@ -454,11 +468,20 @@ form.inlined-form .list-composer {
}
.list-header .list-header-name {
display: block;
font-size: 14px;
/* #6465: the type scale of a list, from the outside in. A list heading is to
its cards what an h2 is to its paragraphs, and it had ended up SMALLER than
them: the heading was 14px while a minicard title and the "Add card" link
both inherited the 16px document size, so the column looked like it was
labelled in fine print and the affordance to add a card shouted louder than
the cards. The scale is now heading 16 > card title 14 >= add-card link 13,
kept in that order by tests/listTypeScale.test.cjs. Mobile keeps its own
larger sizes (the !important rules further down) - a phone is read at arm's
length. */
font-size: 16px;
line-height: 1.2;
margin: 0;
font-weight: bold;
min-height: 14px;
min-height: 16px;
min-width: 56px;
overflow-wrap: break-word;
word-wrap: break-word;
@@ -732,6 +755,23 @@ form.inlined-form .list-composer {
.list-header .highlight {
color: #ce1414;
}
/* #6465: the WIP counter belongs on the title line, and the header of a list
that has one must be as tall as its neighbours - the report showed "(2/5)"
dropped onto a second line under the title, making that one column's header
taller than the lists either side of it.
The counter is already inside the h2, right after the title (listHeader.jade),
but the title is a `+viewer`, and `.viewer` is display: block with a 22px
min-height: a block in the middle of a heading pushes everything after it onto
the next line and reserves a band under it. Inline, the counter stays where the
markup already puts it. Same treatment the quick-access header and the table
view give their viewers. */
.list-header .list-header-name .viewer,
.list-header .list-header-name .viewer p {
display: inline;
min-height: 0;
margin: 0;
}
.list-header .cardCount {
color: #8c8c8c;
font-size: 12px;
@@ -784,12 +824,20 @@ form.inlined-form .list-composer {
margin-bottom: 9px;
}
.list-body .minicards .add-controls button {
min-height: 50px;
/* Was min-height: 50px - taller than a minicard is for its title, so the
"Add" button was the biggest thing on the board while typing. 34px is a
comfortable touch target that still reads as a control rather than a slab. */
min-height: 34px;
font-size: 14px;
}
.list-body .open-minicard-composer {
border-radius: 2px;
color: #8c8c8c;
display: block;
/* Smaller than a card title, because it is not a card: it is the affordance
for making one. At the inherited 16px it was the largest text in the list
body, level with the board's real content. */
font-size: 13px;
padding: 7px 10px;
position: relative;
text-decoration: none;
@@ -30,6 +30,8 @@ template(name="adminReports")
+eventStreamReport(stream="cpu")
else if showDatabase.get
+eventStreamReport(stream="database")
else if showIntegrity.get
+eventStreamReport(stream="integrity")
else if tablePageData
+tablePage(tablePageData)
+31 -2
View File
@@ -110,6 +110,7 @@ Template.adminReports.onCreated(function () {
this.showTests = new ReactiveVar(false);
this.showCpu = new ReactiveVar(false);
this.showDatabase = new ReactiveVar(false);
this.showIntegrity = new ReactiveVar(false);
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
@@ -315,6 +316,10 @@ const PROBLEMS_MENU = [
// What the database itself said, classified: which database type, what it
// means and what to do (server/lib/databaseProblems.js).
{ id: 'report-database', icon: 'fa-database', labelKey: 'databaseReportTitle' },
// What the FILESYSTEM said: a stored file that is no longer the file WeKan
// stored, and whether this server stopped cleanly
// (docs/Security/Remediation/WeKan.md §13).
{ id: 'report-integrity', icon: 'fa-fingerprint', labelKey: 'integrityReportTitle' },
];
Template.adminReports.helpers({
@@ -497,6 +502,7 @@ function openReportPane(tmpl, targetID) {
tmpl.showTests.set(false);
tmpl.showCpu.set(false);
tmpl.showDatabase.set(false);
tmpl.showIntegrity.set(false);
if (tmpl.subscription) {
tmpl.subscription.stop();
}
@@ -533,6 +539,9 @@ function openReportPane(tmpl, targetID) {
} else if ('report-database' === targetID) {
tmpl.showDatabase.set(true);
tmpl.loading.set(false);
} else if ('report-integrity' === targetID) {
tmpl.showIntegrity.set(true);
tmpl.loading.set(false);
} else if ('report-broken' === targetID) {
// A report like the others now: same controls, same paging, same loader.
tmpl.brokenPage.set(1);
@@ -835,8 +844,28 @@ const EVENT_STREAM_COLUMNS = [
{ labelKey: 'event-severity', value: r => r.severity, data: r => r.severity },
{ labelKey: 'event-action', value: r => r.action || r.type },
{ labelKey: 'event-source', value: r => r.source },
// The user who triggered the event (e.g. who uploaded a sanitized file).
{ labelKey: 'username', value: r => userName(r.userId), userId: r => r.userId },
// WHO tried it. The stored `username` wins over looking the account up: it is
// what the account was CALLED when the event happened, so a later rename does
// not rewrite history and a deleted account does not erase it. The lookup
// stays as the fallback for the older events that predate the stored field.
{
labelKey: 'username',
value: r => r.username || userName(r.userId),
userId: r => r.userId,
},
// FROM WHERE. Resolved with the same spoofing-safe rule as the login throttle -
// X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - so this
// column cannot be written by sending a header.
{ labelKey: 'event-ip', nowrap: true, value: r => r.ip || '' },
// HOW MANY attempts this row stands for. A canary counts repeats inside its
// window rather than writing one row each, so "1" is an ordinary event and a
// larger number is a burst that was deliberately not written out in full
// (docs/Security/Remediation/WeKan.md §12).
{
labelKey: 'event-attempts',
nowrap: true,
value: r => (r.count && r.count > 1 ? String(r.count) : ''),
},
// The `database` stream's detail is WeKan's reading of the error - what it means
// and what to do - and it is worth little without the sentence the database
// itself produced, which is the one thing an admin can search for or paste into
@@ -238,3 +238,21 @@
line-height: 1.4;
margin-top: 2px;
}
/* #6465: "Please move the button down. This is too close" - Save sat directly
under the 1..28 day buttons of the monthly schedule, close enough to be hit
while aiming for a date. The gap is on the form-group that FOLLOWS the day
grid, so it applies wherever that grid is used and nowhere else. */
.attachments-content .schedule-day-buttons + .form-group,
.schedule-day-buttons + .form-group {
margin-top: 18px;
}
/* #6465: "The alignment is out of place" - the move button and the labelled
selects beside it are one row (align-items: flex-end), so a margin of the
button's own would offset it from the controls it acts on. Zero it, and let
the row do the aligning. */
.attachments-content .move-storage-form > button {
margin: 0;
align-self: flex-end;
}
+16
View File
@@ -264,3 +264,19 @@ table:not(.table-page-table) td:first-child {
(docs/Design/Page/Table.md), so its controls row, pagination and table are
styled there - and its pager is themed by paginationControls.css like every
other pager. Nothing pane-specific is needed here any more. */
/* #6465: the "all users" select-all in a table heading. One row, box after the
word, with a gap that cannot collapse - the two used to be loose inline nodes
in a centred heading cell, and at the column's width the box sat on top of the
text. inline-flex keeps the heading's own centring intact. */
.select-all-user {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
white-space: nowrap;
}
.select-all-user input.allUserChkBox {
margin: 0;
flex: none;
}
+8 -2
View File
@@ -101,8 +101,14 @@ template(name="peopleGeneral")
+tablePage(this)
template(name="selectAllUser")
| {{_ 'dueCardsViewChange-choice-all'}}
input.allUserChkBox(type="checkbox", id="chkSelectAll")
//- #6465: label AND box in one <label>, so the box cannot land on top of the
//- word. They were two loose inline nodes in a table heading: the heading
//- centres its content, the checkbox is replaced-content with its own
//- baseline, and at the width the column happens to have, the box overlapped
//- the text. A label also makes the word itself click the box.
label.select-all-user(for="chkSelectAll")
span {{_ 'dueCardsViewChange-choice-all'}}
input.allUserChkBox(type="checkbox", id="chkSelectAll")
template(name="domainGeneral")
+tablePage(tablePageData)
+8 -2
View File
@@ -266,10 +266,16 @@ template(name="changeSettingsPopup")
option(value="#{day.value}") #{day.name}
label.bold.clear
| {{_ 'card-settings'}}
//- #6465: the checkbox and its label are ONE row, built the way every
//- other checkbox row in this file is (see the board list above):
//- a .flex anchor, the .materialCheckBox, then the text. It used to
//- carry a `b &nbsp;` spacer and `.left` (float: inline-start) as
//- well - a float inside a flex container is ignored, so the spacer
//- became a stray flex item and the box drifted away from the text it
//- belongs to, up beside the "Card settings" heading above.
ul#cards.card-description-rescued
a.flex.js-rescue-card-description(title="{{_ 'rescue-card-description'}}")
b &nbsp;
#rescue-card-description.materialCheckBox.left(class="{{#if rescueCardDescription}}is-checked{{/if}}", value=rescueCardDescription)
#rescue-card-description.materialCheckBox(class="{{#if rescueCardDescription}}is-checked{{/if}}", value=rescueCardDescription)
span {{_ 'rescue-card-description'}}
//- "Save", and themed. It said "Apply" while every other settings form in
//- WeKan says Save, and it carried no `.primary` class - so it fell to the
-1
View File
@@ -9,7 +9,6 @@ import './responsive.tests';
import './boardNumbering.tests';
import './subtaskStatus.tests';
import './subtaskViewGuard.tests';
import './boardTriggersClass.tests';
import './calendarFirstDay.tests';
import './mentionEnterGuard.tests';
import './subtaskViewTarget.tests';
+8
View File
@@ -725,6 +725,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
+8
View File
@@ -727,6 +727,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
@@ -714,6 +714,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
+8
View File
@@ -731,6 +731,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
@@ -621,6 +621,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
+8
View File
@@ -855,6 +855,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
+8
View File
@@ -685,6 +685,14 @@ services:
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
# unset when users and groups share one subtree: the group searches then
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
# keeps them apart - ou=groups beside ou=people is a common layout - or
# every group search runs under the USER base, finds nothing, and
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
+1
View File
@@ -77,6 +77,7 @@ The bare `/admin/settings` still resolves; it *redirects* to
| `/admin/problems/cards` | Cards Report | `report-cards` |
| `/admin/problems/recovery` | Recovery | `report-recovery` |
| `/admin/problems/database` | Database problems | `report-database` |
| `/admin/problems/integrity` | Filesystem integrity | `report-integrity` |
Two panes are called Security, and they are different things: the *settings*
pane and the *report*. `security` is the settings one, beside `notifications`
+7
View File
@@ -341,6 +341,13 @@ services:
# LDAP_BASEDN : The base DN for the LDAP Tree
# example : LDAP_BASEDN=ou=user,dc=example,dc=org
- LDAP_BASEDN=ou=prod,dc=mydomain,dc=com
# LDAP_GROUP_BASEDN : Where the GROUPS are, when they are not under
# LDAP_BASEDN. Leave unset when users and groups share one subtree - then
# the group searches use LDAP_BASEDN, exactly as before. Set it when the
# directory keeps them apart (ou=groups beside ou=people), or the group
# searches find nothing and LDAP_GROUP_FILTER_ENABLE refuses every login.
# example : LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
#- LDAP_GROUP_BASEDN=ou=groups,dc=mydomain,dc=com
# LDAP_LOGIN_FALLBACK : Fallback on the default authentication method
# example : LDAP_LOGIN_FALLBACK=true
- LDAP_LOGIN_FALLBACK=false
@@ -9,9 +9,13 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
- **New installs use FerretDB v1 (SQLite) by default on every CPU architecture.**
FerretDB v1 is the wekan/FerretDB fork with an embedded pure-Go SQLite backend
that speaks the MongoDB wire protocol, so WeKan talks to it exactly like MongoDB.
- **Six architectures are built as snaps**: amd64, arm64, armhf, ppc64el, riscv64
and s390x — exactly `snapcraft.yaml`'s `build-for:` list. i386 and armv7 are
release bundles but **not** snaps, for reasons that are not oversights; see
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
- **MongoDB 7 *server* binaries (mongod) are included for amd64 and arm64 only.**
The other architectures (s390x, riscv64, ppc64le) have no MongoDB server — they
run FerretDB v1 only.
The other architectures (armhf, ppc64el, riscv64, s390x) have no MongoDB server
— they run FerretDB v1 only.
- **Every Snap platform includes the FerretDB v1 server and the MongoDB Database
Tools** (`mongodump`, `mongorestore`, `mongofiles`, `mongoexport`, …), on every
architecture.
@@ -30,13 +34,20 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
## Per-architecture matrix
| CPU arch | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|----------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
| amd64 | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
| arm64 | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
| s390x | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| ppc64le | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| riscv64 | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| Snap arch | Bundle | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|-----------|--------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
| amd64 | `amd64` | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
| arm64 | `arm64` | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
| armhf | `armhf` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| ppc64el | `ppc64le` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| riscv64 | `riscv64` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| s390x | `s390x` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
These six are exactly the `build-for:` entries in `snapcraft.yaml`. The **Bundle**
column is the release-asset name at
[github.com/wekan/wekan/releases](https://github.com/wekan/wekan/releases), which
is not always the Snap Store's name for the same hardware — see
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
- **MongoDB 7 server (mongod)**: amd64/arm64 only (MongoDB ships no server for the others).
- **MongoDB Database Tools**: every arch — WeKan builds them for all platforms from
@@ -47,6 +58,60 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
All architectures default new installs to FerretDB v1 with `DDP_TRANSPORT=sockjs`
and polling reactivity.
## CPU architecture names and what they mean
An architecture can go by one name in the release bundles and another in the Snap
Store, and two names that look like variants of each other can be different
builds. Nothing warns about either: an unrecognised `--arch` is simply an
architecture the store has never heard of, so a wrong name looks like it worked.
The mapping is kept in one place, `models/lib/snapArchitectures.js`, and is unit
tested (`tests/snapArchitectures.test.cjs`).
### ppc64le and ppc64el are the same hardware
64-bit little-endian POWER. The bundles name it the way Node.js and the kernel do
(`ppc64le`); the Snap Store names it the way Debian does (`ppc64el`). It is the
**only** architecture whose two names differ, and a test asserts that stays true.
### armhf and armv7 are the same CPU family, built to different baselines
This one is not a naming difference, and getting it wrong ships a snap that
crashes. [wekan/node-patches](https://github.com/wekan/node-patches) builds two
32-bit ARM Node.js binaries:
| Bundle | Built for | Runs on |
|--------|-----------|---------|
| `armhf` | hard-float, **VFPv3-D16**, no NEON assumed — the Debian armhf baseline | **any** ARMv7-A board |
| `armv7` | hard-float, **NEON**, ARMv7-A tuned | only boards that have NEON (an ODroid-U3, say) |
The Snap Store has **one** 32-bit ARM architecture, `armhf`, and it serves every
such device. It must therefore carry the **baseline** build: the NEON one would
be an illegal instruction on any armhf board without NEON. So the armhf snap uses
the `armhf` bundle, and **`armv7` has no snap** — it ships as a bundle only. A
separate snap name or track would be the only way to offer the NEON build, which
is not worth a second listing for one board family.
### i386 has no snap, and cannot have a new one
core24 is Ubuntu 24.04, which has **no i386 port**, so there is no i386 core24
base snap. `build-on: i386` is not merely unsupported — it is a **parse error**,
and because snapcraft parses the whole file first it failed *every* architecture's
build, not only i386's. This is not something a patch set can fix the way
[node-patches](https://github.com/wekan/node-patches) fixes a missing Node.js
build: node-patches patches source so a binary *can* be built, whereas here the
**base snap itself does not exist** for the architecture. The last base with a
real i386 port was `core18`, which is end-of-life.
The Snap Store still shows an i386 column for **wekan-ondra** (revision 70,
version `0.X-ci`) because the store keeps whatever was ever uploaded. It is years
stale, nothing new can be built for it, and re-releasing it would only re-publish
`0.X-ci`. **i386 users are served by the .deb and the AppImage.**
### loong64, win64, win32, mac-x64, mac-arm64
Bundle platforms with no Snap Store architecture at all. They are not snaps and
are not expected to be.
## New installs
On a fresh install, every architecture starts on **FerretDB v1 (SQLite)** with
@@ -151,18 +216,82 @@ bundle the snap's `wekan` part downloads, and staged to `$SNAP/ferretdb`
`<tool>-<arch>` from [wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches/releases),
and `mongosh-<arch>` from [wekan/mongosh](https://github.com/wekan/mongosh/releases).
## Snap channels
## Snaps and channels
The default `snapcraft.yaml` is built on `base: core24`, which allows the
**candidate** channel (core26 does not). The automated release workflow publishes
it to the **candidate**, **beta**, and **edge** channels; the **stable** channel is
published manually later, once it is proven.
`snapcraft-core26.yaml` is the same WeKan on the newer `base: core26`
(`build-base: devel`, `grade: devel`), kept for testing the next base; it can only
go to Beta/Edge until core26 is a released stable base.
There are **three snaps** of the same application, and each should reach **all
four channels on every architecture it is built for**:
| Snap | What it is |
|------|------------|
| [`wekan`](https://snapcraft.io/wekan) | the default |
| [`wekan-ondra`](https://snapcraft.io/wekan-ondra) | same WeKan, separate listing |
| [`wekan-gantt-gpl`](https://snapcraft.io/wekan-gantt-gpl) | same WeKan, separate listing |
**All four channels — Stable, Candidate, Beta and Edge.** The default
`snapcraft.yaml` is built on `base: core24`, a *released* base, so the snap carries
`grade: stable` and may be published to **stable** as well as the other three.
That is what makes the full set possible: a `grade: devel` snap is refused by
stable and candidate outright.
`snapcraft-core26.yaml` is the same WeKan on `base: core26`, kept for testing the
next base and **not** built by the release: core26 still requires
`build-base: devel` and therefore `grade: devel`, so it can only reach beta and
edge. That is the whole reason the release builds core24.
### Publishing to every channel
A revision is **per architecture** — the store's own listing shows `wekan` at
revision 3601 on amd64 and 3600 on arm64 for the same version 10.76 — so one
revision number can never be released to every architecture. Use:
```bash
releases/snap-release-all-channels.sh --dry-run # see the plan first
releases/snap-release-all-channels.sh 10.76 # pin the version
releases/snap-release-all-channels.sh # or the newest of each
```
It resolves the revision per (snap, architecture) from the store and releases it
to `stable,candidate,beta,edge` in **one** call, so a revision reaches all four
or none. A (snap, architecture) with no revision is reported and skipped — the
three snaps genuinely have different architecture sets today. It reads the
architecture table from `models/lib/snapArchitectures.js`, so there is no second
copy of it in shell.
**Pinning the version matters.** Without one, the newest revision of each
architecture is promoted — and edge is often ahead of stable, so a bare run
publishes edge builds to stable users. Pass the version, or `--dry-run` first.
### What each snap currently has, and what is still to add
As of WeKan 10.76 the store holds:
| Snap | amd64 | arm64 | armhf | ppc64el | riscv64 | s390x |
|------|:-----:|:-----:|:-----:|:-------:|:-------:|:-----:|
| `wekan` | ✅ | ✅ | — | ✅ | — | ✅ |
| `wekan-ondra` | ✅ | ✅ | ⚠️ | — | — | — |
| `wekan-gantt-gpl` | ✅ | ✅ | — | — | — | — |
- **—** the architecture is built by `snapcraft.yaml` but that snap has no
revision for it yet. Uploading one is all that is needed; the release script
then puts it on all four channels.
- **⚠️** `wekan-ondra`'s armhf is stuck at version **0.22**, and its i386 column at
**0.X-ci**, on all four channels — fossils the store kept from years ago.
Promoting channels cannot fix these: there is no newer revision to promote.
armhf needs a build uploaded for that snap; i386 cannot be built at all (see
[i386](#i386-has-no-snap-and-cannot-have-a-new-one)).
Publishing the **stable** channel is safe for these because the snap performs its
**automatic migrations** on start — the schema upgrades and the one-time
MongoDB 3 → FerretDB v1 migration on amd64 — so an existing installation moving
from an older revision to this one is upgraded in place rather than needing a
manual step.
## Related
- FerretDB v1 fork: https://github.com/wekan/FerretDB
- Snap install: [Install.md](Install.md)
- Snap settings keys: [Supported-settings-keys.md](Supported-settings-keys.md)
- The architecture table in code: `models/lib/snapArchitectures.js`
(tested by `tests/snapArchitectures.test.cjs`)
- Publish every snap to every channel: `releases/snap-release-all-channels.sh`
- Node.js builds per platform: [wekan/node-patches](https://github.com/wekan/node-patches)
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.71-win64.zip](https://github.com/wekan/wekan/releases/download/v10.71/wekan-10.71-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.78-win64.zip](https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.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-10.71-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-10.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+80
View File
@@ -70,6 +70,86 @@ problem *observable* to WeKan; the recording is WeKan's job. Concretely:
WeKan maps these to `eventlog` documents via the category catalog
(`models/lib/securityCategories.js`) using `source:'sqlite.*'`.
---
## 3b. Canary tokens — operations the client never issues
The database is reached over a **local socket by one application**, whose driver
is a Meteor 3 one. That makes a class of operations interesting **by their mere
presence**: server-side JavaScript evaluation, an aggregation writing its result
into a collection, dropping a database, a server-administration command. The
driver does not send them. A request that does is either a bug or somebody who
has reached the socket and is looking around — and both are worth telling the
operator about.
`internal/util/canary` (paired with `tests` in the same package) is the FerretDB
half of [WeKan.md §12](WeKan.md), and it keeps the same three properties:
- **SILENT.** A tripped canary returns the ordinary *"operation not supported by
this build"* refusal — the same answer an unimplemented command gets. The id is
appended as `canary:<id>`, which the client parses and the operator reads;
nothing in it says that anything was detected or recorded, so a probe cannot
tell a watched operation from an unimplemented one and route around the watched
ones. A Go test asserts the message contains none of *detect*, *record*, *log*,
*alert*.
- **BOUNDED.** The package writes **nothing** — no file, no table, no counter. It
is a map lookup and an error value, so a caller hammering it in a loop costs
this process one string comparison per request and this package **no memory at
all**. This is what §2's "FerretDB does not write to any database or file"
means for canaries, and a Go test asserts the package is stateless.
- **ATTRIBUTED.** The marker names *which* canary, so the operator's report says
"tried to run server-side JavaScript" rather than "an error".
The canaries:
| Id | Operations | Why the client never sends it |
| --- | --- | --- |
| `db.javascript` | `eval`, `$where`, `$function`, `$accumulator`, `mapReduce` | the driver has no feature that evaluates JavaScript in the database |
| `db.result-to-collection` | `$out`, `$merge` | aggregation results are read, never persisted by the database |
| `db.drop-database` | `dropDatabase`, `dropAllDatabases` | the application drops collections it owns; dropping the database is an operator action taken with the database's own tools |
| `db.server-admin` | `shutdown`, `setParameter`, `getParameter`, `profile`, `logRotate` | these manage the server, not the data |
`Check(op)` returns `nil` for everything else, and a Go test pins the ordinary
vocabulary — `find`, `insert`, `update`, `aggregate`, `$match`, `$group`,
`$lookup`, … — as **not** tripping. A canary that fires on normal traffic is
worse than no canary: it buries the real ones.
### How it reaches the operator
Exactly as §2 describes for every other FerretDB event — the database reports,
the client records:
```
FerretDB WeKan
Check(op) → canary:<id> in the error ──► recordDatabaseProblem()
│ databaseCanaryId() reads the marker
▼
tripCanary('database.canary') ← rate-limited,
│ aggregated,
▼ attributed
eventlog (stream:'security')
│
▼
Admin Panel → Problems → Security
```
Two things that are deliberate on the WeKan side
(`server/lib/databaseProblems.js`):
1. The id is **not trusted as a category**. It is matched against a known list,
and anything else is recorded generically. An error string is
attacker-influenced, and a marker parsed out of one must never be able to
choose which security category it lands in — or to inject anything, which is
why the extractor accepts only `[a-z][a-z0-9.-]{0,60}`.
2. A canary goes to the **security** stream and does **not** fall through to the
`database` stream. Filing it under "the database said something" would put an
intrusion attempt in the list of things to triage as configuration problems.
On **MongoDB** there is no FerretDB to mark anything, and these operations simply
never appear — the WeKan side is inert, and the feature degrades to nothing
rather than misbehaving. That is the same property as §2: storage and reporting
are WeKan's job, so nothing here depends on which database is underneath.
## 4. Security remediation points → logger
| Point | Remediation (present/added) | Logged as |
+327 -1
View File
@@ -351,7 +351,333 @@ test". It uses the same `events` schema and the same disk-space discipline, in t
---
## 11. Out of scope / follow-ups
## 12. Canary tokens — who tried it, and from where
§7 records what a guard **did**: a request was blocked, a filename sanitized, an
upload refused. That answers "is WeKan defending itself". It does not answer the
question an admin actually asks after seeing one — **who did that, and from
where** — and it does not distinguish a browser that got confused from somebody
working through the [hall of fame](https://wekan.fi/hall-of-fame/) one entry at
a time.
A **canary token** is a tripwire placed at a point that **only a permission-override
attempt reaches**. Ordinary use never gets there: the code path runs when a
request asks for something the rules forbid. So a trip is not noise, and it is
worth an admin's attention with the actor attached.
Three properties define one. Each is enforced by `tests/canaryTokens.test.cjs`
and `tests/canaryCoverage.test.cjs`.
### 12.1 SILENT — the attacker learns nothing
Tripping a canary changes **nothing the caller can observe**. The refusal keeps
its wording, its status, its timing and its shape. This is not politeness: a
canary that announces itself is a map of which paths are watched, and a probe
would simply avoid them.
So the trip function is written as a drop-in for the refusal it replaces:
```js
if (somethingForbidden) return false; // before
if (somethingForbidden) return tripCanary('card.vote-field', { userId }); // after
```
`tripCanary()` **always returns `false`**, and `tripCanaryDeny()` — for a Meteor
`deny` rule, which refuses by returning **true** — always returns `true`. The
return value is decided **before** any reporting is attempted, and the reporting
is wrapped, so a canary can never throw into, delay, or otherwise disturb the
request it is watching. A REST handler that must throw re-throws **the original
error object**, never a new one.
### 12.2 BOUNDED — probing must not cost more than it does now
A canary sits where an attacker can loop. Writing one database row per attempt
would be a denial of service they get for free: CPU on every insert, and a
collection that grows until the disk is full.
`models/lib/canaryTokens.js` (pure, no Meteor, no clock of its own — the runtime
passes the time in, which is how the tests drive it) decides per trip:
- the **first** trip of a `(canary, actor)` pair is recorded immediately, so a
probe shows up promptly;
- further trips **inside the window** (default 60 s) write nothing and are
**counted**;
- the first trip **after** the window writes **one summary** carrying the total,
which is what the `count` column shows;
- a pair that has already written `maxEventsPerPair` summaries (default 60)
keeps counting but **stops writing** until it goes quiet — so an endless slow
probe cannot write one row a minute forever;
- the map of tracked pairs is **capped** (default 5000) and evicts the **least
recently seen** entry, so a long-running attacker is not pushed out by a
passing one, and a botnet varying its address cannot grow it without bound;
- idle pairs are **swept** on a timer, so a long-lived server does not hold a row
per address seen since boot.
A thousand attempts in a minute therefore cost **one row**, and the decision to
suppress costs **one map lookup** — which is what makes a canary safe to put on a
path an attacker chooses to hammer. The username lookup is cached (60 s, capped
at 1000 entries), so probing cannot make WeKan do a database read per attempt
either, and neither the lookup nor the insert is awaited on the caller's path.
### 12.3 ATTRIBUTED — who, and from where
Every event carries:
| Field | Where it comes from |
| --- | --- |
| `userId` | the caller's own knowledge, else the DDP invocation |
| `username` | looked up once and cached; **stored on the event**, not resolved later |
| `ip` | the request, resolved with `resolveClientKey` — the SAME spoofing-safe rule as the login throttle |
| `count` | how many attempts this one row stands for (§12.2) |
The username is **denormalised at write time on purpose**: it is what the account
was *called when it tried*, so a later rename does not rewrite history and a
deleted account does not erase it.
The address honours `X-Forwarded-For` **only as far as `HTTP_FORWARDED_COUNT`
says to trust it**, exactly as `server/lib/loginAttemptThrottle.js` does.
Otherwise an attacker would write somebody else's address into the security log
by sending a header — turning the report into a way to frame a colleague.
`detail` says what was **attempted**, in the words of the feature ("tried to move
a card into a board they cannot write to"), and **never the payload**: it is
attacker-controlled text, and §4's truncation and control-character stripping is
the second line of defence behind that rule.
### 12.4 Where the canaries are
Each is `canaryId → the guard that trips it → the published vulnerability whose
ATTEMPT it watches`. The table is pinned by `tests/canaryCoverage.test.cjs`,
which also fails on a canary in the catalog that nothing trips.
| Canary | Tripped in | Watches the attempt behind |
| --- | --- | --- |
| `card.cross-board-move` | `server/permissions/cards.js` | BoardBleed |
| `card.invisible-parent` | `server/permissions/cards.js` | ParentBleed |
| `card.vote-field`, `card.poker-field` | `server/permissions/cards.js` | direct field writes that must go through a method |
| `list.cross-board-move` | `server/permissions/lists.js` | BoardBleed |
| `swimlane.cross-board-move` | `server/permissions/swimlanes.js` | BoardBleed |
| `checklist.cross-board-move` | `server/permissions/checklists.js` | ChecklistBleed |
| `checklist-item.cross-board-move` | `server/permissions/checklistItems.js` | ChecklistBleed |
| `avatar.version-path`, `avatar.restricted-field`, `avatar.not-owner` | `server/permissions/avatars.js` | PathBleed |
| `attachment.version-path`, `attachment.restricted-field` | `server/permissions/attachments.js` | PathBleed |
| `reaction.foreign` | `server/permissions/cardCommentReactions.js` | writing another user's reaction |
| `comment.foreign-delete` | `server/models/cardComments.js` | CommentBleed |
| `export.path-outside-storage` | `models/exporter.js` | PathBleed (a path poisoned some other way) |
| `database.canary` | `server/lib/databaseProblems.js` | [FerretDB.md](FerretDB.md) — an operation WeKan never issues |
**No Playwright, no browser.** Every canary above fires inside a server-side
permission check, so it works on any WeKan server with nothing installed. A
published vulnerability whose attempt leaves no server-side trace — a fixed XSS,
say, where the payload is refused in the browser — has no canary, and that is a
statement of what is detectable rather than an omission.
### 12.5 What the admin sees
Admin Panel → **Problems → Security** (§8), unchanged except for three columns:
**Username**, **IP address** and **Attempts**. Both new columns are **searchable**,
because the thing an admin does with one security event is pivot on it: every
other event from this address, every other event from this account.
A row reads:
```
2026-08-09 14:02:11 · authz · PathBleed · high · detected · canary:avatar.version-path
· mallory · 203.0.113.7 · 214
· tried to write the on-disk path of an avatar; 214 attempts in this window
```
---
### 12.6 Injection, sanitization, and the other common attacks
§12.4 watches **permission overrides**. Three more families are watched the same
way, and each has the same problem in common: the defence already existed and was
**silent**, so nobody ever learned that it had fired.
**NoSQL injection** (`models/lib/injectionDetect.js`, pure and unit-tested). Two
shapes, and they are different problems:
- an **execution operator** in a client-supplied selector — `$where`,
`$function`, `$accumulator`, `$out`, `$merge`. A selector is data; these turn
it into code the database runs. → `injection.nosql-selector`
- an **operator object where a scalar was expected** — `{"$ne": null}`,
`{"$gt": ""}`, `{"$regex": ".*"}` in a field that should hold the string a
user typed. This is how "match every row" is spelled in a document database
and it needs no JavaScript at all. → `injection.nosql-operator`
The detector is deliberately narrow about the second: **every** key must be an
operator. A plain object with ordinary keys is somebody sending the wrong type,
which is a validation error, and calling it an attack would fill the report with
noise. A `$` inside a *string* is text — a card titled `$100 refund` puts nobody
in the security report.
**SQL injection.** WeKan builds no SQL, so it cannot detect this itself. The
database does: FerretDB's `internal/util/sqlguard` refuses a statement carrying
what only injection produces, and now **marks the refusal** so the attempt
reaches the admin instead of dying in a log file
([FerretDB.md §3b](FerretDB.md)). → `injection.sql-statement`
**Sanitization.** Sanitizing is routine — a filename gets trimmed, a pasted
`<b>` gets stripped from a comment — and routine is not worth an admin's
attention. What is worth it is sanitization that removed something that would
have **done** something:
| Canary | Fires when |
| --- | --- |
| `sanitize.dangerous-filename` | an uploaded name carried an exploit pattern, invisible characters or URL encoding |
| `sanitize.path-traversal` | a file path tried to leave its directory |
| `sanitize.dangerous-content` | active markup was removed from an uploaded file (a script or `javascript:` URI inside an SVG) |
| `sanitize.dangerous-text` | active markup was removed from submitted text (a comment carrying `<script>`, an `onerror=`, a `javascript:` URI) |
`removedActiveMarkup()` decides the last two by comparing what the sanitizer took
**out**: text that never had any is not interesting, and text that still has it
was not sanitized at all — which would be a different bug.
**Other common attacks.**
| Canary | Fires when |
| --- | --- |
| `spoof.forwarded-header` | `/metrics` denied a request that carried `X-Forwarded-For` — nothing legitimate sends one to an endpoint that does not trust it |
| `brute.login-lockout` | a REST login was refused while the address was locked out, i.e. it had already failed the configured number of attempts in a row |
Both replaced a **direct `securityLog.record()`**. That matters for more than
tidiness: those two paths are **unauthenticated**, so a bare `record()` was one
database insert per request an attacker chose to send. Going through the canary
gives them the §12.2 rate limit and the §12.3 attribution, and the responses —
the 401 and the 429 — are untouched.
## 13. Filesystem storage integrity, crashes and downtime
§12 watches what somebody **tries to do through WeKan**. This section is about
what happened to WeKan's own files and process **when nobody was asking it
anything**.
WeKan's attachments and avatars are files under `WRITABLE_PATH`, and the database
holds one document per file. **Nothing checked that the two still agree.** A file
can be replaced, truncated, back-dated or deleted by anything that reaches the
filesystem — a bad restore, a sync tool, a container rebuild, a shell on the
volume — and WeKan would keep serving whatever is there now, silently.
### 13.1 The baseline, and the four hashes
One document per file in the existing WeKan database (`fileIntegrity`; **no new
files under `WRITABLE_PATH`**, per §3): the path, the size, the modification
time, and **md5, sha256 and sha512**.
Three digests, not one, because they answer different questions. **md5** is fast
and is what most other tools print, so an admin can compare against a backup with
the tool they already have — it is never alone here, because its collision
resistance is gone. **sha256** is the working digest. **sha512** is a second,
different-width one, and it earns its place on the day the two *disagree*: two
digests over the same bytes cannot disagree, so when they do, the bytes were not
read the same way twice — a failing disk, a partial write, a truncated copy —
which is a different problem from a substitution and is reported as its own,
**critical**, finding.
**ed25519 is the fourth, and it is not a hash — it is a signature**, which is the
only thing that answers the question the three digests cannot: *who says these
are the right hashes?* Anybody who can rewrite a file can rewrite a row of
hashes. So each baseline entry is signed over a canonical line
(`manifestLine()` — fixed field order, no JSON, whose key order and escaping are
not guaranteed stable across versions), and the signature is verified on every
scan. A record that does not match its signature is a **critical** finding, and
it is never explained away by an ordinary edit: WeKan does not rewrite a baseline
without re-signing it.
The key:
| Source | What it protects against |
| --- | --- |
| `WEKAN_INTEGRITY_PRIVATE_KEY` (PKCS#8 PEM), supplied by the operator and **never stored by WeKan** | an attacker who reaches the **database** as well as the disk |
| otherwise, generated on first run and kept in the database | filesystem-only tampering — a restore, a sync tool, a container rebuild, a shell on the volume |
The second is the common case and is honest about not being more than that. A
**malformed** supplied key is reported and the scan runs **without** signing
rather than silently falling back to a generated one, which would look like it
was working while checking nothing the operator meant.
### 13.2 The scan: once a day, when nothing else needs the machine
Reading every stored byte is the last thing that should compete with users, so
the scan is paced by policy rather than by hope (`models/lib/fileIntegrity.js`,
pure and time-injected):
- **once a day** (`intervalMs`), checked hourly — not on every restart;
- **not while the machine is busy**: at or above **60% CPU** it does not start,
and it stops if the load rises mid-run. It is never urgent;
- **a pause between every file** — 50 ms, plus 20 ms per megabyte, so a
directory of large files does not become a sustained read;
- **a time budget** (15 minutes). A scan that stops and continues tomorrow is
better than one that holds a machine down;
- **one read per file for all three digests.** Three passes would be three times
the disk for the same bytes.
A run that stopped early does **not** report the files it never reached as
missing — it has not looked everywhere, and saying otherwise would be a lie.
### 13.3 The finding: changed, with no record saying why
Files change when people use WeKan; reporting that would be noise. So each change
is classified against WeKan's own record of the file:
| Situation | Result |
| --- | --- |
| unchanged | nothing recorded |
| changed, and WeKan has a record of the operation | `info` — reported once, then **re-baselined** so it does not repeat daily |
| **changed, with nothing to account for it** | **the warning** — at least `medium`, and deliberately **not** re-baselined, so it keeps showing until somebody looks |
| the record's signature does not verify | `critical`, never explained |
| recorded and now missing | `high` |
| present and never recorded | `low` — a leftover or a backup copy is not evidence of anything |
A modification time that moved **backwards** is called out separately: nothing
does that by accident.
The event carries the **file's name and the finding, never its contents and never
the digests** — an admin needs to know which file, not to read it here.
### 13.4 Crashes, downtime and errors nobody caught
Admin Panel → Problems answered "what did WeKan refuse" and "what did the
database say", and not the plainest question: **did this server stop, and did it
stop cleanly?**
A crash leaves nothing behind — the process is gone, so it cannot write a message
about being gone. A **heartbeat** is the only thing that works: WeKan writes the
time every minute, and the NEXT start reads it.
| What the next start finds | Recorded as |
| --- | --- |
| no previous run | nothing — a first run is not a problem |
| last heartbeat with a **clean-shutdown mark** | nothing — a deliberate restart is not a problem |
| a **short** gap, no mark | `low` — the shutdown hook may simply not have run |
| a **long** gap, no mark | **`high`** — it stopped without shutting down cleanly, and here is how long it was down |
Recording every deliberate restart is how a Problems page becomes a page nobody
opens, which is why the first two record nothing at all.
Two more land in the same stream, because they are the same question one level
down: an **uncaught exception** and an **unhandled promise rejection**. This app
turns the second into a process exit, so it is a crash *with a cause attached* —
and the cause is worth recording before the process goes. The listeners are
**added, never replaced**: removing whatever else is listening would change how
the app handles its own failures.
### 13.5 Where it appears
A new event stream, `integrity`, in the same `eventlog` collection and the same
Admin Panel → **Problems → Filesystem integrity** table as the others (§8), with
the Username / IP address / Attempts columns §12.3 added. It counts towards the
red **Problems** button like every other stream.
The `fileIntegrity` baseline itself is **never published and never client-writable**:
it is a map of every file on the server, and the key document is a private key.
Neither belongs on a client, admin or not — Admin Panel reads the **findings**,
which are ordinary event rows.
---
## 14. Out of scope / follow-ups
- Rotating/retention: a cron trims `eventlog` to the newest N per stream (`remove` older docs).
- Shipping logs to an external SIEM (kept local by design).
+5 -2
View File
@@ -2339,5 +2339,8 @@
"event-severity": "Severity",
"event-action": "Action",
"event-source": "Source",
"event-detail": "Detail"
}
"event-detail": "Detail",
"event-ip": "IP address",
"event-attempts": "Attempts",
"integrityReportTitle": "Filesystem integrity"
}
+50 -50
View File
@@ -10,7 +10,7 @@
"board-members-same-team-only": "Додај чланове табле само из истог тима",
"comment-in-reply-to": "Одговор на навод",
"comment-reply": "Одговори",
"restrict-comment-editing": "Ограничи право свих који управљају списима да мењају или уклоне садржај из записника",
"restrict-comment-editing": "Ограничи право свих водећих сарадника који управљају списима да мењају или уклоне садржај из записника",
"due-date-changes": "Померање рока",
"due-date-changed-times": "Рок је померан/пробијен %s пута",
"error-user-notSameOrgOrTeam": "Можете додати само лица која долазе из исте странке или истог правног тима",
@@ -110,13 +110,13 @@
"allboards.workspace-color": "Боја",
"allboards.delete-workspace-confirm": "Да ли сте сигурни да желите да се потпуно иселите из овог радног простора?",
"allboards.delete-workspace-confirm-check": "Потврдите исељење из радног простора",
"multi-selection-active": "Означите припадајуће списе",
"multi-selection-active": "Припајање списа",
"selected-label": "Ознаке:",
"set-selected-starred": "Додели звездицу изабраним списима",
"set-selected-unstarred": "Уклони звездицу са изабраних табли",
"set-selected-home": "Постави да се прво ови списи наметну након пријаве",
"home-board-badge": "Наметнути списи који се први приказују после пријаве",
"home-board-empty": "Drag a board here to open it after login",
"set-selected-starred": "Додели им звездице",
"set-selected-unstarred": "Уклони им звездице",
"set-selected-home": "Нек ме дочека",
"home-board-badge": "Списи добродошлице",
"home-board-empty": "Овде сместите списе за добродошлицу",
"home-board-remove": "Remove from Home",
"home-board-remove-confirm": "Remove this board from Home? The board itself is not deleted.",
"activity-dueDate": "је изменио крајњи рок на %s у предмету %s",
@@ -162,8 +162,8 @@
"added": "додао",
"addMemberPopup-title": "Пријем сарадника",
"memberPopup-title": "Избор сарадника",
"admin": "Управник",
"admin-desc": "Има увид и пун приступ у све предмете из ових списа. У овим списима може да бира сараднике, поставља правила рада и чита записник.",
"admin": "Водећи сарадник",
"admin-desc": "Има пун увид у све предмете из ових списа. Уредник је како садржаја списа тако и делова садржаја предмета. У овим списима може да бира сараднике, поставља правила рада и чита записник.",
"admin-announcement": "Јавни разглас",
"admin-announcement-active": "Пусти на јавни разглас",
"admin-announcement-title": "Јавно саопштење управника:",
@@ -177,7 +177,7 @@
"app-try-reconnect": "Покушавам да се поново повежем.",
"archive": "Спакуј у архиву",
"archive-all": "Спакуј све у архиву",
"archive-board": "Спакуј у средишњу архиву",
"archive-board": "Спакуј у архиву",
"archive-board-confirm": "Да ли сте сигурни да желите да спакујете ове списе у архив?",
"archive-card": "Спакуј предмет у архиву",
"archive-list": "Спакуј овај део у архиву",
@@ -447,19 +447,19 @@
"comments": "Расправа",
"comment": "Изнеси мишљење",
"comment-placeholder": "",
"comment-only": "Стручни саветник",
"comment-only-desc": "Има право увида у све предмете и може да учествује у свим расправама.",
"comment-assigned-only": "Спољни стручни саветник",
"comment-assigned-only-desc": "Једино може да има право увида и узме учешће у расправама у додељеним му предметима.",
"comment-only": "Виши саветник",
"comment-only-desc": "Има право увида у садржај свих предмета. Може да учествује у расправама.",
"comment-assigned-only": "Саветник",
"comment-assigned-only-desc": "Има право увида у садржај само оних предмета за које је задужен. Може да учествује у расправама.",
"comment-delete": "Да ли сте сигурни да желите да повучете изнешено мишљење?",
"deleteCommentPopup-title": "Повлачите мишљење?",
"no-comments": "Посматрач",
"no-comments-desc": "Без права на расправу и права на увид у записник",
"read-only": "Читалац",
"read-only-desc": "Има право увида у све предмете.",
"read-assigned-only": "Спољни читалац",
"read-assigned-only-desc": "Има право увида само у додељене предмете.",
"worker": "Приправник",
"no-comments": "Приправник",
"no-comments-desc": "Има право увида у садржај свих предмета. Не може да учествује у расправи. Може да мења садржај списа.",
"read-only": "Виши посматрач",
"read-only-desc": "Има право увида у садржај свих предмета укључујући и ток расправе али нема право учешћа у расправи. Не може да мења садржај списа.",
"read-assigned-only": "Посматрач",
"read-assigned-only-desc": "Виши посматрач коме је једино одузето право да уопште има увид у предмете за које није задужен",
"worker": "Помоћник",
"worker-desc": "Може да ради помоћне послове - да премешта предмете, бира оне које ће пратити и да учествује у расправи. ",
"computer": "Рачунар",
"confirm-subtask-delete-popup": "Да ли сте сигурни да желите да избришете овај издвојени посао?",
@@ -547,8 +547,8 @@
"enable-vertical-scrollbars": "Омогући вертикалне клизаче",
"enable-wip-limit": "Уведи ограничење",
"error-board-doesNotExist": "Овакви списи не постоје",
"error-board-notAdmin": "Да би то урадили, треба да будете управник ових списа",
"error-board-notAMember": "Да би то урадили треба да будете сарадник на овим списима",
"error-board-notAdmin": "Да би то урадили, треба да постанете водећи сарадник ових списа",
"error-board-notAMember": "Да би то урадили за почетак треба да постанете сарадник на овим списима",
"error-notAllowed": "Ваша улога на табли не дозвољава то",
"error-json-malformed": "То што сте укуцали нема исправан JSON облик",
"error-json-schema": "Ваши подаци у JSON облику не укључују исправне информације у предвиђеном облику",
@@ -719,7 +719,7 @@
"label-delete-pop": "Пажња. Опозив ове радње неће бити могућ! Изабрана налепница ће бити одлепљена/уклоњена из свих предмета и њена историја ће исчезнути.",
"labels": "Налепнице",
"language": "Језик",
"last-admin-desc": "Не можете да мењате задужења јер мора постојати барем један управник/администратор.",
"last-admin-desc": "Не можете да промените улогу јер мора преостати барем један водећи сарадник.",
"leave-board": "Раздужи све предмете",
"leave-board-pop": "Да ли сте сигурни да желите да раздужите све предмете из списа __boardTitle__? Бићете изузети из свих тих предмета.",
"leaveBoardPopup-title": "Раздуживање",
@@ -733,7 +733,7 @@
"settingsUserPopup-title": "Рад са сарадницима",
"settingsTeamPopup-title": "Рад са правним тимовима",
"settingsOrgPopup-title": "Рад са странкама",
"orgAdminsPopup-title": "Администратори организације",
"orgAdminsPopup-title": "Одговорна лица странке",
"swimlaneActionPopup-title": "Радње у овом току поступка",
"swimlaneAddPopup-title": "Додаје испод врсту поступка",
"listImportCardPopup-title": "Унеси једнан Trello предмет",
@@ -764,8 +764,8 @@
"multi-selection": "Обухват",
"multi-selection-label": "Употреби налепницу",
"multi-selection-member": "Задужи сарадника",
"multi-selection-on": "У току је обухват",
"multi-selection-off": "Искључи вишеструки избор",
"multi-selection-on": "Обухватам...",
"multi-selection-off": "Прекини обухват",
"muted": "Не примај обавештења",
"muted-info": "Нећете чути обавештења кад наступе било какве промене у овим списима",
"my-boards": "Списи у мојој надлежности",
@@ -775,9 +775,9 @@
"no-archived-swimlanes": "Нема архивираних врсти поступака.",
"no-results": "Нема резултата",
"normal": "Виши сарадник",
"normal-desc": "Може да има и увид и пун приступ у све предмете из ових списа. Не може да поставља правила рада у овим списима.",
"normal-assigned-only": "Спољни виши сарадник",
"normal-assigned-only-desc": "Има увид и пуна права само у оним предметима где има надлежност или је опуномоћен.",
"normal-desc": "Има потпуно иста права као и водећи сарадник уз разлику да не може да поставља правила рада у овим списима.",
"normal-assigned-only": "Сарадник",
"normal-assigned-only-desc": "Има потпуно иста права као и виши сарадник уз разлику да је сараднику одузето право да уопште има увид у предмете за које није задужен.",
"not-accepted-yet": "Позив још увек није прихваћен",
"notify-participate": "Примајте допунске извештаје при било којој измени предмета које сте сами завели или где сте сарадник",
"notify-watch": "Примајте допунске извештаје при било којој измени списа, делова поступака или предмета које пратите",
@@ -1000,9 +1000,9 @@
"org-domains": "Домени ове организације",
"org-domains-description": "Имена домаћина на којима се послужује ова организација, раздвојена зарезима — на пример a.example.com, kanban.example.org. Захтев ка било ком од њих јесте захтев ове организације, а визуелни идентитет испод замењује идентитет инстанце. Важи само ако је сервер покренут са MULTITENANCY=true. Оставите празно ако ова организација није закупац.",
"error-org-domain-taken": "Тај домен већ припада другој организацији:",
"org-admins": "Администратори организације",
"org-admins-description": "Чланови ове организације који смеју да је администрирају: њене људе и њене резервне копије, и ништа друго. Никада не могу да доделе овлашћење администратора целог сајта нити да управљају администратором сајта.",
"org-admin": "Администратор организације",
"org-admins": "Одговорна лица странке",
"org-admins-description": "Постављени појединци који сами могу да праве сарадничку мрежу странке и њен резервни примерак података. Нису део одлучивања око управе.",
"org-admin": "Одговорно лице",
"team-shared-templates": "Дељени шаблони",
"team-propagate-members-to-boards": "Пренеси чланове на табле",
"team-sync-members-from-auth": "Синхронизуј чланове са провајдера аутентификације",
@@ -1033,10 +1033,10 @@
"default": "Подразумевано",
"defaultdefault": "Подразумевано",
"queue": "Пријемно одељење",
"subtask-settings": "Поставка издвојених послова",
"subtask-settings": "Издвојени послови",
"card-settings": "Садржај предмета",
"minicard-settings": "Рад са омотима предмета",
"boardSubtaskSettingsPopup-title": "Руковање издвојеним пословима",
"boardSubtaskSettingsPopup-title": "Поставка издвојених послова",
"boardCardSettingsPopup-title": "Делови садржаја",
"boardMinicardSettingsPopup-title": "Рад са омотима предмета",
"deposit-subtasks-board": "Депонуј издвојене послове у списе:",
@@ -1283,7 +1283,7 @@
"display-authentication-method": "Прикажи метод за утврђивање идентитета",
"oidc-button-text": "Измени OIDC текст на дугмету",
"default-authentication-method": "Уобичајени метод за утврђивање идентитета",
"duplicate-board": "Умножи списе у још један примерак",
"duplicate-board": "Умножи један примерак",
"duplicate-board-confirm": "Да ли сте сигурни да желите да направите још један примерак списа?",
"org-number": "Број евидентираних странака је:",
"team-number": "Број правних тимова је:",
@@ -1352,21 +1352,21 @@
"allow-rename": "Дозволи преименовање",
"allowRenamePopup-title": "Дозволи преименовање",
"roles": "Улоге",
"roles-info": "Изаберите које улоге на табли смеју да изврше сваку радњу. Корисници администраторског панела (глобални администратори) увек имају сва права и не могу се овде ограничити.",
"allow-invite-to-board": "Дозволи позивање на таблу",
"all-board-members": "Сви чланови табле",
"board-admin": "Администратор табле",
"roles-status": "Стање улога",
"roles-status-desc": "Шта свака улога табле сме. Само за читање: прати поља за потврду изнад, па приказује избор пре чувања.",
"roles-info": "Одредите она лица односно оне улоге којима се дозвољава вршење наведене радње. Управници свакако имају неограничена права која им се овде не могу ограничити.",
"allow-invite-to-board": "Ко има дозволу да буде позивар",
"all-board-members": "Било који сарадник",
"board-admin": "Водећи сарадник",
"roles-status": "Опис улога",
"roles-status-desc": "Описује шта свака улога значи. Исписане вредности се не могу мењати - оне вам само помажу при избору.",
"roles-status-role": "Улога",
"roles-status-invite": "Позив на таблу",
"roles-status-sees": "Види картице",
"roles-status-comment": "Изнеси мишљење",
"roles-status-write": "Прављење и уређивање",
"roles-status-manage": "Подешавања табле",
"roles-status-invite": "Позива",
"roles-status-sees": "Има увид у предмете",
"roles-status-comment": "Учествује у расправи",
"roles-status-write": "Мења садржај",
"roles-status-manage": "Ради поставку списа",
"roles-status-sees-all": "Све",
"roles-status-sees-assigned": "Само додељене",
"roles-status-empty": "Нема улога табле.",
"roles-status-empty": "Нису подељене улоге",
"start-day-of-week": "Постави који дан се рачуна за почетак недеље",
"monday": "Понедељак",
"tuesday": "Уторак",
@@ -1836,7 +1836,7 @@
"text": "Изворна реченица",
"translation-text": "Превод",
"show-subtasks-field": "Прикажи поље издвојени послови",
"show-week-of-year": "Прикажи редни број недељу у години",
"show-week-of-year": "Прикажи редни број недеље у години",
"convert-to-markdown": "Претвори у структуирани текст",
"import-board-zip": "Додај .zip архиву која има списе у json облику и фасцикле по имену списа где је предметна грађа",
"collapse": "Скупи",
@@ -2154,7 +2154,7 @@
"migration-successful": "Обнова је успешно окончана",
"migration-failed": "Обнова није била успешна",
"migrations": "Радионица",
"migrations-admin-only": "За обнову ових списа надлежан је једино онај ко је њихов управник",
"migrations-admin-only": "За обнову ових списа надлежан је једино онај ко је њихов водећи сарадник",
"migrations-description": "Овде може да се изврши провера целовитости података и поправка оштећења. Сваки поступак обнове се може спровести независно један од другог",
"no-issues-found": "Нису уочена оштећења",
"run-migration": "Покрени опоравак",
+33 -9
View File
@@ -4,6 +4,7 @@ import { check, Match } from 'meteor/check';
import { Random } from 'meteor/random';
import { ReactiveCache } from '/imports/reactiveCache';
const { notHelperBoardTitle } = require('/models/lib/helperBoards');
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
import escapeForRegex from 'escape-string-regexp';
import CustomFields from './customFields';
import {
@@ -1862,7 +1863,29 @@ Boards.helpers({
// Upsert keyed on the deterministic _id: concurrent self-heals collide on
// the _id unique index, so only one insert wins instead of racing the
// check-then-insert and each inserting a new swimlane.
Swimlanes.upsert({ _id: defaultId }, { $setOnInsert: this.defaultSwimlaneFields() });
//
// It must be the ASYNC upsert. Meteor 3 removed the synchronous one on the
// server, and calling it threw
// Error: update is not available on the server. Please use updateAsync()
// out of every `moveSwimlane` that reached this self-heal - the failure an
// admin kept seeing in Admin Panel / Problems / Database problems. This
// getter is SYNCHRONOUS and cannot await, so the upsert is started and not
// waited for: the self-heal still happens, and this call returns whatever
// is in the cache, which the caller already had to handle (it was undefined
// before the self-heal existed too). A caller that needs the swimlane back
// in the same tick uses ensureDefaultSwimlaneIdAsync().
try {
const p = Swimlanes.upsertAsync(
{ _id: defaultId },
{ $setOnInsert: this.defaultSwimlaneFields() },
);
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {
if (process.env.DEBUG === 'true') {
// eslint-disable-next-line no-console
console.warn('default swimlane self-heal failed:', e && e.message);
}
}
return ReactiveCache.getSwimlane({ _id: defaultId });
},
@@ -2452,14 +2475,15 @@ Boards.userBoards = (
if (selector.title === undefined) {
selector.title = notHelperBoardTitle();
}
selector.$or = [
...(includePublic ? [{ permission: 'public' }] : []),
{ members: { $elemMatch: { userId, isActive: true } } },
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
// #5850: domain-based board sharing — board shared with the user's email domain.
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
];
// GHSA-gwc4-fw7p-gw58: the same builder the `board` publication uses, so
// "which boards may this user see" has one answer and cannot drift.
selector.$or = boardVisibilitySelectors({
userId,
orgIds: user.orgIds(),
teamIds: user.teamIds(),
emailDomains: user.emailDomains(),
includePublic,
});
return selector;
};
+56 -26
View File
@@ -17,6 +17,23 @@ function sanitizeText(text) {
previous = sanitized;
sanitized = sanitized.replace(/<[^>]*>/g, '');
} while (sanitized !== previous);
// A comment losing a `<b>` is an everyday thing and says nothing. A comment
// losing a SCRIPT TAG, an event handler or a javascript: URI is a stored-XSS
// attempt that this line defused, and an admin should be told who wrote it
// (docs/Security/Remediation/WeKan.md §12.6). The comment is stored sanitized
// either way; the author is told nothing.
if (Meteor.isServer && sanitized !== text) {
try {
const { removedActiveMarkup, CANARY_IDS } = require('/models/lib/injectionDetect');
if (removedActiveMarkup(text, sanitized)) {
require('/server/lib/canary').tripCanary(CANARY_IDS.text, {
detail: 'active markup removed from a comment',
});
}
} catch (e) { /* a canary must never break a comment */ }
}
return sanitized;
}
@@ -201,34 +218,47 @@ CardComments.helpers({
CardComments.hookOptions.after.update = { fetchPrevious: false };
if (Meteor.isServer) {
// Server-side enforcement of comment edit/delete permissions (issue #5906).
// Server-side enforcement of comment edit/delete permissions (issue #5906).
//
// The DDP `allow` rule in server/permissions/cardComments.js is the first gate,
// but the per-board `restrictCommentEditing` setting is enforced here so the
// rule cannot be bypassed and the decision lives next to the data. Exported so
// the REST handlers can enforce the SAME rule (GHSA-pqr4-rxgp-hv2m) rather than
// relying on collection hooks, which cannot see an HTTP caller's identity.
export async function assertCanMutateComment(userId, doc) {
// Server-internal operations (board copy, cleanup, migrations, etc.) run
// without an authenticated user; do not block those here. User-initiated
// DDP calls always carry a userId and are still gated by the allow rule.
//
// The DDP `allow` rule in server/permissions/cardComments.js is the first
// gate, but the per-board `restrictCommentEditing` setting is enforced here
// so the rule cannot be bypassed and the decision lives next to the data.
const assertCanMutateComment = async (userId, doc) => {
// Server-internal operations (board copy, cleanup, migrations, etc.) run
// without an authenticated user; do not block those here. User-initiated
// DDP calls always carry a userId and are still gated by the allow rule.
if (!userId) {
return;
}
const isAuthor = userId === doc.userId;
if (isAuthor) {
return; // Authors may always edit/delete their own comments.
}
const board = await Boards.findOneAsync(doc.boardId);
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
throw new Meteor.Error(
'error-comment-edit-not-allowed',
"You are not allowed to edit or delete another user's comment on this board.",
);
}
};
// GHSA-pqr4-rxgp-hv2m: an HTTP handler is NOT a server-internal operation,
// but it reached the collection with no Meteor userId in the invocation
// context, so it landed on this trusted path — and every board member could
// delete anyone's comment through the REST API, restrictCommentEditing or
// not. The REST handlers now call this function themselves, with req.userId,
// BEFORE touching the collection (server/models/cardComments.js). The trusted
// path stays for the genuine internal callers it was written for.
if (!userId) {
return;
}
const isAuthor = userId === doc.userId;
if (isAuthor) {
return; // Authors may always edit/delete their own comments.
}
const board = await Boards.findOneAsync(doc.boardId);
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
const error = new Meteor.Error(
'error-comment-edit-not-allowed',
"You are not allowed to edit or delete another user's comment on this board.",
);
// So the REST layer answers 403 rather than 500 (httpStatusForError).
error.statusCode = 403;
throw error;
}
}
if (Meteor.isServer) {
CardComments.before.update(async (userId, doc) => {
await assertCanMutateComment(userId, doc);
});
+17 -10
View File
@@ -2614,17 +2614,24 @@ Cards.helpers({
// #3392: card-to-card dependencies ("Red Strings") only connect cards on
// the same board. When a card moves to another board, drop its now
// cross-board dependencies and remove inbound references to it from the
// cards left behind on the old board, so no dangling lines remain.
// cross-board dependencies. This half is part of the by-_id update below,
// so it is allowed from the client.
//
// The OTHER half - removing inbound references to this card from the cards
// left behind on the old board - needs a multi-document update with a
// compound selector, and it used to be done right here. This helper is
// called straight from client code (list.js, swimlanes.js, globalSearch.js,
// sidebarFilters.js, minicard.js), and Meteor's insecure-write rule only
// lets untrusted code updateAsync BY ID: every cross-board move therefore
// threw "Not permitted. Untrusted code may only updateAsync documents by
// ID" and never reached the move itself (#6572). addDependency,
// setDependencyProps and removeDependency above already follow that rule -
// this was the one place that did not.
//
// It is a Cards.after.update hook in server/models/cards.js now, where a
// selector is allowed, and being server-side it also covers the REST API
// and import paths, which never ran this helper at all.
mutatedFields.cardDependencies = [];
await Cards.updateAsync(
{
boardId: previousState.boardId,
'cardDependencies.cardId': this._id,
},
{ $pull: { cardDependencies: { cardId: this._id } } },
{ multi: true },
);
}
await Cards.updateAsync(this._id, { $set: mutatedFields });
-1
View File
@@ -1,6 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { isEmptyObject } from 'jquery';
import Activities from '/models/activities';
import Boards from './boards';
import Cards from '/models/cards';
+22 -1
View File
@@ -31,6 +31,19 @@ EventLog.attachSchema(
source: { type: String, optional: true }, // guard/module/test name (wekan… or sqlite…/ferretdb…)
cwe: { type: String, optional: true },
userId: { type: String, optional: true },
// WHO and FROM WHERE. A security event that says only "something was
// blocked" cannot be acted on: the admin needs the account and the address
// to decide whether to lock it, and to recognise the same actor across
// several events. Denormalised at write time on purpose - the username is
// what the account was CALLED when it tried, which a later rename must not
// rewrite, and a deleted account must not erase.
username: { type: String, optional: true },
ip: { type: String, optional: true },
// How many attempts this one row stands for. A canary sits on a path an
// attacker controls in a loop, so repeats inside a window are counted rather
// than each written (models/lib/canaryTokens.js): 1 for an ordinary event, N
// for a summary flush.
count: { type: Number, optional: true },
detail: { type: String, optional: true },
// The 'database' stream's own four fields (server/lib/databaseProblems.js).
// They MUST be declared here: collection2 cleans every insert against this
@@ -62,7 +75,10 @@ EventLogAcks.attachSchema(
// (MongoDB, or FerretDB over SQLite/PostgreSQL/MySQL/MariaDB/SAP HANA), what the
// message means, and what an admin should do about it. See
// models/lib/databaseErrors.js and server/lib/databaseProblems.js.
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database'];
// 'integrity' is what the FILESYSTEM said: a stored file that is not the file
// WeKan stored (server/lib/fileIntegrityScan.js), and whether this server
// stopped cleanly last time (server/lib/uptimeWatch.js).
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database', 'integrity'];
if (Meteor.isServer) {
// The Security/Speed/Tests report pages filter by `stream` and sort by `at`
@@ -97,6 +113,11 @@ if (Meteor.isServer) {
selector.$or = [
{ category: rx }, { bleed: rx }, { source: rx }, { detail: rx },
{ db: rx }, { kind: rx }, { type: rx }, { message: rx },
// WHO and FROM WHERE are the two things an admin looking at a security
// event actually wants to pivot on: every other event from this address,
// every other event from this account. Searching the table for either
// has to find the rows that DISPLAY it, so both columns are searched.
{ username: rx }, { ip: rx },
];
}
return selector;
+59 -4
View File
@@ -61,6 +61,29 @@ export function writeWithBackpressure(res, str) {
});
}
// GHSA-4mxf-m8pq-xc9p: an avatar's `versions.original.path` comes out of the
// DATABASE, and the export below reads it off disk and embeds the bytes as
// base64. The avatar allow rule now refuses to let a client write that field
// (server/permissions/avatars.js), and this is the second lock: a path is only
// read when it resolves to somewhere inside WeKan's own storage, so a path
// poisoned any other way - an old document written before the fix, a restored
// backup, a bad migration - still cannot turn an export into a file read.
//
// The roots are computed the same way models/avatars.js and models/attachments.js
// compute their storagePath, via the shared helper both of them are kept in sync
// with.
export function avatarStorageRoots() {
const { computeStoragePaths } = require('./lib/attachmentStoragePath');
const paths = computeStoragePaths(process.env.WRITABLE_PATH);
return [paths.avatars, paths.attachments];
}
// True when `storedPath` may be read. Exported so the guard itself is testable.
export function isReadableStoredFilePath(storedPath, roots) {
const { isPathInsideAny } = require('./lib/storagePathContainment');
return isPathInsideAny(storedPath, roots || avatarStorageRoots());
}
// exporter maybe is broken since Gridfs introduced, add fs and path
export class Exporter {
constructor(boardId, attachmentId, options = {}) {
@@ -117,12 +140,33 @@ export class Exporter {
buffer.fill(0);
// callback has the form function (err, res) {}
// GHSA-4mxf-m8pq-xc9p: this is the ONE place the export turns a stored
// path into bytes, for attachments and avatars alike, so the containment
// check belongs here as well as at the call sites. A path that does not
// resolve inside WeKan's storage is not a file this export may read.
const storedPath = doc?.versions?.original?.path;
if (!isReadableStoredFilePath(storedPath)) {
if (process.env.DEBUG === 'true') {
console.warn('Refused to export a file stored outside the storage root:', storedPath);
}
// A canary, not a guess: every path WeKan writes is inside the storage
// root, so one that is not was put there by something that should not
// have. The export continues without the file, exactly as before.
try {
const { tripCanary } = require('/server/lib/canary');
tripCanary('export.path-outside-storage', { detail: 'during board export' });
} catch (e) { /* never break an export to report on it */ }
callback(null, null);
return;
}
const tmpFile = path.join(
os.tmpdir(),
`tmpexport${process.pid}${Math.random()}`,
);
const tmpWriteable = fs.createWriteStream(tmpFile);
const readStream = fs.createReadStream(doc.versions.original.path);
const readStream = fs.createReadStream(storedPath);
readStream.on('data', function (chunk) {
buffer = Buffer.concat([buffer, chunk]);
});
@@ -295,7 +339,10 @@ export class Exporter {
const m = localUrl.match(/\/(?:cdn\/storage\/avatars|cfs\/files\/avatars)\/([^/?#]+)/);
if (m && m[1]) {
const avatar = await ReactiveCache.getAvatar(m[1]);
if (avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path) {
const avatarPath = avatar?.versions?.original?.path;
// GHSA-4mxf-m8pq-xc9p: only read it when it is really inside WeKan's
// own storage. A path pointing anywhere else is not an avatar.
if (avatarPath && isReadableStoredFilePath(avatarPath)) {
const file = await getBase64DataAsync(avatar);
if (file) {
user.profile = user.profile || {};
@@ -434,7 +481,13 @@ export class Exporter {
if (!this._excludeAttachments) {
await w(',"file":"');
const filePath = att.versions && att.versions.original && att.versions.original.path;
if (filePath && fs.existsSync(filePath)) {
// GHSA-4mxf-m8pq-xc9p class: the ATTACHMENT half of the same read. The
// attachment allow rule refuses a client-supplied path, so this is not
// the reported hole - but a path is only as trustworthy as every way it
// could have been written, and this streaming export is the one
// remaining place that read a stored path with nothing but an
// existsSync. Same containment check as the avatars above.
if (filePath && isReadableStoredFilePath(filePath) && fs.existsSync(filePath)) {
await new Promise((resolve, reject) => {
const rs = fs.createReadStream(filePath);
let leftover = Buffer.alloc(0);
@@ -512,7 +565,9 @@ export class Exporter {
if (m && m[1]) {
const avatar = await avatarsRaw.findOne({ _id: m[1] });
const p = avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path;
if (p) {
// GHSA-4mxf-m8pq-xc9p: same containment check as the non-streaming
// export above - never read a stored path from outside the storage.
if (p && isReadableStoredFilePath(p)) {
try {
const buf = fs.readFileSync(p);
user.profile = user.profile || {};
+50
View File
@@ -0,0 +1,50 @@
import { Mongo } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
// ============================================================================
// The filesystem storage BASELINE (design: docs/Security/Remediation/WeKan.md §13).
//
// One document per stored file: what it was called, how big it was, when it was
// last modified, its md5/sha256/sha512, and an ed25519 signature over all of
// that. server/lib/fileIntegrityScan.js writes it and re-checks it once a day.
//
// In the EXISTING WeKan database, like every other part of this subsystem - no
// new files under WRITABLE_PATH, nothing extra to back up or to forget to back
// up, and it works the same on FerretDB and MongoDB.
//
// Deliberately NOT schema-attached: `digests` is a small open map, and the point
// of this collection is to be a faithful record of what was observed rather than
// something a schema might quietly clean fields out of - which is exactly the
// bug that once emptied the database-problems rows (models/eventLog.js).
// ============================================================================
const FileIntegrity = new Mongo.Collection('fileIntegrity');
// The signing key, and the scan's own bookkeeping (when it last ran). Separate
// from the baseline so a full re-baseline never touches the key.
export const IntegrityKeys = new Mongo.Collection('fileIntegrityKeys');
if (Meteor.isServer) {
const { ensureIndex } = require('/server/lib/mongoStartup');
Meteor.startup(async () => {
// The scan reads by _id (the path) and sweeps by `at`.
await ensureIndex(FileIntegrity, { at: -1 });
});
// NEVER published. The baseline is a map of every file on the server and the
// key document is a private key; neither has any business on a client, not
// even an admin's. Admin Panel reads the FINDINGS, which are ordinary event
// log rows, not this.
FileIntegrity.deny({
insert: () => true,
update: () => true,
remove: () => true,
});
IntegrityKeys.deny({
insert: () => true,
update: () => true,
remove: () => true,
});
}
export default FileIntegrity;
+2
View File
@@ -101,6 +101,7 @@ const ADMIN_PAGES = {
cards: 'report-cards',
recovery: 'report-recovery',
database: 'report-database',
integrity: 'report-integrity',
},
},
attachments: {
@@ -173,6 +174,7 @@ const ADMIN_PANE_TITLES = {
cards: { titleKey: 'cardsReportTitle' },
recovery: { titleKey: 'recoveryReportTitle' },
database: { titleKey: 'databaseReportTitle' },
integrity: { titleKey: 'integrityReportTitle' },
},
attachments: {
backup: { titleKey: 'backup' },
+64
View File
@@ -0,0 +1,64 @@
// Pure, dependency-free construction of the Mongo `$or` that answers "which
// boards may this user see". No Meteor imports, so it is unit tested directly
// with plain Node (tests/boardVisibilitySelectors.test.cjs).
//
// GHSA-gwc4-fw7p-gw58: this was written out TWICE, and the two copies disagreed.
// Boards.userBoards() - the All Boards list - matched org/team/domain shares
// with `$elemMatch: { ..., isActive: true }`, but the `board` publication (the
// one that sends the board and all its cards) matched them with a dotted
// `'orgs.orgId': { $in: [...] }`, which ignores isActive entirely. isActive is
// the REVOKE switch: setting a share to `isActive: false` made the board vanish
// from All Boards, as it should, while a user who still knew the boardId could
// subscribe to it and receive the whole private board. A revoke that the primary
// data publication does not honour is not a revoke.
//
// So there is one builder now, and both callers use it. A share entry counts
// only while it is active, everywhere.
/**
* @param {object} args
* @param {string} [args.userId] the subscriber; omitted/falsy means anonymous
* @param {string[]} [args.orgIds] organisations the user belongs to
* @param {string[]} [args.teamIds] teams the user belongs to
* @param {string[]} [args.emailDomains] the user's verified email domains (#5850)
* @param {boolean} [args.includePublic=true] include `{ permission: 'public' }`,
* which is not a relationship to the user at all but "anybody may open
* this" - wanted in a board LIST, not in a search over all boards.
* @return {object[]} the `$or` clauses, in a stable order
*/
function boardVisibilitySelectors({
userId,
orgIds = [],
teamIds = [],
emailDomains = [],
includePublic = true,
} = {}) {
const selectors = [];
if (includePublic) {
selectors.push({ permission: 'public' });
}
if (!userId) {
return selectors;
}
const clean = (values) =>
Array.isArray(values) ? values.filter((v) => typeof v === 'string' && v) : [];
selectors.push({ members: { $elemMatch: { userId, isActive: true } } });
selectors.push({
orgs: { $elemMatch: { orgId: { $in: clean(orgIds) }, isActive: true } },
});
selectors.push({
teams: { $elemMatch: { teamId: { $in: clean(teamIds) }, isActive: true } },
});
// #5850: domain-based board sharing — board shared with the user's email domain.
selectors.push({
domains: { $elemMatch: { domain: { $in: clean(emailDomains) }, isActive: true } },
});
return selectors;
}
module.exports = { boardVisibilitySelectors };
+346
View File
@@ -0,0 +1,346 @@
'use strict';
// ============================================================================
// Canary tokens — the pure half
// (design: docs/Security/Remediation/WeKan.md §12)
// ----------------------------------------------------------------------------
// A CANARY is a tripwire placed at a point where somebody could TRY to override
// permissions. Ordinary users never reach it: the code path only runs when a
// request asks for something the rules forbid. So a canary trip is not noise -
// it is somebody probing, and it is worth an admin's attention with WHO and
// FROM WHERE attached.
//
// Three properties, and they are the whole point:
//
// 1. SILENT. Tripping a canary changes nothing the caller can observe. The
// trip function returns `false` so a guard can `return tripCanary(...)` in
// place of `return false`, and the attacker gets the same refusal, with the
// same timing and the same message, as before the canary existed. Nothing
// is ever returned, thrown, delayed or logged back to them.
// 2. BOUNDED. A canary sits on a path an attacker controls and can hit in a
// loop. Writing one database row per attempt is a denial-of-service the
// attacker gets for free - CPU on every insert, and a table that grows
// until the disk is full. So the FIRST trip in a window is recorded and the
// rest are COUNTED, then flushed as a single summary event when the window
// closes. The counter map itself is capped, so memory is bounded too.
// 3. ATTRIBUTED. Every trip carries the actor - userId, username and client IP
// address - so Admin Panel → Problems → Security answers "who tried this,
// and from where", not just "something happened".
//
// This file is pure and dependency-free (no Meteor, no database, no clock of its
// own), so it is unit tested with plain Node: tests/canaryTokens.test.cjs. The
// runtime half - resolving the actor, applying this limiter, writing the event -
// is server/lib/canary.js.
// ============================================================================
// ---------------------------------------------------------------- the catalog
//
// One entry per tripwire. `key` is the existing securityCategories key, so a
// canary's category, *Bleed name, severity and CWE come from the SAME catalog
// the other guards use and cannot drift from it (models/lib/securityCategories.js).
//
// `what` is the sentence an admin reads. It says what was ATTEMPTED, in the
// terms of the feature, because "authz.board blocked" is not something anyone
// can act on.
const CANARIES = {
'card.cross-board-move': {
key: 'authz.board',
what: 'tried to move a card into a board they cannot write to',
},
'card.invisible-parent': {
key: 'authz.board',
what: 'tried to set a card parent on a board they cannot see',
},
'list.cross-board-move': {
key: 'authz.board',
what: 'tried to move a list into a board they cannot write to',
},
'swimlane.cross-board-move': {
key: 'authz.board',
what: 'tried to move a swimlane into a board they cannot write to',
},
'checklist.cross-board-move': {
key: 'authz.checklist',
what: 'tried to move a checklist into a board they cannot write to',
},
'checklist-item.cross-board-move': {
key: 'authz.checklist',
what: 'tried to move a checklist item into a board they cannot write to',
},
'attachment.version-path': {
key: 'authz.file-path',
what: 'tried to write the on-disk path of an attachment',
},
'avatar.version-path': {
key: 'authz.file-path',
what: 'tried to write the on-disk path of an avatar',
},
'attachment.restricted-field': {
key: 'authz.file-path',
what: 'tried to write a restricted attachment field',
},
'avatar.restricted-field': {
key: 'authz.file-path',
what: 'tried to write a restricted avatar field',
},
'avatar.not-owner': {
key: 'authz.file-path',
what: "tried to change another user's avatar",
},
'comment.foreign-delete': {
key: 'authz.comment',
what: "tried to delete another user's comment over the REST API",
},
'reaction.foreign': {
key: 'authz.comment',
what: "tried to add or remove another user's comment reaction",
},
'export.path-outside-storage': {
key: 'authz.file-path',
what: 'a stored file path pointed outside the storage root and was not read',
},
'card.vote-field': {
key: 'authz.board',
what: 'tried to write a vote field directly instead of through the method',
},
'card.poker-field': {
key: 'authz.board',
what: 'tried to write a poker field directly instead of through the method',
},
'database.canary': {
key: 'authz.database',
what: 'the database refused an operation that WeKan never issues',
},
// --- Injection ------------------------------------------------------------
// WeKan builds no SQL, so a SQL canary can only come from the database's own
// last-look guard (docs/Security/Remediation/FerretDB.md §3b). NoSQL is the
// one WeKan can see for itself: a selector that carries an execution
// operator, or an operator object where the value a user typed belongs.
'injection.nosql-selector': {
key: 'injection.nosql',
what: 'sent a query carrying a database-side execution operator',
},
'injection.nosql-operator': {
key: 'injection.nosql',
what: 'sent a query operator where a plain value was expected',
},
'injection.sql-statement': {
key: 'injection.sql',
what: 'the database refused a statement that looked like injected SQL',
},
// --- Sanitization ---------------------------------------------------------
// Sanitizing is routine - a filename gets trimmed, a document gets its
// formatting normalised - and routine is not worth an admin's attention. What
// IS worth it is sanitization that removed something DANGEROUS: markup that
// would have executed, a path that would have escaped its directory, a type
// that lied about itself. Only those trip.
'sanitize.dangerous-filename': {
key: 'file.name',
what: 'uploaded a filename carrying an exploit pattern',
},
'sanitize.path-traversal': {
key: 'file.name',
what: 'used a file path that tried to escape its directory',
},
'sanitize.dangerous-content': {
key: 'file.content',
what: 'uploaded a file whose content had to have active markup removed',
},
'sanitize.dangerous-text': {
key: 'xss.input',
what: 'submitted text that had to have active markup removed',
},
// --- Other common attacks -------------------------------------------------
'spoof.forwarded-header': {
key: 'spoofing.xff',
what: 'sent a forwarded-for header that is not trusted here',
},
'brute.login-lockout': {
key: 'brute.login',
what: 'was locked out after repeated failed logins',
},
};
// The id used when a caller passes something not in the catalog. A canary with
// an unknown id must still be RECORDED - losing the event would be the worst
// possible failure mode for a tripwire - it is simply recorded generically.
const UNKNOWN_CANARY = {
key: 'authz.canary',
what: 'tripped a canary',
};
/**
* Look up a canary. Never throws and never returns undefined.
* @param {string} id
* @return {{key: string, what: string, id: string, known: boolean}}
*/
function canaryFor(id) {
const entry = (typeof id === 'string' && CANARIES[id]) || null;
return {
id: typeof id === 'string' && id ? id : 'unknown',
key: (entry || UNKNOWN_CANARY).key,
what: (entry || UNKNOWN_CANARY).what,
known: !!entry,
};
}
/** Every catalogued canary id, for the tests and the docs. */
function canaryIds() {
return Object.keys(CANARIES).sort();
}
// ------------------------------------------------------------- the rate limit
const DEFAULTS = {
// How long one (canary, actor) pair is aggregated for. The first trip in a
// window is recorded immediately - an admin should see a probe promptly - and
// everything else in the window is counted.
windowMs: 60 * 1000,
// How many (canary, actor) pairs are tracked at once. An attacker can vary the
// actor (a fresh IP per request over a botnet), so this map is the thing that
// would grow without a cap. At the cap the OLDEST entry is dropped: losing the
// count of an old, already-reported probe is much cheaper than unbounded
// memory, and the first trip of every pair was recorded regardless.
maxTracked: 5000,
// A hard ceiling on how many events one pair can produce, however long the
// attack runs: after this many summaries the pair keeps counting but stops
// writing until it goes quiet for a window. Prevents a slow-but-endless probe
// from writing one row a minute forever.
maxEventsPerPair: 60,
};
/**
* Decides, for each trip, whether to WRITE an event now and what count it
* carries. Pure: the clock is passed in, so the tests drive time directly.
*
* The contract:
* - the first trip of a (canary, actor) pair writes immediately, count 1;
* - further trips inside the window write nothing and are counted;
* - the first trip AFTER the window closes writes a summary carrying the
* suppressed count, and opens a new window;
* - a pair that has already written `maxEventsPerPair` summaries stops
* writing until it goes quiet for a whole window;
* - the tracking map never exceeds `maxTracked` entries.
*/
class CanaryRateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || DEFAULTS.windowMs;
this.maxTracked = options.maxTracked || DEFAULTS.maxTracked;
this.maxEventsPerPair = options.maxEventsPerPair || DEFAULTS.maxEventsPerPair;
// Insertion-ordered, which is what makes the oldest entry cheap to evict.
this._pairs = new Map();
}
/**
* @param {string} pairKey identifies (canary, actor)
* @param {number} now epoch ms
* @return {{write: boolean, count: number, suppressed: number}}
* `write` - record an event now. `count` - how many trips it stands for.
* `suppressed` - how many of those were not individually recorded.
*/
consider(pairKey, now) {
const key = typeof pairKey === 'string' && pairKey ? pairKey : 'unknown';
const at = typeof now === 'number' && isFinite(now) ? now : 0;
const existing = this._pairs.get(key);
if (!existing) {
this._evictIfFull();
this._pairs.set(key, { windowStart: at, seen: 1, written: 1, lastSeen: at });
return { write: true, count: 1, suppressed: 0 };
}
// Re-insert so the map stays ordered by RECENCY, not by first sighting;
// otherwise a long-running attacker would be evicted before a one-off probe.
this._pairs.delete(key);
this._pairs.set(key, existing);
existing.lastSeen = at;
const windowOpen = at - existing.windowStart < this.windowMs;
if (windowOpen) {
existing.seen += 1;
return { write: false, count: existing.seen, suppressed: existing.seen - 1 };
}
// The window closed. Flush what it accumulated, then start a new one.
const suppressed = Math.max(0, existing.seen - 1);
existing.windowStart = at;
const total = existing.seen + 1;
existing.seen = 1;
if (existing.written >= this.maxEventsPerPair) {
// Still counting, no longer writing: an endless probe must not be able to
// write forever, and by now the admin has 60 events saying so.
return { write: false, count: total, suppressed };
}
existing.written += 1;
return { write: true, count: total, suppressed };
}
/** Drop pairs whose window closed long ago; called by the runtime's sweeper. */
sweep(now, idleMs) {
const cutoff = (typeof now === 'number' ? now : 0) - (idleMs || this.windowMs * 10);
let dropped = 0;
for (const [key, entry] of this._pairs) {
if (entry.lastSeen < cutoff) {
this._pairs.delete(key);
dropped += 1;
}
}
return dropped;
}
size() {
return this._pairs.size;
}
_evictIfFull() {
while (this._pairs.size >= this.maxTracked) {
const oldest = this._pairs.keys().next();
if (oldest.done) return;
this._pairs.delete(oldest.value);
}
}
}
/**
* The key one canary + one actor are aggregated under. An unauthenticated actor
* is keyed by IP alone; a logged-in one by userId, so switching IP mid-attack
* does not reset their counter.
* @param {string} canaryId
* @param {{userId?: string, ip?: string}} actor
* @return {string}
*/
function canaryPairKey(canaryId, actor = {}) {
const id = typeof canaryId === 'string' && canaryId ? canaryId : 'unknown';
const userId = actor && typeof actor.userId === 'string' && actor.userId ? actor.userId : '';
const ip = actor && typeof actor.ip === 'string' && actor.ip ? actor.ip : 'unknown-ip';
return userId ? `${id}u:${userId}` : `${id}i:${ip}`;
}
/**
* The `detail` line an event carries. Deliberately says what was attempted and
* how many times - never the payload, which is attacker-controlled and would
* turn the security log into an injection sink (the logger truncates and strips
* control characters as a second line of defence).
*/
function canaryDetail(canary, count, extra) {
const parts = [canary.what];
if (typeof extra === 'string' && extra) parts.push(extra);
if (count > 1) parts.push(`${count} attempts in this window`);
return parts.join('; ');
}
module.exports = {
CANARIES,
UNKNOWN_CANARY,
DEFAULTS,
CanaryRateLimiter,
canaryFor,
canaryIds,
canaryPairKey,
canaryDetail,
};
+35
View File
@@ -20,6 +20,41 @@ const DATABASES = ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'
// doing it is obviously right. Everything else is advice, because a database
// that is out of disk is not something an application should "handle".
const RULES = [
// ── WeKan's own bugs, arriving as database errors ─────────────────────────
//
// These two filled Admin Panel / Problems / Database problems with rows
// marked "unknown / unclassified", whose advice was "add a rule to
// models/lib/databaseErrors.js". They are not the database's fault and there
// is nothing an admin can configure: they are WeKan calling the database
// wrongly, so the rule says so and names the version that fixes it rather
// than sending the admin to read a stack trace.
{
id: 'meteor3-sync-api',
match: /(update|insert|remove|upsert|find\w*) is not available on the server\. Please use \w+Async\(\) instead/i,
databases: ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'],
severity: 'warning',
kind: 'bug',
means: 'WeKan called a synchronous collection method on the server, which Meteor 3 removed. ' +
'The operation did not happen.',
whatToDo: 'A WeKan bug, not a database or configuration problem. Upgrade WeKan; if it ' +
'persists on the newest version, report it at https://github.com/wekan/wekan/issues ' +
'with the method name from the message.',
act: null,
},
{
id: 'schema-validation',
match: /ValidationError: Failed validation|Cannot read properties of undefined \(reading 'title'\)/i,
databases: ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'],
severity: 'warning',
kind: 'bug',
means: 'A write was refused because the document did not match its schema - usually a ' +
'required field that was empty. The document was not saved.',
whatToDo: 'Check whether the item named in the message is missing a title or another ' +
'required field, and fill it in. If nothing obvious is missing, it is a WeKan bug: ' +
'report it at https://github.com/wekan/wekan/issues with the method name.',
act: null,
},
// ── injection and malformed SQL: FerretDB's bug, never the admin's ────────
{
id: 'sql-guard-refused',
+298
View File
@@ -0,0 +1,298 @@
'use strict';
// ============================================================================
// Filesystem storage integrity — the pure half
// (design: docs/Security/Remediation/WeKan.md §13)
// ----------------------------------------------------------------------------
// WeKan's attachments and avatars live as files under WRITABLE_PATH, and the
// database holds one document per file. Nothing checks that the two still agree.
// A file can be replaced, truncated, back-dated or deleted by anything that
// reaches the filesystem - a bad restore, a sync tool, a container rebuild, or
// somebody who got in - and WeKan would keep serving whatever is there now.
//
// So a BASELINE is recorded per file (name, size, modification time, and three
// digests), signed, and re-checked. What makes a finding worth an admin's
// attention is not that a file changed - files change when people edit
// boards - but that it changed WITH NO RECORD SAYING WHY.
//
// This file is pure: no filesystem, no database, no clock, no crypto. It is
// given what was recorded and what was observed and says what that means, so it
// is unit tested with plain Node (tests/fileIntegrity.test.cjs). The scanning,
// the digests and the signature are server/lib/fileIntegrityScan.js.
// ============================================================================
// The digests kept per file. Three, not one, because they answer different
// questions:
//
// md5 - fast, and what most other tools print, so an admin can compare
// against a backup with the tool they already use. Broken for
// collision RESISTANCE, which is why it is never alone here.
// sha256 - the working digest.
// sha512 - a second, different-width digest. Two independent digests
// disagreeing about the same bytes means something is wrong with
// the READING (a failing disk, a truncated copy), not merely with
// the file - and that is a different problem from a substitution.
const DIGESTS = ['md5', 'sha256', 'sha512'];
// What a finding can be. Ordered from "the file is gone" to "somebody re-signed
// the record", which is roughly increasing seriousness.
const FINDINGS = {
MISSING: 'missing', // recorded, and not on disk any more
UNTRACKED: 'untracked', // on disk, and never recorded
SIZE: 'size-changed',
MTIME: 'mtime-changed',
CONTENT: 'content-changed', // every digest changed: different bytes
DIGEST_DISAGREE: 'digests-disagree', // some digests changed and some did not
SIGNATURE: 'signature-invalid', // the RECORD was tampered with
};
const SEVERITY = {
[FINDINGS.MISSING]: 'high',
[FINDINGS.UNTRACKED]: 'low',
[FINDINGS.SIZE]: 'medium',
[FINDINGS.MTIME]: 'low',
[FINDINGS.CONTENT]: 'high',
[FINDINGS.DIGEST_DISAGREE]: 'critical',
[FINDINGS.SIGNATURE]: 'critical',
};
/**
* The exact bytes that get signed for one baseline entry.
*
* Canonical and explicit: a fixed field order, a separator that cannot appear
* in any field, and no JSON (whose key order and escaping are not guaranteed
* stable across versions). If this string is not reproducible byte for byte,
* every signature verification fails a year from now for no reason.
*
* @param {object} entry
* @return {string}
*/
function manifestLine(entry) {
const e = entry || {};
const digests = DIGESTS.map(name => `${name}=${(e.digests && e.digests[name]) || ''}`);
return [
`path=${e.path || ''}`,
`size=${Number.isFinite(e.size) ? e.size : ''}`,
// Milliseconds since the epoch, as an integer: a formatted date is a
// locale and a timezone away from being unstable.
`mtime=${Number.isFinite(e.mtimeMs) ? Math.floor(e.mtimeMs) : ''}`,
...digests,
].join('\n');
}
/**
* Compare one recorded baseline against what was just observed.
*
* @param {object|null} baseline what was recorded (null = never recorded)
* @param {object|null} observed what is on disk now (null = not there)
* @param {object} [options]
* @param {boolean} [options.signatureValid=true] result of verifying the
* baseline's own signature; false means the RECORD was edited
* @return {Array<{finding: string, severity: string, detail: string}>}
*/
function compareEntry(baseline, observed, options = {}) {
const findings = [];
if (options.signatureValid === false) {
findings.push({
finding: FINDINGS.SIGNATURE,
severity: SEVERITY[FINDINGS.SIGNATURE],
detail: 'the recorded baseline does not match its signature',
});
}
if (!baseline && !observed) return findings;
if (baseline && !observed) {
findings.push({
finding: FINDINGS.MISSING,
severity: SEVERITY[FINDINGS.MISSING],
detail: 'a recorded file is no longer on disk',
});
return findings;
}
if (!baseline && observed) {
findings.push({
finding: FINDINGS.UNTRACKED,
severity: SEVERITY[FINDINGS.UNTRACKED],
detail: 'a file is in the storage directory that WeKan never stored',
});
return findings;
}
// Digests first: they are what actually decides whether the bytes changed.
const changed = DIGESTS.filter(name => {
const was = baseline.digests && baseline.digests[name];
const now = observed.digests && observed.digests[name];
// A digest that was never recorded, or was not computed now, says nothing.
return was && now && was !== now;
});
const compared = DIGESTS.filter(name => {
const was = baseline.digests && baseline.digests[name];
const now = observed.digests && observed.digests[name];
return was && now;
});
if (changed.length && changed.length === compared.length) {
findings.push({
finding: FINDINGS.CONTENT,
severity: SEVERITY[FINDINGS.CONTENT],
detail: `the file's contents changed (${changed.join(', ')})`,
});
} else if (changed.length) {
// Two digests over the same bytes cannot disagree. If they do, the bytes
// were not read the same way twice - a failing disk, a partial write, a
// truncated copy - or somebody updated one digest and not the others.
findings.push({
finding: FINDINGS.DIGEST_DISAGREE,
severity: SEVERITY[FINDINGS.DIGEST_DISAGREE],
detail: `digests disagree: ${changed.join(', ')} changed, ` +
`${compared.filter(n => !changed.includes(n)).join(', ')} did not`,
});
}
if (Number.isFinite(baseline.size) && Number.isFinite(observed.size) &&
baseline.size !== observed.size) {
findings.push({
finding: FINDINGS.SIZE,
severity: SEVERITY[FINDINGS.SIZE],
detail: `size ${baseline.size} → ${observed.size} bytes`,
});
}
// A modification time that moved while the contents did not is worth a low
// note and nothing more: a copy, a restore or a backup tool does it. A time
// that moved BACKWARDS is different - nothing does that by accident - so it
// is called out even though it is the same finding.
if (Number.isFinite(baseline.mtimeMs) && Number.isFinite(observed.mtimeMs) &&
Math.floor(baseline.mtimeMs) !== Math.floor(observed.mtimeMs)) {
const backwards = observed.mtimeMs < baseline.mtimeMs;
findings.push({
finding: FINDINGS.MTIME,
severity: backwards ? 'medium' : SEVERITY[FINDINGS.MTIME],
detail: backwards
? 'the modification time moved BACKWARDS'
: 'the modification time changed',
});
}
return findings;
}
/**
* Was this change EXPLAINED?
*
* A file changing is ordinary; WeKan changes files when people use it. What is
* not ordinary is a change with nothing in the record to account for it, and
* that is the finding an admin is actually being warned about.
*
* @param {Array} findings from compareEntry
* @param {boolean} hasRecentRecord did WeKan record an operation on this file
* around the time it changed?
* @return {{explained: boolean, severity: string, summary: string}}
*/
function classifyChange(findings, hasRecentRecord) {
if (!Array.isArray(findings) || findings.length === 0) {
return { explained: true, severity: 'info', summary: 'unchanged' };
}
const worst = findings.reduce((acc, f) => {
const order = ['info', 'low', 'medium', 'high', 'critical'];
return order.indexOf(f.severity) > order.indexOf(acc) ? f.severity : acc;
}, 'info');
// A tampered RECORD is never explained by an ordinary operation: WeKan does
// not rewrite a baseline without re-signing it.
const signature = findings.some(f => f.finding === FINDINGS.SIGNATURE);
if (hasRecentRecord && !signature) {
return {
explained: true,
severity: 'info',
summary: `${findings.map(f => f.finding).join(', ')} — WeKan has a record of this`,
};
}
return {
explained: false,
// An unexplained change is at least a medium: the point of the scan is that
// nobody knows why it happened.
severity: worst === 'low' || worst === 'info' ? 'medium' : worst,
summary: `${findings.map(f => f.finding).join(', ')} — with no record saying why`,
};
}
// ---------------------------------------------------------------- the pacing
//
// The scan reads every stored byte, which on a busy instance is the last thing
// that should compete with users. So it is paced by policy rather than by hope:
// skip entirely while the machine is busy, sleep between files, and stop after
// a budget rather than running to completion at any cost.
const PACING = {
// Above this, do not start and do not continue. The scan is never urgent.
maxCpuPercent: 60,
// Between files. Small, but it is what turns a tight read loop into a
// background trickle.
pauseMsPerFile: 50,
// ...and proportional to the file, so a directory of large files does not
// become a sustained read.
pauseMsPerMegabyte: 20,
// Stop after this long and continue tomorrow. A scan that never finishes is
// better than a scan that holds a machine down.
maxRunMs: 15 * 60 * 1000,
// How long between runs. Once a day.
intervalMs: 24 * 60 * 60 * 1000,
};
/**
* What to do before reading the next file.
* @param {object} state
* @param {number} state.cpuPercent current CPU load, 0-100
* @param {number} state.elapsedMs how long this run has been going
* @param {number} state.fileSize the next file's size in bytes
* @param {object} [pacing] overrides for the constants above
* @return {{action: 'stop'|'pause'|'go', pauseMs: number, reason: string}}
*/
function nextStep(state = {}, pacing = PACING) {
const cpu = Number(state.cpuPercent);
const elapsed = Number(state.elapsedMs) || 0;
const size = Number(state.fileSize) || 0;
if (elapsed >= pacing.maxRunMs) {
return { action: 'stop', pauseMs: 0, reason: 'this run has used its time; continuing next time' };
}
if (Number.isFinite(cpu) && cpu >= pacing.maxCpuPercent) {
return { action: 'stop', pauseMs: 0, reason: `CPU at ${Math.round(cpu)}%; not competing with users` };
}
const megabytes = size / (1024 * 1024);
const pauseMs = Math.round(pacing.pauseMsPerFile + megabytes * pacing.pauseMsPerMegabyte);
return { action: 'pause', pauseMs, reason: '' };
}
/** Is it time for another run? */
function isDue(lastRunAtMs, nowMs, pacing = PACING) {
const last = Number(lastRunAtMs);
const now = Number(nowMs);
// `Number(null)` is 0, which is finite - so an unreadable clock would have
// read as "the epoch" and started a scan. A clock this side of 1970 is not a
// clock, and a scan is never urgent enough to run on a guess.
if (!Number.isFinite(now) || now <= 0) return false;
if (!Number.isFinite(last) || last <= 0) return true;
return now - last >= pacing.intervalMs;
}
module.exports = {
DIGESTS,
FINDINGS,
SEVERITY,
PACING,
manifestLine,
compareEntry,
classifyChange,
nextStep,
isDue,
};
+5 -12
View File
@@ -31,18 +31,11 @@ export {
const { sanitizeFilename } = require('./filenameSanitizer');
const { hasEnoughDiskSpace } = require('./diskSpace');
function normalizeForCompare(inputPath) {
const normalized = path.resolve(inputPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function isPathInside(basePath, targetPath) {
const normalizedBase = normalizeForCompare(basePath);
const normalizedTarget = normalizeForCompare(targetPath);
const relative = path.relative(normalizedBase, normalizedTarget);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
// GHSA-4mxf-m8pq-xc9p: this containment check used to be written out here and
// nowhere else, so the board exporter - which reads the very same stored paths -
// had none. It lives in models/lib/storagePathContainment.js now, where the
// exporter uses it too and where plain Node can test it.
const { isPathInsideBase: isPathInside } = require('./storagePathContainment');
function tryRealPath(inputPath) {
try {
+82
View File
@@ -0,0 +1,82 @@
// Pure, dependency-free guards for the `versions` metadata of a stored file
// (ostrio:files: `versions.<name>.path` / `.storage`), so both the attachment
// and the avatar permission rules can use the SAME rule and cannot drift apart.
//
// GHSA-4mxf-m8pq-xc9p: attachments blocked client-supplied `versions.*.path`,
// avatars did not - their allow rule was `update: isOwner` with no field
// restriction at all. An avatar owner could therefore point
// `versions.original.path` at any file the WeKan process can read (/etc/passwd,
// anything under WRITABLE_PATH) and then export a board they are a member of:
// the exporter reads that path from disk and embeds the bytes as base64 in
// `profile.avatarFile`. That is an arbitrary file read for any authenticated
// user, so the rule now lives in ONE place and is applied to both collections.
//
// `path` and `storage` are server-managed: they say WHERE on disk the bytes
// are. A client never has a reason to set or change them, on any collection.
const SERVER_MANAGED_VERSION_FIELDS = ['path', 'storage'];
/**
* True when a document a client is trying to INSERT carries server-managed
* version metadata (`versions.<any>.path` or `.storage`).
* @param {object} fileObj the document as the client sent it
* @return {boolean}
*/
function hasUnsafeClientVersionFields(fileObj) {
const versions = fileObj && fileObj.versions;
if (!versions || typeof versions !== 'object') {
return false;
}
return Object.values(versions).some((version) => {
if (!version || typeof version !== 'object') {
return false;
}
return SERVER_MANAGED_VERSION_FIELDS.some((field) =>
Object.prototype.hasOwnProperty.call(version, field),
);
});
}
/**
* True when an UPDATE touches the `versions` subtree at all. Deliberately
* broader than hasUnsafeClientVersionFields: an update arrives as a list of
* modified field names, and `versions.original` (the whole sub-object, path
* included) is as dangerous as `versions.original.path` spelled out.
* @param {string[]} fields the modified field names Meteor passes to allow()
* @return {boolean}
*/
function touchesVersionFields(fields) {
if (!Array.isArray(fields)) {
return false;
}
return fields.some(
(field) =>
typeof field === 'string' &&
(field === 'versions' || field.startsWith('versions.')),
);
}
/**
* True when every modified field name is in `allowedFields` (comparing the
* part before the first dot, so `meta.boardId` is allowed by `meta`).
* @param {string[]} fields the modified field names
* @param {string[]} allowedFields the whitelist
* @return {boolean}
*/
function onlyTouchesAllowedFields(fields, allowedFields) {
if (!Array.isArray(fields)) {
return false;
}
return fields.every((field) => {
if (typeof field !== 'string') return false;
return allowedFields.includes(field.split('.')[0]);
});
}
module.exports = {
SERVER_MANAGED_VERSION_FIELDS,
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
};
+222
View File
@@ -0,0 +1,222 @@
'use strict';
// ============================================================================
// Injection detection — the pure half
// (design: docs/Security/Remediation/WeKan.md §12.6)
// ----------------------------------------------------------------------------
// Two shapes of NoSQL injection reach a Meteor app, and they are different
// problems:
//
// 1. A client-supplied SELECTOR carrying an operator that makes the DATABASE do
// something: `$where`, `$function`, `$accumulator` (server-side JavaScript),
// `$out`/`$merge` (write the result somewhere). A selector is data; these
// turn it into code.
//
// 2. An OPERATOR OBJECT where a scalar was expected. This is the classic one:
// a field that should hold the string a user typed arrives as
// `{"$ne": null}` or `{"$gt": ""}` or `{"$regex": ".*"}`, and a comparison
// that was meant to match one value now matches every value. It is how
// "log in as anybody" is spelled in a document database, and it needs no
// JavaScript at all.
//
// Both are detected here, purely - no Meteor, no database - so they are unit
// tested with plain Node (tests/injectionDetect.test.cjs) and so the same
// functions can be used on any input path.
//
// SQL injection is NOT detected here: WeKan never builds SQL. The database does,
// and it has its own last-look guard (FerretDB internal/util/sqlguard), which
// refuses the statement and marks the refusal so WeKan can record it -
// docs/Security/Remediation/FerretDB.md §3b.
// ============================================================================
// Operators that make the database EXECUTE something rather than compare.
// Presence of any of these in a client-supplied selector is not a query.
const EXECUTION_OPERATORS = [
'$where', // server-side JavaScript
'$function', // aggregation JavaScript
'$accumulator', // aggregation JavaScript
'$out', // write the result into a collection
'$merge', // ...or merge it into one
];
// Operators that are legitimate in a query but NOT in a place that expects the
// scalar a user typed. `{"$ne": null}` in a username field is an attack; the
// same operator built by WeKan's own code is ordinary.
const COMPARISON_OPERATORS = [
'$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin',
'$regex', '$not', '$exists', '$type', '$expr', '$elemMatch',
];
const MAX_DEPTH = 12;
/**
* Every execution operator found anywhere in a value, deduplicated and sorted.
* Walks objects and arrays; bounded in depth so a deeply nested payload cannot
* be used to burn CPU here.
* @param {*} value
* @return {string[]}
*/
function executionOperatorsIn(value) {
const found = new Set();
const walk = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== 'object') return;
if (Array.isArray(node)) {
node.forEach(item => walk(item, depth + 1));
return;
}
Object.keys(node).forEach(key => {
if (EXECUTION_OPERATORS.includes(key)) found.add(key);
walk(node[key], depth + 1);
});
};
walk(value, 0);
return [...found].sort();
}
/**
* True when `value` is an OPERATOR OBJECT - a plain object whose keys are Mongo
* operators - in a position that expected a scalar.
*
* Deliberately strict about what counts: EVERY key must be an operator. A plain
* object with ordinary keys is somebody sending the wrong type, which is a
* validation error and not an attack, and calling it one would fill the security
* report with noise.
*
* @param {*} value
* @return {boolean}
*/
function isOperatorObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
if (keys.length === 0) return false;
const known = COMPARISON_OPERATORS.concat(EXECUTION_OPERATORS);
return keys.every(key => known.includes(key));
}
/**
* The operators inside such an object, for the report. Empty when it is not one.
* @param {*} value
* @return {string[]}
*/
function operatorsOf(value) {
return isOperatorObject(value) ? Object.keys(value).sort() : [];
}
/**
* Classify one client-supplied value that should have been a scalar.
*
* @param {*} value the value as it arrived
* @return {{injection: boolean, kind: string, operators: string[]}}
* `kind` is 'execution' (the database would run something), 'operator' (a
* comparison where a value was expected) or '' (ordinary).
*/
function classifyScalarParam(value) {
const execution = executionOperatorsIn(value);
if (execution.length) {
return { injection: true, kind: 'execution', operators: execution };
}
if (isOperatorObject(value)) {
return { injection: true, kind: 'operator', operators: operatorsOf(value) };
}
return { injection: false, kind: '', operators: [] };
}
/**
* Classify a whole client-supplied SELECTOR. Only execution operators make a
* selector an attack - comparison operators are what a selector is made of.
* @param {*} selector
* @return {{injection: boolean, kind: string, operators: string[]}}
*/
function classifySelector(selector) {
const execution = executionOperatorsIn(selector);
return execution.length
? { injection: true, kind: 'execution', operators: execution }
: { injection: false, kind: '', operators: [] };
}
/**
* The one-clause summary a canary carries. Names the OPERATORS, which are a
* short fixed vocabulary, and never any value beside them - those are
* attacker-controlled.
* @param {{kind: string, operators: string[]}} classification
* @param {string} [where] the parameter or publication name
* @return {string}
*/
function injectionDetail(classification, where) {
const what =
classification.kind === 'execution'
? 'database-side execution operator'
: 'query operator where a value was expected';
const ops = classification.operators.join(', ');
return where ? `${what} (${ops}) in ${where}` : `${what} (${ops})`;
}
// Markup that would have DONE something if it had survived. Sanitizing text is
// routine - a pasted `<b>` gets stripped from a comment every day - and routine
// is not worth an admin's attention. A script tag, an event handler, a
// javascript: URI or an embedding element is not routine: it is a stored-XSS
// attempt that the sanitizer defused, and that is worth knowing about with the
// author attached.
const ACTIVE_MARKUP = [
/<\s*script\b/i,
/<\s*iframe\b/i,
/<\s*object\b/i,
/<\s*embed\b/i,
/<\s*svg\b/i,
/\son[a-z]+\s*=/i, // onerror=, onload=, onclick=, …
/javascript\s*:/i,
/data\s*:\s*text\/html/i,
];
/**
* True when sanitizing `original` into `sanitized` removed ACTIVE markup, as
* opposed to tidying formatting away.
*
* Compares what the sanitizer took OUT: text that never had any is not
* interesting, and text that still has it was not sanitized at all (which would
* be a different bug).
*
* @param {string} original
* @param {string} sanitized
* @return {boolean}
*/
function removedActiveMarkup(original, sanitized) {
if (typeof original !== 'string' || typeof sanitized !== 'string') return false;
if (original === sanitized) return false;
return ACTIVE_MARKUP.some(re => re.test(original) && !re.test(sanitized));
}
// Which canary id a caller passes for each finding. Kept here, beside the
// detector, so a call site does not have to guess and so the ids cannot drift
// from the catalog (models/lib/canaryTokens.js).
const CANARY_IDS = {
// A client-supplied SELECTOR carrying a database-side execution operator.
selector: 'injection.nosql-selector',
// An operator object where the value a user typed belongs.
operator: 'injection.nosql-operator',
// Text whose active markup the sanitizer had to remove.
text: 'sanitize.dangerous-text',
};
module.exports = {
EXECUTION_OPERATORS,
COMPARISON_OPERATORS,
ACTIVE_MARKUP,
CANARY_IDS,
removedActiveMarkup,
executionOperatorsIn,
isOperatorObject,
operatorsOf,
classifyScalarParam,
classifySelector,
injectionDetail,
};
+60
View File
@@ -0,0 +1,60 @@
'use strict';
// #5251: "get last change date of a list using API".
//
// The asker is building an offline Android client and wants to know whether
// anything in a list changed - a card added, edited or archived - without
// fetching every card and diffing it. A list's own `modifiedAt` does not answer
// that: it moves when the LIST document changes (title, sort, archived), and a
// card being edited does not touch it.
//
// So the answer is two dates per list: the list's own modifiedAt, and the newest
// modifiedAt among its cards. This is the second one, as a pure reduction over
// the cards of a board, so the REST handler needs one query for the whole board
// instead of one per list - and so it is unit-testable without Meteor.
//
// Archived cards are the caller's choice: archiving IS a change the asker names,
// so the handler passes them in. What matters here is that a card with no usable
// date cannot poison the result, and that a list with no cards gets no date
// rather than a wrong one.
function toTime(value) {
if (value instanceof Date) {
const t = value.getTime();
return Number.isNaN(t) ? null : t;
}
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const t = Date.parse(value);
return Number.isNaN(t) ? null : t;
}
return null;
}
/**
* The newest card modification per list.
*
* @param {Array<{listId: string, modifiedAt: *, dateLastActivity: *}>} cards
* @returns {Map<string, Date>} listId -> newest date. A list whose cards all
* lack a usable date is absent from the map, so the caller can report null
* rather than an invented time.
*/
function newestCardChangeByList(cards) {
const newest = new Map();
for (const card of Array.isArray(cards) ? cards : []) {
if (!card || typeof card.listId !== 'string' || card.listId === '') continue;
// dateLastActivity is what older WeKan wrote; modifiedAt is current. Take
// whichever is newer, so a board written by an older version still answers.
const times = [toTime(card.modifiedAt), toTime(card.dateLastActivity)]
.filter(t => t !== null);
if (!times.length) continue;
const t = Math.max(...times);
const previous = newest.get(card.listId);
if (previous === undefined || t > previous) newest.set(card.listId, t);
}
const out = new Map();
for (const [listId, t] of newest) out.set(listId, new Date(t));
return out;
}
module.exports = { newestCardChangeByList, toTime };
+122
View File
@@ -0,0 +1,122 @@
// Pure, dependency-free check that a comment-reaction update only changes the
// ACTING USER's own reactions. No Meteor imports, so it is unit tested directly
// with plain Node (tests/reactionOwnership.test.cjs).
//
// Found while auditing for more of GHSA-pqr4-rxgp-hv2m (the REST comment delete
// that checked board membership and nothing about WHOSE comment it was). A
// CardCommentReactions document is
//
// { cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }
//
// and its allow rule was `allowIsBoardMemberCommentOnly` for insert, update AND
// remove - board membership, and nothing about whose reaction is being changed.
// The whole `reactions` array is one field, so a member could $set it to
// anything: add their colleague's userId to a reaction they never made, or
// remove one they did. `toggleReaction()` only ever touches Meteor.userId(), so
// no legitimate client needs anything else - the rule simply never said so.
//
// Not a confidentiality bug: reactions are visible to everyone on the board
// already. It is an integrity one, and it puts words in another person's mouth.
/**
* Normalise a reactions array into `codepoint -> Set(userIds)`, ignoring
* anything that is not shaped like a reaction.
* @param {Array} reactions
* @return {Map<string, Set<string>>}
*/
function indexReactions(reactions) {
const index = new Map();
if (!Array.isArray(reactions)) return index;
reactions.forEach(reaction => {
if (!reaction || typeof reaction !== 'object') return;
const codepoint = reaction.reactionCodepoint;
if (typeof codepoint !== 'string') return;
const userIds = Array.isArray(reaction.userIds)
? reaction.userIds.filter(id => typeof id === 'string')
: [];
// A codepoint repeated in the array is still one reaction.
const existing = index.get(codepoint) || new Set();
userIds.forEach(id => existing.add(id));
index.set(codepoint, existing);
});
return index;
}
/**
* True when going from `oldReactions` to `newReactions` changes nothing about
* any user other than `userId`.
*
* Deliberately compares MEMBERSHIP rather than array order or shape: the client
* rebuilds the array on every toggle, so a reordered array with the same
* membership is the same set of reactions and must not be refused.
*
* @param {Array} oldReactions the reactions as stored
* @param {Array} newReactions the reactions being written
* @param {string} userId the acting user
* @return {boolean}
*/
function changesOnlyOwnReactions(oldReactions, newReactions, userId) {
if (typeof userId !== 'string' || !userId) return false;
const before = indexReactions(oldReactions);
const after = indexReactions(newReactions);
const codepoints = new Set([...before.keys(), ...after.keys()]);
for (const codepoint of codepoints) {
const wasThere = before.get(codepoint) || new Set();
const isThere = after.get(codepoint) || new Set();
// Everyone except the acting user must appear in exactly the same
// reactions before and after.
const othersBefore = [...wasThere].filter(id => id !== userId).sort();
const othersAfter = [...isThere].filter(id => id !== userId).sort();
if (othersBefore.length !== othersAfter.length) return false;
for (let i = 0; i < othersBefore.length; i++) {
if (othersBefore[i] !== othersAfter[i]) return false;
}
}
return true;
}
/**
* The deny-rule form: true means "refuse this write". A modifier that does not
* set `reactions` is not a reaction change and is left to the allow rule.
*
* @param {string} userId the acting user
* @param {object} doc the stored document (needs `reactions`)
* @param {object} modifier the update modifier
* @return {boolean}
*/
function denyForeignReactionChange(userId, doc, modifier) {
const set = modifier && modifier.$set;
if (!set || !Object.prototype.hasOwnProperty.call(set, 'reactions')) {
// Any OTHER way of writing the array - $push, $pull, $addToSet, a dotted
// `reactions.0.userIds` - cannot be checked field by field here, and no
// legitimate client sends one: toggleReaction() always $sets the whole
// array. Refuse those outright rather than letting them through unchecked.
const touchesReactions = modifier
? Object.keys(modifier).some(op => {
const value = modifier[op];
return (
value &&
typeof value === 'object' &&
Object.keys(value).some(field => field === 'reactions' || field.startsWith('reactions.'))
);
})
: false;
return touchesReactions;
}
return !changesOnlyOwnReactions(doc && doc.reactions, set.reactions, userId);
}
module.exports = {
indexReactions,
changesOnlyOwnReactions,
denyForeignReactionChange,
};
+16
View File
@@ -32,6 +32,22 @@ function sanitizeUploadedFileExploits(fileObj) {
});
}
// Active markup had to be REMOVED from an uploaded file - a script, an event
// handler, a javascript: URI inside an SVG. Nothing a drawing program produces
// needs any of it, so this is a stored-XSS attempt that was defused rather than
// a file that needed tidying, and it trips a canary with the uploader attached
// (docs/Security/Remediation/WeKan.md §12.6). The sanitized file is stored
// exactly as before; the uploader is told nothing.
if (changed) {
try {
const { tripCanary } = require('/server/lib/canary');
tripCanary('sanitize.dangerous-content', {
userId: (fileObj.userId || (fileObj.meta && fileObj.meta.userId)) || undefined,
detail: 'active markup removed from an uploaded ' + (fileObj.type || 'file'),
});
} catch (e) { /* a canary must never break an upload */ }
}
return changed;
}
+16
View File
@@ -33,6 +33,22 @@ const CATALOG = {
'file.disk': { category: 'file', bleed: 'FloppyBleed', severity: 'low', cwe: 'CWE-400' },
'file.avatar-url': { category: 'ssrf', bleed: 'RedirectBleed', severity: 'high', cwe: 'CWE-918' },
'file.policy': { category: 'file', bleed: 'PolicyBleed', severity: 'info', cwe: '' },
// Canary tokens (docs/Security/Remediation/WeKan.md §12). A canary sits where
// somebody could TRY to override permissions, so every one of these is an
// ATTEMPT that the rules already refused - the event is the attribution, not
// the defence. They resolve through this same catalog so a canary's category
// and *Bleed name cannot drift from the guard it sits next to.
'authz.canary': { category: 'authz', bleed: 'CanaryBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.checklist': { category: 'authz', bleed: 'ChecklistBleed', severity: 'high', cwe: 'CWE-863' },
'authz.comment': { category: 'authz', bleed: 'CommentBleed', severity: 'medium', cwe: 'CWE-639' },
'authz.file-path': { category: 'authz', bleed: 'PathBleed', severity: 'high', cwe: 'CWE-22' },
'authz.parent': { category: 'authz', bleed: 'ParentBleed', severity: 'high', cwe: 'CWE-862' },
'authz.share': { category: 'authz', bleed: 'RevokeBleed', severity: 'high', cwe: 'CWE-863' },
'authz.readonly': { category: 'authz', bleed: 'ReadOnlyBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.database': { category: 'authz', bleed: 'DatabaseBleed', severity: 'high', cwe: 'CWE-863' },
'injection.nosql': { category: 'injection', bleed: 'SelectorBleed', severity: 'high', cwe: 'CWE-943' },
'injection.sql': { category: 'injection', bleed: 'EscapeBleed', severity: 'critical', cwe: 'CWE-89' },
};
const DEFAULT = { category: 'unknown', bleed: 'Generic', severity: 'info', cwe: '' };
+222
View File
@@ -0,0 +1,222 @@
'use strict';
// ============================================================================
// Snap names, channels, and the two names every architecture has
// ----------------------------------------------------------------------------
// WeKan publishes three snaps of the same application - `wekan`, and the
// `wekan-ondra` and `wekan-gantt-gpl` variants - and each must reach every
// architecture on every channel.
//
// Two things make this less obvious than "three names times eight arches".
//
// 1. ppc64le and ppc64el are the SAME hardware under two names. The release
// bundles at https://github.com/wekan/wekan are named the way Node.js and the
// kernel name things (`ppc64le`); the Snap Store names it the way Debian does
// (`ppc64el`). Nothing warns when the wrong one is used - an unrecognised
// --arch is simply an architecture the store has never heard of - so the
// rename is written down once, here.
//
// 2. armhf and armv7 are NOT two names for one thing, and must never be mapped
// onto each other. They are the same CPU family built to different
// baselines: node-patches builds `armhf` to the Debian armhf baseline
// (hard-float, VFPv3-D16, assuming NO NEON) so it runs on any ARMv7-A, and
// `armv7` with NEON for boards that have it. The Snap Store has ONE 32-bit
// ARM architecture serving every such device, so it must carry the BASELINE
// build; the NEON one would be an illegal instruction on a board without
// NEON. armv7 therefore has no snap - see NOT_SNAP_ARCHITECTURES below.
//
// The list is therefore the six architectures snapcraft.yaml actually declares a
// `build-for` for, not the eight the release bundles cover. Both directions are
// unit tested (tests/snapArchitectures.test.cjs).
//
// Pure and dependency-free: the shell scripts read it through node, so there is
// no second copy of this table in bash.
// ============================================================================
// The three snaps. Same application, three store listings.
const SNAP_NAMES = ['wekan', 'wekan-ondra', 'wekan-gantt-gpl'];
// Every channel a revision is released to. `stable` is included deliberately:
// the old helper released to `edge,beta,candidate` only, so a build reached
// three of the four and somebody had to remember the fourth by hand.
const SNAP_CHANNELS = ['stable', 'candidate', 'beta', 'edge'];
// One row per architecture the Snap Store gets, matching snapcraft.yaml's
// `build-for` list exactly. `bundle` is null where the release bundle goes by
// the same name - all but one - so the single rename stands out.
const ARCHITECTURES = [
{ snap: 'amd64', bundle: null },
{ snap: 'arm64', bundle: null },
{ snap: 'armhf', bundle: null }, // 32-bit Raspberry Pi OS
{ snap: 'ppc64el', bundle: 'ppc64le' }, // the store spells POWER the Debian way
{ snap: 'riscv64', bundle: null },
{ snap: 's390x', bundle: null },
];
// Architectures WeKan SHIPS but does not BUILD a snap for, and why.
//
// "Does not build" is not the same as "the store has no column": the store keeps
// whatever was ever uploaded, so an architecture can appear there with an
// ancient revision on every channel long after it stopped being buildable.
// That is what makes these worth writing down rather than inferring from the
// store's own listing.
// Written down because "it is missing" and "it cannot be there" look identical
// from the outside, and somebody re-adding one of these would be repeating a
// mistake that has already been made.
const NOT_SNAP_ARCHITECTURES = {
i386:
'core24 (Ubuntu 24.04) has no i386 port, so snapcraft rejects `build-on: i386` - ' +
'and because that is a PARSE error it failed EVERY architecture\'s snap build, not ' +
'only i386\'s. The STORE still holds old i386 revisions (wekan-ondra has one, at ' +
'version 0.X-ci, sitting on all four channels), so the column exists there and is ' +
'years stale; nothing new can be built for it, and re-releasing what is there would ' +
'only re-publish 0.X-ci. i386 users are served by the .deb and the AppImage.',
armv7:
'the same CPU family as armhf, tuned differently: node-patches builds armhf to the ' +
'Debian armhf baseline (hard-float, VFPv3-D16, NO NEON assumed) so it runs on any ' +
'ARMv7-A, and armv7 with NEON for boards that have it (an ODroid-U3, say). The Snap ' +
'Store has ONE 32-bit ARM architecture and it serves every such device, so it must ' +
'carry the BASELINE build - shipping the NEON one would be an illegal instruction on ' +
'any armhf board without NEON. armv7 ships as a bundle only.',
};
/** Every Snap Store architecture, in a stable order. */
function snapArchitectures() {
return ARCHITECTURES.map(a => a.snap);
}
/** Every release-bundle architecture name, in the same order. */
function bundleArchitectures() {
return ARCHITECTURES.map(a => a.bundle || a.snap);
}
/**
* The Snap Store's name for a bundle architecture.
*
* Accepts either spelling, so a caller that already has the store's name is not
* punished for it - `snapArchOf('ppc64el')` is `ppc64el`. Unknown input returns
* '' rather than being passed through: an architecture nobody recognises must
* not reach `snapcraft release`, where it would be a silent no-op.
*
* @param {string} name
* @return {string} the Snap Store architecture, or '' when it is not one
*/
function snapArchOf(name) {
if (typeof name !== 'string' || !name) return '';
const key = name.trim().toLowerCase();
const row = ARCHITECTURES.find(a => a.snap === key || a.bundle === key);
if (row) return row.snap;
// Names the same hardware also goes by upstream. armv7/armv7l is NOT here:
// it is a different CPU from armhf, not another spelling of it.
const ALIASES = { x64: 'amd64', 'x86_64': 'amd64', aarch64: 'arm64' };
return ALIASES[key] || '';
}
/**
* The release bundle's name for an architecture. Same tolerance in reverse.
* @param {string} name
* @return {string} the bundle architecture, or '' when it is not one
*/
function bundleArchOf(name) {
const snap = snapArchOf(name);
if (!snap) return '';
const row = ARCHITECTURES.find(a => a.snap === snap);
return row.bundle || row.snap;
}
/**
* Every (snap, architecture) pair that has to be released, in a stable order.
* The channels are not part of the pair: one `snapcraft release` call takes them
* all at once, so a revision reaches all four or none.
* @return {Array<{snap: string, arch: string, bundleArch: string}>}
*/
function releaseTargets() {
const targets = [];
SNAP_NAMES.forEach(snap => {
ARCHITECTURES.forEach(a => {
targets.push({ snap, arch: a.snap, bundleArch: a.bundle || a.snap });
});
});
return targets;
}
/**
* Pick the revision to release for one architecture out of `snapcraft
* list-revisions` rows.
*
* Revisions are PER ARCHITECTURE - amd64 revision 42 and arm64 revision 42 are
* different uploads - which is why one revision number can never be released to
* every architecture at once, and why this exists at all.
*
* The highest revision wins, not the first row: the store's ordering is not
* something to depend on. When `version` is given, only that version's rows are
* considered, so a release publishes what it built rather than whatever was
* uploaded last.
*
* @param {Array<{revision: number, arch: string, version: string}>} rows
* @param {string} arch a Snap Store architecture
* @param {string} [version]
* @return {number|null}
*/
function pickRevision(rows, arch, version) {
if (!Array.isArray(rows)) return null;
const want = snapArchOf(arch);
if (!want) return null;
const candidates = rows.filter(r => {
if (!r || snapArchOf(r.arch) !== want) return false;
if (!Number.isFinite(Number(r.revision))) return false;
if (version && String(r.version) !== String(version)) return false;
return true;
});
if (!candidates.length) return null;
return candidates.reduce((best, r) => Math.max(best, Number(r.revision)), -Infinity);
}
/**
* Parse `snapcraft list-revisions` output.
*
* Its columns are `Rev. Uploaded Arch Version Channels`, the header is skipped,
* and a revision with no channels has an empty last column - so the parse keys
* on the leading revision number and takes the next three fields, rather than
* counting columns from the right.
*
* @param {string} text
* @return {Array<{revision: number, uploaded: string, arch: string, version: string, channels: string}>}
*/
function parseRevisions(text) {
if (typeof text !== 'string') return [];
return text
.split('\n')
.map(line => line.trim())
.filter(line => /^\d+\s/.test(line))
.map(line => {
const [revision, uploaded, arch, version, ...rest] = line.split(/\s+/);
return {
revision: Number(revision),
uploaded,
arch,
version,
channels: rest.join(' '),
};
})
.filter(r => r.arch && r.version);
}
module.exports = {
SNAP_NAMES,
SNAP_CHANNELS,
ARCHITECTURES,
NOT_SNAP_ARCHITECTURES,
snapArchitectures,
bundleArchitectures,
snapArchOf,
bundleArchOf,
releaseTargets,
pickRevision,
parseRevisions,
};
+84
View File
@@ -0,0 +1,84 @@
// Pure, dependency-free containment checks for a file path that came out of the
// DATABASE rather than off the disk. No Meteor imports, so it is unit tested
// directly with plain Node (tests/storagePathContainment.test.cjs).
//
// GHSA-4mxf-m8pq-xc9p: the board exporter read `versions.original.path` from an
// avatar document and streamed that file into the export as base64, trusting
// whatever Mongo held. The avatar allow rule let the owner write that field, so
// the export embedded the bytes of any file the WeKan process could read.
// Blocking the write is the first half of the fix; this is the second, and it is
// the half that also holds if a path is poisoned some other way - a document
// written before the fix, a restored backup, a bad migration.
//
// The DOWNLOAD path in models/lib/fileStoreStrategy.js has always checked
// containment this way; that function now lives here, so the download and the
// export ask the same question instead of each having their own answer. Drift
// between two copies of a rule is exactly what this advisory was.
const path = require('path');
/** Resolve, and lowercase on Windows, whose paths are case-insensitive. */
function normalizeForCompare(inputPath) {
const normalized = path.resolve(inputPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
/**
* True when `targetPath` is `basePath` or lies under it.
*
* Resolving first is the whole point: '..' segments are what a plain
* `startsWith` gets wrong, so `/data/files/avatars/../../etc/passwd` is NOT
* inside `/data/files/avatars`. Comparing the RELATIVE path also means
* `/data/files/avatars-evil` is not inside `/data/files/avatars`, which a
* `startsWith` on the raw strings would accept.
*
* @param {string} basePath the directory
* @param {string} targetPath the path to test
* @return {boolean}
*/
function isPathInsideBase(basePath, targetPath) {
if (typeof basePath !== 'string' || !basePath) return false;
if (typeof targetPath !== 'string' || !targetPath) return false;
const normalizedBase = normalizeForCompare(basePath);
const normalizedTarget = normalizeForCompare(targetPath);
const relative = path.relative(normalizedBase, normalizedTarget);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/**
* The same question asked the other way round, for reading a FILE: is this
* candidate somewhere under that root? The root ITSELF is not an answer - a
* storage root is a directory, never the file being read - which is the one
* difference from isPathInsideBase().
*
* @param {string} candidate the path to check (as stored)
* @param {string} root the directory it must be under
* @return {boolean}
*/
function isPathInside(candidate, root) {
if (typeof candidate !== 'string' || !candidate) return false;
if (typeof root !== 'string' || !root) return false;
if (normalizeForCompare(candidate) === normalizeForCompare(root)) return false;
return isPathInsideBase(root, candidate);
}
/**
* Same, against several roots at once (attachments and avatars, say).
* @param {string} candidate
* @param {string[]} roots
* @return {boolean}
*/
function isPathInsideAny(candidate, roots) {
if (!Array.isArray(roots)) return false;
return roots.some((root) => isPathInside(candidate, root));
}
module.exports = {
normalizeForCompare,
isPathInsideBase,
isPathInside,
isPathInsideAny,
};
+17 -2
View File
@@ -328,8 +328,23 @@ Lists.helpers({
await card.move(boardId, this._id, boardList._id);
}
} else {
console.log('list.title:', this.title);
console.log('boardList:', boardList);
// A list with no usable title cannot be inserted: `title` is required by
// the schema, so the insert fails validation - and collection2's own error
// formatter then reads a property of the undefined field and throws
// ValidationError: Failed validation
// Cannot read properties of undefined (reading 'title')
// which is what an admin actually saw in Admin Panel / Problems /
// Database problems: an opaque crash naming neither the list nor the
// real problem. Say what is wrong instead, and say it before the insert.
// (The two console.log lines that used to be here printed it to a log
// nobody reads and did not stop the crash.)
if (typeof this.title !== 'string' || this.title.trim().length === 0) {
throw new Meteor.Error(
'list-has-no-title',
'This list has no title, so it cannot be moved to another board. ' +
'Give it a title first.',
);
}
listId = await Lists.insertAsync({
title: this.title,
boardId,
+7 -3
View File
@@ -243,9 +243,13 @@ Meteor.startup(() => {
// that is a likely forged-whitelisted-IP attempt (MetricsBleed) — record it.
if (req.headers['x-forwarded-for']) {
try {
require('/server/lib/securityLog').record({
key: 'spoofing.xff', action: 'blocked', source: 'metrics',
detail: 'denied /metrics with X-Forwarded-For present (socket ' + (req.socket && req.socket.remoteAddress) + ')',
// A canary, with the REQUEST in hand: nothing legitimate sends a
// forwarded-for header to an endpoint that does not trust one, so
// this is somebody trying to look like a whitelisted address. The
// 401 below is unchanged (docs/Security/Remediation/WeKan.md §12).
require('/server/lib/canary').tripCanary('spoof.forwarded-header', {
req,
detail: 'denied /metrics with X-Forwarded-For present',
});
} catch (e) { /* logging must never break the endpoint */ }
}
+1100 -1618
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v10.71.0",
"version": "v10.78.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -54,9 +54,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1095.0",
"@aws-sdk/lib-storage": "~3.1085.0",
"@aws-sdk/lib-storage": "~3.1104.0",
"@azure/storage-blob": "^12.33.0",
"@babel/parser": "^7.29.2",
"@babel/parser": "^8.0.4",
"@babel/runtime": "^8.0.0",
"@google-cloud/storage": "^7.21.0",
"@meteorjs/reify": "^0.25.4",
@@ -83,11 +83,11 @@
"hotkeys-js": "^4.0.2",
"i18next": "^26.3.6",
"i18next-sprintf-postprocessor": "^0.2.2",
"jquery": "^3.7.1",
"jquery": "^4.0.0",
"jquery-ui": "^1.14.2",
"jszip": "^3.7.1",
"limax": "^4.2.3",
"markdown-it": "^14.1.1",
"markdown-it": "^15.0.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-math": "^6.0.0",
"meteor-accounts-t9n": "^2.6.0",
+24 -2
View File
@@ -86,6 +86,13 @@ export default class LDAP {
User_Search_Field : this.constructor.settings_get('LDAP_USER_SEARCH_FIELD'),
Search_Page_Size : this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'),
Search_Size_Limit : this.constructor.settings_get('LDAP_SEARCH_SIZE_LIMIT'),
// #5539: groups do not have to live under the users' base DN. The group
// searches used BaseDN - the USER base - so a directory that keeps
// ou=groups beside ou=people (a common layout, and the reporter's) found
// no groups at all: with LDAP_GROUP_FILTER_ENABLE on, isUserInGroup then
// said "not a member" and refused every login. Unset, this stays BaseDN,
// so nothing changes for a directory that keeps both in one subtree.
GroupBaseDN : this.constructor.settings_get('LDAP_GROUP_BASEDN'),
group_filter_enabled : this.constructor.settings_get('LDAP_GROUP_FILTER_ENABLE'),
group_filter_object_class : this.constructor.settings_get('LDAP_GROUP_FILTER_OBJECTCLASS'),
group_filter_group_id_attribute : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE'),
@@ -195,6 +202,19 @@ export default class LDAP {
return fields;
}
// #5539: where to look for groups. LDAP_GROUP_BASEDN when it is set, the user
// base otherwise - an install that never had groups in a separate subtree
// behaves exactly as before. A blank string counts as unset: an env var that is
// present but empty is one somebody meant to fill in, not a search base, and
// searching "" would silently search the whole directory root.
groupBaseDN() {
const groupBase = this.options.GroupBaseDN;
if (typeof groupBase === 'string' && groupBase.trim() !== '') {
return groupBase.trim();
}
return this.options.BaseDN;
}
async searchAll(BaseDN, options) {
const searchOptions = {
filter: options.filter,
@@ -480,7 +500,8 @@ export default class LDAP {
Log.debug(`Group list filter LDAP: ${searchOptions.filter}`);
const result = await this.searchAll(this.options.BaseDN, searchOptions);
// #5539: the GROUP subtree, which equals the user one only by default.
const result = await this.searchAll(this.groupBaseDN(), searchOptions);
if (!Array.isArray(result) || result.length === 0) {
return [];
@@ -552,8 +573,9 @@ export default class LDAP {
};
Log.debug(`Group filter LDAP: ${searchOptions.filter}`);
// #5539: the GROUP subtree, which equals the user one only by default.
const result = await this.searchAll(this.options.BaseDN, searchOptions);
const result = await this.searchAll(this.groupBaseDN(), searchOptions);
if (!Array.isArray(result) || result.length === 0) {
return false;
+51 -2
View File
@@ -38,8 +38,30 @@ OAuth.registerService('oidc', 2, null, async function (query) {
var token = await getToken(query);
if (debug) console.log('XXX: register token:', token);
// #5174: everything below reads fields off the provider's answer, so the
// answer has to BE something first. When it is not, the failure has to name
// the provider's own complaint rather than surface as a TypeError three lines
// later - the reported case was a scope mismatch, and what the admin saw was
// "Cannot read property 'ocs' of null".
if (!token || typeof token !== 'object') {
throw new Error('OIDC: the token endpoint returned no usable response. '
+ 'Check the provider log for the authorization code exchange.');
}
var accessToken = token.access_token || token.id_token;
var expiresAt = (+new Date) + (1000 * parseInt(token.expires_in, 10));
if (!accessToken) {
// A 200 with neither token in it: the provider answered, and what it
// answered with is the useful part. Keys only - the values are secrets.
throw new Error('OIDC: the token endpoint returned neither access_token nor '
+ 'id_token. It returned these fields: ' + Object.keys(token).join(', ')
+ (token.error_description ? ' (' + token.error_description + ')' : ''));
}
// parseInt(undefined) is NaN, and NaN propagates into the account's expiry
// silently. A provider that sends no expires_in gets the OAuth2 default hour.
var expiresIn = parseInt(token.expires_in, 10);
if (!Number.isFinite(expiresIn)) expiresIn = 3600;
var expiresAt = (+new Date) + (1000 * expiresIn);
var claimsInAccessToken = (process.env.OAUTH2_ADFS_ENABLED === 'true' ||
process.env.OAUTH2_ADFS_ENABLED === true ||
@@ -57,8 +79,29 @@ OAuth.registerService('oidc', 2, null, async function (query) {
userinfo = await getUserInfo(accessToken);
}
if (userinfo.ocs) userinfo = userinfo.ocs.data; // Nextcloud hack
// #5174: getTokenContent() returns NULL for a token it cannot parse, and the
// ADFS/B2C branch above assigns that straight into userinfo - so this line was
// `null.ocs` and threw "Cannot read property 'ocs' of null", which says
// nothing about what actually went wrong. Both branches are checked here, once,
// before anything reads a claim.
if (!userinfo || typeof userinfo !== 'object') {
throw new Error('OIDC: no user information came back from the provider ('
+ (claimsInAccessToken
? 'claims were expected inside the access token, which could not be parsed'
: 'the userinfo endpoint returned nothing usable')
+ '). Check the provider log, and that the scopes WeKan requests '
+ '(OAUTH2_REQUEST_PERMISSIONS) are the ones the provider allows.');
}
// Both hacks re-point userinfo at a nested object, so both need that object to
// exist: a Nextcloud response with `ocs` but no `ocs.data` used to replace
// userinfo with undefined and fail on the next line instead of this one.
if (userinfo.ocs && userinfo.ocs.data) userinfo = userinfo.ocs.data; // Nextcloud hack
if (userinfo.metadata) userinfo = userinfo.metadata // Openshift hack
if (!userinfo || typeof userinfo !== 'object') {
throw new Error('OIDC: the provider response unwrapped to nothing usable '
+ '(ocs/metadata). Check the provider log.');
}
if (debug) console.log('XXX: userinfo:', userinfo);
serviceData.id = userinfo[process.env.OAUTH2_ID_MAP]; // || userinfo["id"];
@@ -86,6 +129,12 @@ OAuth.registerService('oidc', 2, null, async function (query) {
}
if (process.env.OAUTH2_B2C_ENABLED === 'true' || process.env.OAUTH2_B2C_ENABLED === true) {
// #5174: an Azure AD B2C token without the `emails` claim - which is what a
// scope the tenant did not grant produces - made this `undefined[0]`.
if (!Array.isArray(userinfo.emails) || !userinfo.emails.length) {
throw new Error('OIDC (B2C): the token carries no "emails" claim. The '
+ 'tenant must be configured to release it for the requested scopes.');
}
serviceData.email = userinfo["emails"][0];
}
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v10.71</title>
<title>WeKan REST API v10.78</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.71<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.78<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -2005,7 +2005,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.71","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 `<field>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 Line truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.78","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>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 Line truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v10.71
version: v10.78
description: |
The REST API allows you to control and extend Wekan with ease.
+6 -3
View File
@@ -62,10 +62,12 @@ LOGDIR="${WEKAN_LOGDIR:-$WEKAN_LOG_ROOT/$RUN_TS}"
mkdir -p "$LOGDIR"
LOGDIR="$(cd "$LOGDIR" && pwd)"
FERRET_DIR="$WEKAN_DIR/FerretDB"
# .tools/FerretDB: companion repos live in one ignored directory inside the
# checkout, instead of one ignored subdirectory each at the repo root.
FERRET_DIR="$WEKAN_DIR/.tools/FerretDB"
# The binary this script BUILDS and tests with: FerretDB/bin/ferretdb, never a
# downloaded release.
FERRET_BIN="$WEKAN_DIR/FerretDB/bin/ferretdb"
FERRET_BIN="$WEKAN_DIR/.tools/FerretDB/bin/ferretdb"
FERRET_REPO_SSH="git@github.com:wekan/FerretDB"
FERRET_REPO_HTTPS="https://github.com/wekan/FerretDB"
@@ -135,7 +137,8 @@ fi
# ── 1. the FerretDB source ──────────────────────────────────────────────────
if [ ! -d "$FERRET_DIR/.git" ]; then
echo "---- FerretDB source is not here; cloning wekan/FerretDB ----"
echo "---- FerretDB source is not in .tools/; cloning wekan/FerretDB ----"
mkdir -p "$(dirname "$FERRET_DIR")"
if ! git clone "$FERRET_REPO_SSH" "$FERRET_DIR" 2>&1 | tee -a "$LOGDIR/db-conformance-build.log"; then
echo "SSH clone failed (no key for github.com?); trying HTTPS."
git clone "$FERRET_REPO_HTTPS" "$FERRET_DIR" 2>&1 | tee -a "$LOGDIR/db-conformance-build.log" || {
+9
View File
@@ -89,7 +89,16 @@ echo " OK: No Go buildinfo binaries found"
'
echo "=== Step 3: Checks passed - building and pushing multi-arch image to registries ==="
# --provenance=mode=max: full build provenance (SLSA), matching release-all.yml.
# The local --load build above gets none - the docker exporter cannot carry
# attestations - so only this pushing build has it.
#
# The comment is HERE and not among the arguments below: a `#` after a line
# continuation comments out the REST OF THE JOINED LINE, so the command becomes
# `buildx build` with every argument swallowed. `bash -n` accepts it, because it
# is valid syntax - it is just a different command.
${DOCKER} buildx build \
--provenance=mode=max \
--platform linux/amd64,linux/arm64 \
-t wekanteam/wekan:v${VERSION} \
-t wekanteam/wekan:latest \
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
#
# ferretdb-latest-tag.sh - which version `latest` is right now.
#
# WeKan fetches FerretDB from .../releases/latest/download/ferretdb-<arch>, so a
# security fix published in wekan/FerretDB reaches the next bundle without a
# commit here. The cost of that is that the URL does not say WHICH version was
# fetched, and the release notes have to say it: "which FerretDB did v10.77
# ship" must be answerable a year later, when `latest` is something else.
#
# The extra-architecture job already asked the API and recorded the answer. The
# amd64, arm64, win64, win32, mac-arm64 and mac-x64 jobs passed the literal
# string `latest` instead, so six rows of the v10.77 table - including amd64,
# the platform most people download - had "latest" in the Version column, which
# is the one question the column exists to answer.
#
# Usage: ferretdb-latest-tag.sh
#
# Prints the tag (e.g. v1.48.0) and exits 0. Prints NOTHING and still exits 0
# when it cannot find out: this is for a release note, and no caller should fail
# a build over it. Callers write "${TAG:-latest}", which degrades to exactly
# what they had before.
#
# The answer is cached for the life of the job, so six callers in one job make
# one request rather than six.
set -uo pipefail
cache="${FERRETDB_TAG_CACHE:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/ferretdb-latest-tag}"
if [ -s "$cache" ]; then
cat "$cache"
exit 0
fi
api="https://api.github.com/repos/wekan/FerretDB/releases/latest"
# -f so an HTTP error is a failure rather than an error page parsed as JSON.
# The token is used when there is one - an unauthenticated runner shares a
# 60/hour limit with every other job on that IP, and this is the failure that
# put "latest" in the table.
auth=()
if [ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then
auth=(-H "Authorization: Bearer ${GH_TOKEN:-$GITHUB_TOKEN}")
fi
body="$(curl -fsSL --retry 3 --retry-delay 2 "${auth[@]}" "$api" 2>/dev/null || true)"
[ -n "$body" ] || exit 0
# Prefer python3 - it is on every runner that builds a bundle - and fall back to
# sed, so a container without it still answers instead of silently saying
# "latest".
tag=""
if command -v python3 >/dev/null 2>&1; then
tag="$(printf '%s' "$body" \
| python3 -c 'import json,sys
try:
print(json.load(sys.stdin).get("tag_name", ""))
except Exception:
pass' 2>/dev/null || true)"
fi
if [ -z "$tag" ]; then
tag="$(printf '%s' "$body" \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -1)"
fi
# A tag, not a sentence. Anything else means the shape of the answer changed,
# and printing it into a markdown table cell would be worse than printing
# nothing - the caller's ${TAG:-latest} is the honest fallback.
case "$tag" in
v[0-9]*) ;;
*) exit 0 ;;
esac
mkdir -p "$(dirname "$cache")" 2>/dev/null || true
printf '%s' "$tag" > "$cache" 2>/dev/null || true
printf '%s' "$tag"
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
# Build WeKan arm64 bundle - Meteor 3 style (no fibers needed).
#
# This script rebuilds only the native Node.js modules for arm64.
# Run on an arm64 machine, or use the GitHub Actions ubuntu-24.04-arm runner.
#
# Usage:
# ./releases/build-bundle-arm64.sh 8.43
if [ $# -ne 1 ]; then
echo "Syntax with Wekan version number:"
echo " ./releases/build-bundle-arm64.sh 8.43"
exit 1
fi
VERSION=$1
# Install build dependencies
sudo apt-get update
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
# Remove old files
rm -rf bundle
rm -f wekan-${VERSION}-arm64.zip wekan-${VERSION}-amd64.zip
# Download the amd64 bundle as the base
wget --no-check-certificate \
https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip
# Extract bundle
unzip wekan-${VERSION}-amd64.zip
# Rebuild native Node.js modules for arm64 (no fibers or bcrypt patching needed with Meteor 3)
cd bundle/programs/server
npm rebuild
cd ../../..
# Clean up temporary files
cd bundle
find . -type d -name '*-garbage*' -exec rm -rf {} + 2>/dev/null || true
find . -name '*phantom*' -exec rm -rf {} + 2>/dev/null || true
find . -name '.*.swp' -delete 2>/dev/null || true
find . -name '*.swp' -delete 2>/dev/null || true
cd ..
# Create arm64 bundle
zip -r wekan-${VERSION}-arm64.zip bundle
echo "Done: wekan-${VERSION}-arm64.zip"
+21 -7
View File
@@ -14,7 +14,11 @@ set -euo pipefail
files=("$@")
if [ ${#files[@]} -eq 0 ]; then
shopt -s nullglob globstar
files=(provenance/**/*.tsv provenance/*.tsv)
# ONE pattern. With globstar, `**/` matches ZERO or more directories, so
# provenance/**/*.tsv ALREADY covers provenance/*.tsv - and listing both
# matched every top-level file twice, which is why the v10.77 release notes
# printed every row of the table twice.
files=(provenance/**/*.tsv)
fi
rows=""
@@ -24,8 +28,18 @@ for f in "${files[@]}"; do
"
done
# Drop blank lines, then sort by bundle and binary.
rows="$(printf '%s' "$rows" | awk 'NF' | sort -t"$(printf '\t')" -k1,1 -k2,2)"
# Drop blank lines, drop rows that are duplicates of a whole other row, then
# sort by bundle and binary.
#
# Deduplicated on the WHOLE line, not on (bundle, binary): a row is (bundle,
# binary, source, version, checked, sha, url), so an identical line is the same
# FACT recorded twice - by a doubled glob, a retried step, a job that ran again
# - and nothing distinguishes the copies. Two rows that share a bundle and a
# binary but differ anywhere else are NOT that; they are a real disagreement
# about which Node.js went into a bundle, and `sort -u -k1,1 -k2,2` would hide
# one of the two at random. Better shown twice and noticed.
rows="$(printf '%s' "$rows" | awk 'NF && !seen[$0]++' \
| sort -t"$(printf '\t')" -k1,1 -k2,2)"
if [ -z "$rows" ]; then
# Not fatal. The bundles are what the release is; a missing provenance
@@ -41,10 +55,10 @@ echo
echo "Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools."
echo "Which source has a given CPU varies from release to release - nodejs.org"
echo "builds some architectures, unofficial-builds others, and the"
echo "[wekan/node](https://github.com/wekan/node) fork the ones neither of them"
echo "does - and not every source publishes a checksum. This is what went into"
echo "this release, and which downloads were checked against a published"
echo "SHA256."
echo "[wekan/node-patches](https://github.com/wekan/node-patches) build the ones"
echo "neither of them does - and not every source publishes a checksum. This is"
echo "what went into this release, and which downloads were checked against a"
echo "published SHA256."
echo
echo "| Bundle | Binary | From | Version | Checked | SHA256 |"
echo "| --- | --- | --- | --- | --- | --- |"
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
#
# snap-release-all-channels.sh - put every snap, on every architecture, on every
# channel.
#
# WeKan publishes THREE snaps of the same application - wekan, wekan-ondra and
# wekan-gantt-gpl - across SIX Snap Store architectures, and each has to reach
# FOUR channels. That is 72 (snap, architecture, channel) combinations, and the
# helper this replaces released one snap, one revision, to three channels:
#
# snapcraft release wekan $1 edge,beta,candidate
#
# which is wrong three ways at once. It names only `wekan`. It takes ONE revision
# number, and revisions are PER ARCHITECTURE - amd64 revision 42 and arm64
# revision 42 are different uploads - so a single number can only ever be right
# for one of them. And it leaves out `stable`, so somebody had to remember the
# fourth channel by hand, every time.
#
# This resolves the revision per (snap, architecture) from the store itself, so
# the caller never types a revision number, and releases it to all four channels
# in one call - a revision reaches all of them or none.
#
# SIX ARCHITECTURES, NOT EIGHT. The release bundles cover eight; snapcraft.yaml
# declares a `build-for` for six, and the two missing ones are missing for
# reasons, not by oversight:
#
# i386 core24 has no i386 port. `build-on: i386` is a PARSE error, which
# failed EVERY architecture's snap build, not only i386's. Served by
# the .deb and the AppImage instead.
# armv7 the Snap Store has ONE 32-bit ARM architecture, `armhf`, and that is
# the 32-bit Raspberry Pi OS build. armv7 is the ODroid-U3 - a DIFFERENT
# CPU with its own bundle. Publishing it as armhf would hand Raspberry
# Pi users the ODroid build, so it ships as a bundle only.
#
# ppc64le AND ppc64el ARE THE SAME HARDWARE. The bundles at
# https://github.com/wekan/wekan name it the way Node.js and the kernel do
# (ppc64le); the Snap Store names it the way Debian does (ppc64el). Nothing warns
# when the wrong one is used - an unrecognised --arch is simply an architecture
# the store has never heard of - so the mapping lives in ONE place,
# models/lib/snapArchitectures.js, and this script reads it from there rather
# than keeping a second copy in bash.
#
# Usage:
# releases/snap-release-all-channels.sh # newest revision of each
# releases/snap-release-all-channels.sh 10.75 # only that version's
# releases/snap-release-all-channels.sh --dry-run # print, do not release
# releases/snap-release-all-channels.sh --snap wekan # one snap only
# releases/snap-release-all-channels.sh --arch ppc64le # one architecture (either spelling)
#
# Needs `snapcraft` on PATH and a logged-in store session (`snapcraft login`).
set -uo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version=""
dry_run=0
only_snap=""
only_arch=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--snap) only_snap="${2:-}"; shift 2 ;;
--arch) only_arch="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) version="$1"; shift ;;
esac
done
node_bin="$(command -v node || true)"
if [ -z "$node_bin" ]; then
echo "snap-release-all-channels.sh: node is required (it reads the architecture table" >&2
echo " from models/lib/snapArchitectures.js so there is only one copy of it)." >&2
exit 1
fi
if [ "$dry_run" -eq 0 ] && ! command -v snapcraft >/dev/null 2>&1; then
echo "snap-release-all-channels.sh: snapcraft is not on PATH." >&2
echo " Install it and run 'snapcraft login', or pass --dry-run to see the plan." >&2
exit 1
fi
# The single source of truth, read once.
snaps="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").SNAP_NAMES.join(" "))')"
arches="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").snapArchitectures().join(" "))')"
channels="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").SNAP_CHANNELS.join(","))')"
# Accept either spelling of --arch, and refuse one that is neither.
if [ -n "$only_arch" ]; then
resolved="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").snapArchOf(process.argv[1]))' "$only_arch")"
if [ -z "$resolved" ]; then
echo "snap-release-all-channels.sh: '$only_arch' is not an architecture WeKan builds." >&2
echo " Known: $arches (and their bundle names ppc64le, armv7)." >&2
exit 2
fi
only_arch="$resolved"
fi
echo "Channels: $channels"
echo "Snaps: ${only_snap:-$snaps}"
echo "Architectures: ${only_arch:-$arches}"
echo "Version: ${version:-<newest uploaded>}"
# Say what is NOT being published, every run. "It is missing" and "it cannot
# be there" look identical from the outside, and this is the difference.
"$node_bin" -e 'const n = require(process.argv[1] + "/models/lib/snapArchitectures").NOT_SNAP_ARCHITECTURES;'\
'Object.entries(n).forEach(([a, why]) => console.log("Not a snap: " + a + " - " + why));' "$repo_root"
[ "$dry_run" -eq 1 ] && echo "DRY RUN - nothing will be released."
echo
released=0
skipped=0
failed=0
for snap in $snaps; do
[ -n "$only_snap" ] && [ "$snap" != "$only_snap" ] && continue
# One store round-trip per snap, not one per architecture.
revisions="$(snapcraft list-revisions "$snap" 2>/dev/null)"
if [ -z "$revisions" ]; then
echo " $snap: could not list revisions (not logged in, or no such snap) - skipping"
skipped=$((skipped + 1))
continue
fi
for arch in $arches; do
[ -n "$only_arch" ] && [ "$arch" != "$only_arch" ] && continue
revision="$(printf '%s' "$revisions" | "$node_bin" -e '
const { parseRevisions, pickRevision } = require("'"$repo_root"'/models/lib/snapArchitectures");
let text = "";
process.stdin.on("data", d => { text += d; });
process.stdin.on("end", () => {
const rev = pickRevision(parseRevisions(text), process.argv[1], process.argv[2] || undefined);
process.stdout.write(rev === null ? "" : String(rev));
});
' "$arch" "$version")"
if [ -z "$revision" ]; then
# Not every architecture is built for every release, and a snap that
# has none of an architecture is not an error - it is a fact about
# what was uploaded. Say so and carry on.
echo " $snap $arch: no revision${version:+ for $version} - skipping"
skipped=$((skipped + 1))
continue
fi
echo " $snap $arch: revision $revision -> $channels"
if [ "$dry_run" -eq 1 ]; then
released=$((released + 1))
continue
fi
if snapcraft release "$snap" "$revision" "$channels"; then
released=$((released + 1))
else
# One architecture failing must not stop the other seventeen.
echo " $snap $arch: RELEASE FAILED" >&2
failed=$((failed + 1))
fi
done
done
echo
echo "===== released $released, skipped $skipped, failed $failed ====="
[ "$failed" -eq 0 ]
@@ -1,362 +0,0 @@
cd ~/repos/wekan
echo "Arabic:"
tx pull -f -l ar
echo "Arabic (Egypt), simply Masri (مَصرى, [ˈmɑsˤɾi], Egyptian, Masr refers to Cairo):"
tx pull -f -l ar_EG
echo "Arabic (Moroccan):"
tx pull -f -l ary
echo "Arabic (Algeria):"
tx pull -f -l ar_DZ
echo "Azerbaijani:"
tx pull -f -l az
echo "Azerbaijani (Azerbaijan):"
tx pull -f -l az_AZ
echo "Azerbaijani (Latin):"
tx pull -f -l az@latin
echo "Armenian:"
tx pull -f -l hy
echo "Basque:"
tx pull -f -l eu
echo "Bulgarian:"
tx pull -f -l bg_BG
echo "Breton:"
tx pull -f -l br
echo "Catalan:"
tx pull -f -l ca
echo "Chinese (China):"
tx pull -f -l zh_CN
echo "Chinese (Hong Kong):"
tx pull -f -l zh_HK
echo "Chinese Simplified (zh-Hans):"
tx pull -f -l zh_Hans
echo "Chinese (Taiwan):"
tx pull -f -l zh_TW
echo "Croatian:"
tx pull -f -l hr
echo "Czech:"
tx pull -f -l cs
echo "Czech (Czech Republic):"
tx pull -f -l cs_CZ
echo "Danish:"
tx pull -f -l da
echo "Dutch:"
tx pull -f -l nl
echo "Dutch (Netherlands):"
tx pull -f -l nl_NL
echo "Esperanto:"
tx pull -f -l eo
echo "Estonian (Estonia):"
tx pull -f -l et_EE
echo "English (Brazil):"
tx pull -f -l en_BR
echo "English (Germany):"
tx pull -f -l en_DE
echo "English (Italy):"
tx pull -f -l en_IT
echo "English (Malaysia) (en_MY):"
tx pull -f -l en_MY
echo "English (United Kingdom):"
tx pull -f -l en_GB
echo "Finnish:"
tx pull -f -l fi
echo "French:"
tx pull -f -l fr
echo "French (Switzerland):"
tx pull -f -l fr_CH
echo "Galician:"
tx pull -f -l gl
echo "Galician (Spain):"
tx pull -f -l gl_ES
echo "Georgian:"
tx pull -f -l ka
echo "German:"
tx pull -f -l de
echo "German (Austria):"
tx pull -f -l de_AT
echo "German (Switzerland) => Deutsch (Schweiz):"
tx pull -f -l de_CH
#echo "Greek:"
#tx pull -f -l el
echo "Greek (Greece):"
tx pull -f -l el_GR
echo "Gurajati (India):"
tx pull -f -l gu_IN
echo "Hebrew:"
tx pull -f -l he
echo "Hebrew (Israel):"
tx pull -f -l he_IL
echo "Hungarian:"
tx pull -f -l hu_HU
echo "Hindi:"
tx pull -f -l hi
echo "Hindi (India)":
tx pull -f -l hi_IN
echo "Khmer (km)":
tx pull -f -l km
echo "Lithuanian:"
tx pull -f -l lt
echo "Persian:"
tx pull -f -l fa
echo "Persian (Iran) فارسی/پارسی (ایران‎):"
tx pull -f -l fa_IR
echo "Indonesian (Indonesia):"
tx pull -f -l id_ID
echo "Igbo:"
tx pull -f -l ig
echo "Italian:"
tx pull -f -l it
echo "Japanese:"
tx pull -f -l ja
echo "Japanese (Hiragana):"
tx pull -f -l ja-Hira
echo "Khmer:"
tx pull -f -l km
echo "Korean:"
tx pull -f -l ko
echo "Korean (Korea):"
tx pull -f -l ko_KR
echo "Latvian (Latvia):"
tx pull -f -l lv_LV
echo "Macedonian:"
tx pull -f -l mk
echo "Malay (ms):"
tx pull -f -l ms
echo "Malay (Malaysia) (ms_MY):"
tx pull -f -l ms_MY
echo "Mongolian (Mongolia):"
tx pull -f -l mn_MN
#echo "Norwegian:"
#tx pull -f -l no
# This is same as no below
echo "Norwegian Bokmål:"
tx pull -f -l nb
echo "Occitan (post 1500):"
tx pull -f -l oc
echo "Persian:"
tx pull -f -l fa
echo "Panjabi (Punjabi):"
tx pull -f -l pa
echo "Polish:"
tx pull -f -l pl
echo "Polish (Poland):"
tx pull -f -l pl_PL
echo "Portuguese:"
tx pull -f -l pt
echo "Portuguese (Brazil):"
tx pull -f -l pt_BR
echo "Romanian:"
tx pull -f -l ro
echo "Romanian (Romania):"
tx pull -f -l ro_RO
echo "Russian:"
tx pull -f -l ru
echo "Russian (Ukraine):"
tx pull -f -l ru_UA
echo "Spanish:"
tx pull -f -l es
echo "Spanish (Argentina):"
tx pull -f -l es_AR
echo "Spanish (Asturia):"
tx pull -f -l ast_ES
echo "Spanish (Chile):"
tx pull -f -l es_CL
echo "Spanish (Latin America)":
tx pull -f -l es_419
echo "Spanish (Mexico)":
tx pull -f -l es_MX
echo "Spanish (Paraguay):"
tx pull -f -l es_PY
echo "Spanish (Peru):"
tx pull -f -l es_PE
echo "Serbian:"
tx pull -f -l sr
echo "Slovak:"
tx pull -f -l sk
echo "Slovenian:"
tx pull -f -l sl_SI
echo "Standard Moroccan Tamazight"
tx pull -f -l zgh
echo "Swahili:"
tx pull -f -l sw
echo "Swedish:"
tx pull -f -l sv
echo "Tamil:"
tx pull -f -l ta
echo "Thai:"
tx pull -f -l th
echo "Turkish:"
tx pull -f -l tr
echo "Ukrainian:"
tx pull -f -l uk
echo "Ukrainian (Ukraine):"
tx pull -f -l uk_UA
echo "Uyghur (ug):"
tx pull -f -l ug
echo "Uzbek (uz):"
tx pull -f -l uz
echo "Uzbek (Arabic) (uz@Arab):"
tx pull -f -l uz@Arab
echo "Uzbek (Latin) (uz@Latn):"
tx pull -f -l uz@Latn
echo "Uzbek (Uzbekistan) (uz_UZ):"
tx pull -f -l uz_UZ
echo "Venda (ve):"
tx pull -f -l ve
echo "Venetian (vec):"
tx pull -f -l vec
echo "Vepsian (vep):"
tx pull -f -l vep
echo "Vietnamese:"
tx pull -f -l vi
echo "Vietnamese (Viet Nam) (vi_VN):"
tx pull -f -l vi_VN
echo "Vlaams (vls):"
tx pull -f -l vls
echo "Volapük (vo):"
tx pull -f -l vo
echo "Walloon (wa):"
tx pull -f -l wa
echo "Welsh (cy):"
tx pull -f -l cy
echo "Welsh (United Kingdom) (cy_GB):"
tx pull -f -l cy_GB
echo "Western Frisian (fy):"
tx pull -f -l fy
echo "Western Frisian (Netherlands) (fy_NL):"
tx pull -f -l fy_NL
echo "Wolof (wo):"
tx pull -f -l wo
echo "Wáray-Wáray (war):"
tx pull -f -l war
echo "Xhosa (xh):"
tx pull -f -l xh
echo "Yeshivish English (en@ysv):"
tx pull -f -l en@ysv
echo "Yiddish (yi):"
tx pull -f -l yi
echo "Yoruba (yo):"
tx pull -f -l yo
echo "Zulu (zu):"
tx pull -f -l zu
echo "Zulu (South Africa) (zu_ZA):"
tx pull -f -l zu_ZA
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1071,
appVersion = 1078,
# Increment this for every release.
appMarketingVersion = (defaultText = "10.71~2026-08-06"),
appMarketingVersion = (defaultText = "10.78~2026-08-09"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+10
View File
@@ -74,6 +74,16 @@ WebApp.handlers.post('/users/login', async function (req, res) {
// out, before doing any user lookup or password work.
const gate = restLoginThrottle.check(clientKey, now);
if (gate.blocked) {
// A canary: being locked out means this address already failed the
// configured number of logins in a row, which nobody typing their own
// password does. The 429 below is unchanged - the attacker learns only
// what the throttle already told them (docs/Security/Remediation/WeKan.md §12).
try {
require('/server/lib/canary').tripCanary('brute.login-lockout', {
req,
detail: 'REST login refused while locked out',
});
} catch (e) { /* a canary must never break the login path */ }
const retryAfterSeconds = Math.ceil(gate.retryAfterMs / 1000);
const error = new Meteor.Error(
'too-many-requests',
+5
View File
@@ -204,6 +204,11 @@ import '/server/lib/tenantResolver';
import '/server/lib/ssrfGuard';
import '/server/lib/ddpSessionSendGuard';
import '/server/lib/databaseProblems';
// What the FILESYSTEM said, and whether this server stopped cleanly last time
// (docs/Security/Remediation/WeKan.md §13). Both write to the 'integrity' event
// stream, shown in Admin Panel / Problems / Filesystem integrity.
import '/server/lib/fileIntegrityScan';
import '/server/lib/uptimeWatch';
import '/server/lib/utils';
// ----------------------------------------------------------------------------
+230
View File
@@ -0,0 +1,230 @@
// ============================================================================
// Canary tokens — the runtime half
// (design: docs/Security/Remediation/WeKan.md §12; pure half:
// models/lib/canaryTokens.js)
// ----------------------------------------------------------------------------
// `tripCanary()` is callable from ANY point in WeKan where somebody could try to
// override permissions - an allow/deny rule, a REST handler, a publication, a
// method. It resolves who is calling and from where, rate-limits, records one
// security event, and RETURNS FALSE.
//
// Returning false is the interface. Every one of these call sites already had to
// produce a refusal, so the canary is written as a drop-in for it:
//
// if (somethingForbidden) return false; // before
// if (somethingForbidden) return tripCanary('card.vote-field', { ... });
//
// and a deny rule, which refuses by returning TRUE, uses `tripCanaryDeny()`.
// Nothing about the response, its timing, its wording or its status changes, so
// the person probing cannot tell a canaried path from an ordinary refusal - if
// they could, they would simply avoid the canaried ones.
//
// NOTHING here may throw into the caller. A tripwire that breaks the request it
// is watching would be a denial of service triggered by trying to report one.
// Every step is wrapped, and the return value is decided before any of the
// reporting work is attempted.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import securityLog from '/server/lib/securityLog';
const {
CanaryRateLimiter,
canaryFor,
canaryPairKey,
canaryDetail,
} = require('/models/lib/canaryTokens');
const { resolveClientKey } = require('/server/lib/loginAttemptThrottle');
// One limiter for the process. Bounded by construction (models/lib/canaryTokens.js):
// a capped map, a counting window, and a ceiling on events per pair.
const limiter = new CanaryRateLimiter();
// Idle pairs are swept so a long-lived server does not hold a row per address
// seen since boot. Ten minutes of silence and the pair is forgotten; its trips
// were all recorded already.
const SWEEP_EVERY_MS = 10 * 60 * 1000;
const IDLE_MS = 10 * 60 * 1000;
if (Meteor.isServer) {
Meteor.startup(() => {
Meteor.setInterval(() => {
try {
limiter.sweep(Date.now(), IDLE_MS);
} catch (e) {
/* a sweeper that throws must not take the timer down */
}
}, SWEEP_EVERY_MS);
});
}
// The username is looked up once per userId and cached briefly: a canary on a
// hot path must not add a database read per attempt, which would hand the
// attacker a cheap way to make WeKan work harder the faster they probe.
const NAME_TTL_MS = 60 * 1000;
const NAME_CACHE_MAX = 1000;
const nameCache = new Map();
function cachedUsername(userId) {
const hit = nameCache.get(userId);
if (hit && Date.now() - hit.at < NAME_TTL_MS) return hit.username;
return undefined;
}
function rememberUsername(userId, username) {
if (nameCache.size >= NAME_CACHE_MAX) {
const oldest = nameCache.keys().next();
if (!oldest.done) nameCache.delete(oldest.value);
}
nameCache.set(userId, { username, at: Date.now() });
}
/**
* Who is doing this, and from where.
*
* Sources, in order: what the caller passed (a REST handler knows its own
* `req`), then the current DDP invocation. The address is resolved with the
* SAME spoofing-safe rule as the login throttle - X-Forwarded-For is honoured
* only as far as HTTP_FORWARDED_COUNT says to trust it - so an attacker cannot
* write someone else's address into the security log by sending a header.
*/
function resolveActor(given = {}) {
const actor = { userId: null, username: '', ip: '' };
try {
if (given.userId) actor.userId = String(given.userId);
if (given.username) actor.username = String(given.username);
if (given.ip) actor.ip = String(given.ip);
if (given.req) {
const req = given.req;
if (!actor.userId && req.userId) actor.userId = String(req.userId);
if (!actor.ip) {
actor.ip = resolveClientKey({
headers: req.headers,
socketAddress:
(req.socket && req.socket.remoteAddress) ||
(req.connection && req.connection.remoteAddress),
forwardedCount: process.env.HTTP_FORWARDED_COUNT,
});
}
}
if (!actor.userId || !actor.ip) {
// A DDP method or allow/deny rule: Meteor exposes the invocation, whose
// `connection` carries the address it already resolved (honouring the
// same HTTP_FORWARDED_COUNT).
const invocation =
typeof DDP !== 'undefined' && DDP._CurrentMethodInvocation
? DDP._CurrentMethodInvocation.get()
: null;
if (invocation) {
if (!actor.userId && invocation.userId) actor.userId = String(invocation.userId);
const connection = invocation.connection;
if (!actor.ip && connection && connection.clientAddress) {
actor.ip = String(connection.clientAddress);
}
}
}
} catch (e) {
/* an actor we could not resolve is still an actor: record what we have */
}
if (!actor.ip) actor.ip = 'unknown';
return actor;
}
/** Fill in the username for a userId, from cache or one lookup. Never throws. */
async function fillUsername(actor) {
if (actor.username || !actor.userId) return actor;
try {
const cached = cachedUsername(actor.userId);
if (cached !== undefined) {
actor.username = cached;
return actor;
}
const user = await Meteor.users.findOneAsync(actor.userId, {
fields: { username: 1 },
});
actor.username = (user && user.username) || '';
rememberUsername(actor.userId, actor.username);
} catch (e) {
/* the account may be gone; the userId is still attribution enough */
}
return actor;
}
/**
* Trip a canary. ALWAYS returns false, immediately - the reporting happens
* afterwards and off the caller's path.
*
* @param {string} canaryId an id from the catalog in models/lib/canaryTokens.js
* @param {object} [context]
* @param {string} [context.userId] the actor, when the caller knows it
* @param {string} [context.username] ...and their name, if already at hand
* @param {string} [context.ip] ...and their address
* @param {object} [context.req] or the HTTP request to take both from
* @param {string} [context.detail] one short clause of extra context. NEVER a
* payload: it is attacker-controlled text and
* the log is not a place to store it.
* @return {false} always
*/
export function tripCanary(canaryId, context = {}) {
try {
report(canaryId, context);
} catch (e) {
if (process.env.DEBUG === 'true') {
console.warn('canary reporting failed:', e && e.message);
}
}
return false;
}
/**
* The same tripwire for a DENY rule, which refuses by returning TRUE.
* @return {true} always
*/
export function tripCanaryDeny(canaryId, context = {}) {
tripCanary(canaryId, context);
return true;
}
/**
* Report, without blocking or affecting the caller. Split out so `tripCanary`
* is nothing but "wrap, and return false".
*/
function report(canaryId, context) {
const canary = canaryFor(canaryId);
const actor = resolveActor(context);
const pair = canaryPairKey(canary.id, actor);
// Decide BEFORE doing any work: a suppressed trip costs one map lookup, which
// is what makes a canary safe to put on a path an attacker can hammer.
const decision = limiter.consider(pair, Date.now());
if (!decision.write) return;
// The username lookup and the insert are both async and neither is awaited:
// the caller has already been given its `false`.
const finish = async () => {
await fillUsername(actor);
securityLog.record({
key: canary.key,
action: 'detected',
source: `canary:${canary.id}`,
userId: actor.userId || undefined,
username: actor.username || undefined,
ip: actor.ip,
count: decision.count,
detail: canaryDetail(canary, decision.count, context.detail),
});
};
const p = finish();
if (p && typeof p.catch === 'function') p.catch(() => {});
}
/** For tests and the admin self-check: how many pairs are being tracked. */
export function canaryTrackedPairs() {
return limiter.size();
}
export default { tripCanary, tripCanaryDeny, canaryTrackedPairs };
+79
View File
@@ -40,10 +40,89 @@ function tooRecent(key) {
return false;
}
// ---------------------------------------------------------------------------
// Database canaries (docs/Security/Remediation/FerretDB.md).
//
// FerretDB writes nothing itself: it refuses the operation and puts a stable
// marker in the error text, and WeKan - which owns the database and the Admin
// Panel - decides what to record. That keeps the whole feature working
// identically on MongoDB, where there is no FerretDB to mark anything and the
// same operations simply never appear.
//
// The id inside `canary:<id>` is FerretDB's, and it is NOT trusted as a
// category: it is matched against a known list, and anything else is recorded
// generically. An error string is attacker-influenced, and a marker parsed out
// of one must never be able to choose which security category it lands in.
const FERRETDB_CANARY_MARKER = 'canary:';
const KNOWN_DB_CANARIES = {
'db.javascript': 'tried to run server-side JavaScript',
'db.result-to-collection': 'tried to write a query result into a collection',
'db.drop-database': 'tried to drop a database',
'db.server-admin': 'tried to run a server administration command',
// The database's own last-look SQL guard refused a statement that carried
// what only injection produces. WeKan builds no SQL, so this is either a bug
// in a statement builder or an attack that reached one - both worth an
// operator's attention (docs/Security/Remediation/FerretDB.md §3b).
'db.sql-injection': 'the database refused a statement that looked like injected SQL',
};
// Extract the id, the same way FerretDB's own IDOf does, so the two cannot drift.
export function databaseCanaryId(error) {
const text = (error && (error.message || error.reason)) || (typeof error === 'string' ? error : '');
if (!text) return '';
const at = text.indexOf(FERRETDB_CANARY_MARKER);
if (at < 0) return '';
let rest = text.slice(at + FERRETDB_CANARY_MARKER.length);
const end = rest.search(/[\s)]/);
if (end >= 0) rest = rest.slice(0, end);
// A marker is an identifier, never a sentence: anything else is not one of
// ours and is treated as absent.
return /^[a-z][a-z0-9.-]{0,60}$/.test(rest) ? rest : '';
}
// True when this error WAS a canary and has been recorded as one.
function recordDatabaseCanary(error, options = {}) {
const id = databaseCanaryId(error);
if (!id) return false;
try {
// Through the ordinary canary path, so a database canary is rate-limited,
// aggregated and attributed exactly like every other one - and, like every
// other one, tells the caller nothing.
const { tripCanary } = require('/server/lib/canary');
const known = Object.prototype.hasOwnProperty.call(KNOWN_DB_CANARIES, id);
// SQL injection gets its own canary id: it is a different thing from "an
// operation the client never issues", and an admin reading the report
// should not have to open the detail to tell them apart.
const canaryId = id === 'db.sql-injection' ? 'injection.sql-statement' : 'database.canary';
tripCanary(canaryId, {
detail: known ? KNOWN_DB_CANARIES[id] : `unrecognised database canary (${id.slice(0, 30)})`,
userId: options.userId,
});
} catch (e) {
if (process.env.DEBUG === 'true') {
// eslint-disable-next-line no-console
console.warn('database canary reporting failed:', e && e.message);
}
}
return true;
}
// Record one database error. Never throws: this is called from error paths, and
// an error handler that fails is worse than the error it was handling.
export function recordDatabaseProblem(error, options = {}) {
try {
// A DATABASE CANARY first: the database refuses operations WeKan never
// issues - server-side JavaScript, an aggregation writing into a collection,
// dropping a database, a server-admin command - and marks the refusal
// `canary:<id>` (.tools/FerretDB internal/util/canary). One of those arriving
// means something spoke to the database socket that was not WeKan, so it is
// a SECURITY event with an actor, not a database problem to be triaged. It
// goes to the security stream and does not fall through to this one, which
// would file it under "the database said something".
if (recordDatabaseCanary(error, options)) return null;
const classified = classifyDatabaseError(error, {
configured: configuredDatabase(process.env),
operation: options.operation || '',
+371
View File
@@ -0,0 +1,371 @@
// ============================================================================
// Filesystem storage integrity — the runtime half
// (design: docs/Security/Remediation/WeKan.md §13; pure half:
// models/lib/fileIntegrity.js)
// ----------------------------------------------------------------------------
// Once a day, when the machine is not busy, walk WeKan's storage directories and
// check that every file is still the file WeKan stored: same name, same size,
// same modification time, same md5, sha256 and sha512 - and that the RECORD of
// all that still matches its ed25519 signature.
//
// Anything that does not match is recorded in Admin Panel / Problems /
// Integrity, and the finding an admin is actually being warned about is a change
// WITH NO RECORD SAYING WHY: files change when people use WeKan, and those
// changes are accounted for. A file that changed while WeKan was not looking is
// the interesting one.
//
// About ed25519. Three digests detect a file being CHANGED. They do not detect
// the baseline being changed to match - anybody who can rewrite a file can
// rewrite a row of hashes. So each baseline entry is signed, and the signature is
// verified on every scan. The key:
//
// * WEKAN_INTEGRITY_PRIVATE_KEY (PKCS#8 PEM) - supplied by the operator and
// never stored by WeKan. This is the configuration that means something
// against an attacker who reaches the DATABASE as well as the disk.
// * otherwise a key generated on first run and kept in the database. That
// detects filesystem-only tampering, which is the common case (a restore, a
// sync tool, a container rebuild, a shell on the volume) - and it is honest
// about not being more than that.
//
// Everything here is best-effort and never throws into its caller: an integrity
// scan that breaks the server it is checking would be a poor trade.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import EventLog from '/models/eventLog';
import FileIntegrity, { IntegrityKeys } from '/models/fileIntegrity';
const fs = Npm.require('fs');
const path = Npm.require('path');
const crypto = Npm.require('crypto');
const {
DIGESTS,
PACING,
manifestLine,
compareEntry,
classifyChange,
nextStep,
isDue,
} = require('/models/lib/fileIntegrity');
const { computeStoragePaths } = require('/models/lib/attachmentStoragePath');
// ------------------------------------------------------------------ the key
let cachedKeys = null;
async function integrityKeys() {
if (cachedKeys) return cachedKeys;
// An operator-supplied key is never written down by WeKan.
const supplied = process.env.WEKAN_INTEGRITY_PRIVATE_KEY;
if (supplied) {
try {
const privateKey = crypto.createPrivateKey(supplied);
cachedKeys = { privateKey, publicKey: crypto.createPublicKey(privateKey), source: 'env' };
return cachedKeys;
} catch (e) {
// A malformed key must not silently fall back to a generated one: that
// would look like it is working while checking nothing the operator meant.
record({
severity: 'high',
category: 'integrity',
action: 'failed',
detail: 'WEKAN_INTEGRITY_PRIVATE_KEY is set but could not be read as a private key',
});
return null;
}
}
const stored = await IntegrityKeys.findOneAsync({ _id: 'ed25519' });
if (stored && stored.privateKeyPem) {
const privateKey = crypto.createPrivateKey(stored.privateKeyPem);
cachedKeys = { privateKey, publicKey: crypto.createPublicKey(privateKey), source: 'database' };
return cachedKeys;
}
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
await IntegrityKeys.upsertAsync(
{ _id: 'ed25519' },
{
$set: {
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
createdAt: new Date(),
},
},
);
cachedKeys = { privateKey, publicKey, source: 'database' };
return cachedKeys;
}
function sign(keys, line) {
if (!keys) return '';
return crypto.sign(null, Buffer.from(line, 'utf8'), keys.privateKey).toString('base64');
}
function verify(keys, line, signature) {
if (!keys || !signature) return false;
try {
return crypto.verify(null, Buffer.from(line, 'utf8'), keys.publicKey,
Buffer.from(signature, 'base64'));
} catch (e) {
return false;
}
}
// --------------------------------------------------------------- the digests
/**
* md5, sha256 and sha512 in ONE pass over the file. Three passes would be three
* times the disk read for the same bytes, which is the opposite of what a
* background scan should do.
*/
function digestFile(filePath) {
return new Promise(resolve => {
const hashes = {};
DIGESTS.forEach(name => { hashes[name] = crypto.createHash(name); });
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => DIGESTS.forEach(name => hashes[name].update(chunk)));
stream.on('error', () => resolve(null));
stream.on('end', () => {
const out = {};
DIGESTS.forEach(name => { out[name] = hashes[name].digest('hex'); });
resolve(out);
});
});
}
// ---------------------------------------------------------------- reporting
function record(evt) {
try {
const p = EventLog.insertAsync({
stream: 'integrity',
at: new Date(),
severity: evt.severity || 'info',
category: evt.category || 'integrity',
bleed: evt.bleed || 'StorageBleed',
action: evt.action || 'detected',
source: evt.source || 'integrity-scan',
detail: String(evt.detail || '').slice(0, 500),
count: evt.count || 1,
});
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('integrity record failed:', e && e.message);
}
}
// ----------------------------------------------------------------- the scan
const sleep = ms => new Promise(resolve => Meteor.setTimeout(resolve, ms));
/** Current CPU load, 0-100, or NaN when it cannot be read. */
function cpuPercent() {
try {
const { currentCpuPercent } = require('/server/lib/cpuMonitor');
if (typeof currentCpuPercent === 'function') return Number(currentCpuPercent());
} catch (e) { /* the monitor is optional */ }
return NaN;
}
function listFiles(root) {
const out = [];
const walk = dir => {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return;
}
entries.forEach(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) out.push(full);
});
};
walk(root);
return out;
}
/**
* Did WeKan itself touch this file around when it changed? An attachment that a
* user replaced has a modified document; one that changed with nothing to show
* for it is the finding.
*/
async function hasRecentRecord(filePath, observedMtimeMs) {
try {
const Attachments = require('/models/attachments').default;
const Avatars = require('/models/avatars').default;
const WINDOW_MS = 10 * 60 * 1000;
const near = new Date(observedMtimeMs - WINDOW_MS);
for (const collection of [Attachments, Avatars]) {
const doc = await collection.collection.findOneAsync({
'versions.original.path': filePath,
});
if (!doc) continue;
const touched = doc.updatedAt || doc.meta?.updatedAt || doc.versions?.original?.updatedAt;
if (touched && new Date(touched) >= near) return true;
// A file WeKan knows about, whose document has no timestamp to compare,
// is treated as explained: the alternative is warning about every file on
// an instance that never set one.
if (!touched) return true;
}
} catch (e) { /* on any doubt, prefer reporting: an unexplained change is the point */ }
return false;
}
/** One pass. Returns a short summary for the caller and the log. */
export async function runIntegrityScan(options = {}) {
const started = Date.now();
const keys = await integrityKeys();
const paths = computeStoragePaths(process.env.WRITABLE_PATH);
const roots = [paths.attachments, paths.avatars].filter(Boolean);
let checked = 0;
let findings = 0;
let unexplained = 0;
let stoppedEarly = '';
const seen = new Set();
for (const root of roots) {
for (const filePath of listFiles(root)) {
let stat;
try {
stat = fs.statSync(filePath);
} catch (e) {
continue;
}
const step = nextStep(
{ cpuPercent: cpuPercent(), elapsedMs: Date.now() - started, fileSize: stat.size },
options.pacing || PACING,
);
if (step.action === 'stop') {
stoppedEarly = step.reason;
break;
}
await sleep(step.pauseMs);
seen.add(filePath);
checked += 1;
const digests = await digestFile(filePath);
if (!digests) continue; // unreadable right now; not a finding on its own
const observed = { path: filePath, size: stat.size, mtimeMs: stat.mtimeMs, digests };
const baseline = await FileIntegrity.findOneAsync({ _id: filePath });
if (!baseline) {
// First sight of a file: record the baseline rather than warning. A new
// instance would otherwise report every file it has.
await storeBaseline(keys, observed);
continue;
}
const signatureValid = verify(keys, manifestLine(baseline), baseline.signature);
const result = compareEntry(baseline, observed, { signatureValid });
if (result.length === 0) continue;
findings += 1;
const explained = await hasRecentRecord(filePath, stat.mtimeMs);
const verdict = classifyChange(result, explained);
if (!verdict.explained) unexplained += 1;
record({
severity: verdict.severity,
action: verdict.explained ? 'detected' : 'blocked',
// The file's NAME, never its contents, and never the digests
// themselves - an admin needs to know which file, not to read it here.
detail: `${path.basename(filePath)}: ${verdict.summary}`,
});
// Re-baseline an EXPLAINED change so it is reported once, not every day.
// An unexplained one is deliberately left, so it keeps showing until
// somebody looks at it.
if (verdict.explained) await storeBaseline(keys, observed);
}
if (stoppedEarly) break;
}
// Files that were recorded and are no longer there. Only when the scan
// completed: a run that stopped early has not looked everywhere, and
// reporting the rest as missing would be a lie.
if (!stoppedEarly) {
const gone = await FileIntegrity.find(
{ _id: { $nin: [...seen] } },
{ fields: { _id: 1 } },
).fetchAsync();
for (const doc of gone) {
const verdict = classifyChange(compareEntry({ path: doc._id }, null), false);
unexplained += 1;
record({
severity: verdict.severity,
action: 'blocked',
detail: `${path.basename(doc._id)}: ${verdict.summary}`,
});
await FileIntegrity.removeAsync({ _id: doc._id });
}
}
const summary = {
checked,
findings,
unexplained,
ms: Date.now() - started,
stoppedEarly,
keySource: keys ? keys.source : 'none',
};
record({
severity: unexplained ? 'high' : 'info',
action: 'detected',
source: 'integrity-scan-summary',
detail:
`checked ${checked} files in ${Math.round(summary.ms / 1000)}s; ` +
`${findings} changed, ${unexplained} with no record saying why` +
(stoppedEarly ? `; stopped early: ${stoppedEarly}` : ''),
});
return summary;
}
async function storeBaseline(keys, observed) {
const entry = {
path: observed.path,
size: observed.size,
mtimeMs: observed.mtimeMs,
digests: observed.digests,
};
await FileIntegrity.upsertAsync(
{ _id: observed.path },
{ $set: { ...entry, signature: sign(keys, manifestLine(entry)), at: new Date() } },
);
}
// ------------------------------------------------------------- the schedule
const CHECK_EVERY_MS = 60 * 60 * 1000; // look at the clock hourly; run daily
if (Meteor.isServer && process.env.WEKAN_INTEGRITY_SCAN !== 'false') {
Meteor.startup(() => {
Meteor.setInterval(async () => {
try {
const last = await IntegrityKeys.findOneAsync({ _id: 'lastRun' });
if (!isDue(last && last.at && new Date(last.at).getTime(), Date.now())) return;
if (Number(cpuPercent()) >= PACING.maxCpuPercent) return; // try again next hour
await IntegrityKeys.upsertAsync({ _id: 'lastRun' }, { $set: { at: new Date() } });
await runIntegrityScan();
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('integrity scan failed:', e && e.message);
}
}, CHECK_EVERY_MS);
});
}
export default { runIntegrityScan };
+27
View File
@@ -19,10 +19,37 @@ function record(evt) {
} catch (e) { /* logging must never break the caller */ }
}
// Sanitizing a filename is routine - names get trimmed, unicode gets normalised,
// an extension gets corrected - and routine is not worth an admin's attention.
// What IS worth it is a name that carried an EXPLOIT: markup, a shell
// metacharacter, a path trying to leave its directory. Those reasons, and only
// those, also trip a canary, so the Security report separates "somebody uploaded
// a file with an awkward name" from "somebody is probing the upload path"
// (docs/Security/Remediation/WeKan.md §12.6).
const DANGEROUS_REASON = /^exploit: |traversal|invisible characters|URL-encoded name/i;
function canaryForReasons(reasons, fileObj) {
try {
const list = Array.isArray(reasons) ? reasons : [];
const dangerous = list.filter(r => typeof r === 'string' && DANGEROUS_REASON.test(r));
if (!dangerous.length) return;
const traversal = dangerous.some(r => /traversal/i.test(r));
const { tripCanary } = require('/server/lib/canary');
tripCanary(traversal ? 'sanitize.path-traversal' : 'sanitize.dangerous-filename', {
userId: (fileObj && (fileObj.userId || (fileObj.meta && fileObj.meta.userId))) || undefined,
// The REASONS, which are a short fixed vocabulary - never the filename,
// which is the attacker's own text.
detail: dangerous.slice(0, 4).join(', '),
});
} catch (e) { /* a canary must never break an upload */ }
}
// Record that a stored filename was sanitized. `reasons` is the array from
// uploadFileName.sanitizationReasons(); `fileObj` supplies the uploader + location.
async function logFilenameSanitized({ fileObj, source, reasons, from, to }) {
try {
canaryForReasons(reasons, fileObj);
const why = Array.isArray(reasons) && reasons.length ? reasons.join('; ') : 'normalized';
let context = '';
try { context = formatFileContext(await resolveFileContext(fileObj)); } catch (e) { /* omit */ }
+12
View File
@@ -43,6 +43,18 @@ export function record(evt = {}) {
detail: sanitizeDetail(m.detail),
};
if (m.userId || m.userid) doc.userId = String(m.userId || m.userid);
// WHO and FROM WHERE (docs/Security/Remediation/WeKan.md §12). Both are
// optional: a guard that fires on an unauthenticated request has no
// username, and one that fires outside a request context has no address.
// Truncated here as well as sanitized, because both are attacker-influenced
// strings - a username can be chosen at registration and an XFF header is
// whatever was sent.
if (m.username) doc.username = String(m.username).slice(0, 100);
if (m.ip) doc.ip = String(m.ip).slice(0, 64);
// How many attempts this row stands for; 1 unless a canary is flushing a
// window's summary.
const count = Number(m.count);
doc.count = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
insert(doc);
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('securityLog.record failed:', e && e.message);
@@ -6,11 +6,21 @@ import Attachments from '/models/attachments';
import { ReactiveCache } from '/imports/reactiveCache';
// #3392: card-to-card dependencies ("Red Strings") only connect cards on the
// same board. When a card is moved to another board, card.move() must drop the
// moved card's now cross-board dependencies and pull inbound references to it
// from the cards left behind on the old board. These are pure-logic stub tests
// of the model helper (there is no `cards.move` Meteor method — the client calls
// the helper directly).
// same board, so a card that moves to another board must lose its own
// cross-board dependencies AND the inbound references to it from the cards left
// behind. These are pure-logic stub tests of the model helper (there is no
// `cards.move` Meteor method — the client calls the helper directly).
//
// #6572 split those two halves apart, and this file changed with it. move()
// clears the moved card's own dependencies as before, in the by-_id update. It
// must NOT do the inbound half: that needs a multi-document update with a
// compound selector, and this helper runs in the CLIENT bundle, where Meteor
// allows updates only by id. It threw "Not permitted. Untrusted code may only
// updateAsync documents by ID" on every cross-board move — before the move
// itself ran, whether or not the card had any dependencies — so the inbound
// cleanup is a Cards.after.update hook in server/models/cards.js now, where a
// selector is allowed and which also covers the REST and import paths that never
// called this helper. tests/cardMoveUntrustedUpdate.test.cjs pins that side.
const origGetBoard = ReactiveCache.getBoard;
@@ -55,7 +65,7 @@ describe('cards move: dependency cleanup (#3392)', function () {
ReactiveCache.getBoard = origGetBoard;
});
it('clears the moved card deps and pulls inbound refs on a cross-board move', async function () {
it('clears the moved card deps, and leaves the inbound half to the server', async function () {
const oldBoardId = 'b-old';
const newBoardId = 'b-new';
ReactiveCache.getBoard = id => {
@@ -81,14 +91,26 @@ describe('cards move: dependency cleanup (#3392)', function () {
expect(mainCall.args[1].$set.cardDependencies).to.deep.equal([]);
expect(mainCall.args[1].$set.boardId).to.equal(newBoardId);
// A multi-update pulls inbound references to the moved card from the old board.
const cleanupCall = updateStub
.getCalls()
.find(c => c.args[0] && c.args[0]['cardDependencies.cardId'] === 'cardA');
expect(cleanupCall, 'inbound cleanup update').to.exist;
expect(cleanupCall.args[0].boardId).to.equal(oldBoardId);
expect(cleanupCall.args[1].$pull.cardDependencies).to.deep.equal({ cardId: 'cardA' });
expect(cleanupCall.args[2]).to.deep.equal({ multi: true });
// #6572: and it does NOT do the inbound half itself. Every update this
// helper makes must be BY ID - a selector, or `multi: true`, is rejected on
// the client with "Untrusted code may only updateAsync documents by ID", and
// that is what used to fail the whole move before it started.
// `{ _id: x }` is allowed - Meteor's rule is "by id", not "not an object" -
// so only a selector that asks for anything else is the violation.
const selectorCall = updateStub.getCalls().find(c => {
const sel = c.args[0];
if (!sel || typeof sel !== 'object') return false;
const keys = Object.keys(sel);
return keys.length !== 1 || keys[0] !== '_id';
});
expect(
selectorCall,
selectorCall
? `move() ran a selector update the client may not make: ${JSON.stringify(selectorCall.args[0])}`
: '',
).to.not.exist;
const multiCall = updateStub.getCalls().find(c => c.args[2] && c.args[2].multi);
expect(multiCall, 'move() ran a multi-document update, which the client may not').to.not.exist;
});
it('does not touch dependencies on a same-board move', async function () {
@@ -1,4 +1,9 @@
/* eslint-env mocha */
// MimeBleed - https://wekan.fi/hall-of-fame/mimebleed/
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
import { expect } from 'chai';
import { looksLikeDangerousMarkup } from '/models/fileValidation';
+10
View File
@@ -62,3 +62,13 @@ import './brokenCardsRepair.tests';
import './checklistbleed.security.tests';
import './proxybleed.security.tests';
import './filenameTruncation.tests';
// Moved here from client/lib/tests in v10.72. It reads boardTriggers.jade and
// boardTriggers.js off disk with fs/path, which are server-only: while the
// client bundle still carried node-polyfill-webpack-plugin's browser shims it
// bundled anyway, and when that plugin left with the @meteorjs/rspack 2.1.0
// update the whole `meteor test` client build stopped with "Cannot find module
// 'path' for matched aliased key 'path'" - one file's imports failing every
// mocha suite there is. A test that reads the repository belongs on the side
// that has a filesystem.
import './boardTriggersClass.tests';
+182
View File
@@ -0,0 +1,182 @@
// ============================================================================
// Unexpected outcomes: crashes, downtime, and errors nobody caught
// (design: docs/Security/Remediation/WeKan.md §13.4)
// ----------------------------------------------------------------------------
// Admin Panel / Problems answers "what did WeKan refuse" and "what did the
// database say". It did not answer the plainest question of all: did this server
// stop, and did it stop cleanly?
//
// A crash leaves nothing behind. The process is gone, so it cannot write a
// message about being gone - which is why a heartbeat is the only thing that
// works: WeKan writes the time every minute, and the NEXT start reads it. A gap
// between the last heartbeat and the new start is downtime, and a last heartbeat
// with no clean-shutdown mark beside it is a crash rather than a restart.
//
// Two more things land here, because they are the same question one level down:
// an uncaught exception and an unhandled promise rejection. Meteor turns the
// second into a process exit in this app, so it is a crash with a cause
// attached - and the cause is worth recording before the process goes.
//
// Best-effort throughout, and never in the way: everything is wrapped, and the
// heartbeat is one small upsert a minute.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import EventLog from '/models/eventLog';
import { IntegrityKeys } from '/models/fileIntegrity';
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
const HEARTBEAT_MS = 60 * 1000;
// A gap longer than two heartbeats is a real gap rather than a slow write.
const DOWNTIME_THRESHOLD_MS = 3 * HEARTBEAT_MS;
function record(evt) {
try {
const p = EventLog.insertAsync({
stream: 'integrity',
at: new Date(),
severity: evt.severity || 'medium',
category: evt.category || 'availability',
bleed: evt.bleed || 'DowntimeBleed',
action: evt.action || 'detected',
source: evt.source || 'uptime',
detail: sanitizeDetail(evt.detail),
count: 1,
});
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('uptime record failed:', e && e.message);
}
}
/**
* Read the previous run's last heartbeat and say what it means. Pure enough to
* be worth keeping separate: given two timestamps and a flag, this is the whole
* decision, and tests/fileIntegrity.test.cjs drives it directly.
*
* @param {object} previous the stored heartbeat document, or null on a first run
* @param {number} nowMs
* @return {{kind: 'first-run'|'clean'|'restart'|'crash', downMs: number, detail: string}}
*/
export function classifyPreviousRun(previous, nowMs) {
if (!previous || !previous.at) {
return { kind: 'first-run', downMs: 0, detail: 'no previous run recorded' };
}
const last = new Date(previous.at).getTime();
const downMs = Math.max(0, Number(nowMs) - last);
const minutes = Math.round(downMs / 60000);
if (previous.cleanShutdown) {
return {
kind: 'clean',
downMs,
detail: `stopped cleanly; down for about ${minutes} minute(s)`,
};
}
if (downMs < DOWNTIME_THRESHOLD_MS) {
// Short, and no clean-shutdown mark: a restart quick enough that the
// shutdown hook may simply not have run. Worth a note, not an alarm.
return {
kind: 'restart',
downMs,
detail: `restarted without a clean-shutdown mark; down for about ${minutes} minute(s)`,
};
}
return {
kind: 'crash',
downMs,
detail:
`the previous run STOPPED WITHOUT SHUTTING DOWN CLEANLY, and this server ` +
`was down for about ${minutes} minute(s)`,
};
}
const SEVERITY_OF = {
'first-run': 'info',
clean: 'info',
restart: 'low',
crash: 'high',
};
if (Meteor.isServer) {
Meteor.startup(async () => {
try {
const previous = await IntegrityKeys.findOneAsync({ _id: 'heartbeat' });
const verdict = classifyPreviousRun(previous, Date.now());
// A clean stop and a first run are not problems; recording them would put
// a row in Problems on every deliberate restart, and a page that cries
// wolf is a page nobody opens.
if (verdict.kind === 'crash' || verdict.kind === 'restart') {
record({
severity: SEVERITY_OF[verdict.kind],
category: 'availability',
action: verdict.kind === 'crash' ? 'failed' : 'detected',
source: 'uptime',
detail: verdict.detail,
});
}
await IntegrityKeys.upsertAsync(
{ _id: 'heartbeat' },
{ $set: { at: new Date(), cleanShutdown: false, pid: process.pid } },
);
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('uptime startup check failed:', e && e.message);
}
Meteor.setInterval(() => {
try {
const p = IntegrityKeys.upsertAsync(
{ _id: 'heartbeat' },
{ $set: { at: new Date(), cleanShutdown: false, pid: process.pid } },
);
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) { /* a heartbeat that fails must not take the timer down */ }
}, HEARTBEAT_MS);
// A clean stop marks itself, which is the only way the next start can tell
// the difference between "stopped" and "died".
const markClean = () => {
try {
IntegrityKeys.update(
{ _id: 'heartbeat' },
{ $set: { at: new Date(), cleanShutdown: true } },
);
} catch (e) { /* nothing useful to do while exiting */ }
};
process.once('SIGTERM', markClean);
process.once('SIGINT', markClean);
// The cause, recorded before the process goes. Listeners are ADDED, never
// replaced: removing whatever else is listening would change how the app
// handles its own failures.
process.on('uncaughtException', error => {
record({
severity: 'critical',
category: 'crash',
bleed: 'CrashBleed',
action: 'failed',
source: 'uncaughtException',
detail: (error && error.message) || String(error),
});
});
process.on('unhandledRejection', reason => {
record({
severity: 'critical',
category: 'crash',
bleed: 'CrashBleed',
action: 'failed',
source: 'unhandledRejection',
detail: (reason && reason.message) || String(reason),
});
});
});
}
export default { classifyPreviousRun };
+88
View File
@@ -0,0 +1,88 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
// "Which of these boards may this user see?" — asked with the SAME selectors as
// the All Boards list and the `board` publication, so a board is visible here
// exactly when it is visible there: public, an active membership, or an ACTIVE
// org/team/domain share.
//
// GHSA-jvv9-498p-hxrg: a card's `parentId` could point at a card on ANOTHER
// board, and nothing checked that the person setting it, or the people
// subscribed to the child board, were allowed to see that other board. The
// board publication walks the whole ancestor chain to render a subtask's path
// (#3453) and published the full ancestor card documents to every subscriber of
// the child board — so writing one id into one field bridged a private board's
// cards into another board's DDP feed. Both ends of that now ask this question:
// the write refuses an invisible parent, and the publication refuses to send
// ancestors from a board the subscriber cannot see.
/**
* @param {string} userId
* @param {string[]} boardIds
* @return {Promise<Set<string>>} the subset of boardIds the user may see
*/
export async function visibleBoardIds(userId, boardIds) {
const ids = (Array.isArray(boardIds) ? boardIds : [])
.filter(id => typeof id === 'string' && id);
if (ids.length === 0) {
return new Set();
}
const user = userId ? await ReactiveCache.getUser(userId) : null;
const $or = boardVisibilitySelectors({
userId: user ? userId : null,
orgIds: user && typeof user.orgIds === 'function' ? user.orgIds() : [],
teamIds: user && typeof user.teamIds === 'function' ? user.teamIds() : [],
emailDomains:
user && typeof user.emailDomains === 'function' ? user.emailDomains() : [],
});
// An anonymous caller still sees public boards, so $or is never empty.
const boards = await ReactiveCache.getBoards(
{ _id: { $in: ids }, $or },
{ fields: { _id: 1 } },
);
return new Set((boards || []).map(board => board._id));
}
/**
* @param {string} userId
* @param {string} boardId
* @return {Promise<boolean>}
*/
export async function canUserSeeBoard(userId, boardId) {
if (!boardId) return false;
const visible = await visibleBoardIds(userId, [boardId]);
return visible.has(boardId);
}
/**
* Refuse a `parentId` that names a card on a board this user cannot see.
* Throws so both the DDP mutation and the REST handler stop before writing;
* the REST layer maps `Forbidden`/`NotFound` to 403/404
* (server/lib/apiResponseHelpers.js httpStatusForError).
*
* @param {string} userId the actor
* @param {string} parentId the proposed parent card id
*/
export async function assertParentCardIsVisible(userId, parentId) {
if (!parentId) return;
const parent = await ReactiveCache.getCard(parentId);
if (!parent) {
const error = new Meteor.Error('NotFound', 'Parent card not found');
error.statusCode = 404;
throw error;
}
if (!(await canUserSeeBoard(userId, parent.boardId))) {
const error = new Meteor.Error(
'Forbidden',
'You are not allowed to use a card from that board as a parent.',
);
error.statusCode = 403;
throw error;
}
}
+9
View File
@@ -77,6 +77,15 @@ Activities.after.insert(async (userId, doc) => {
const user = await activity.user();
if (user) {
params.user = getActivityUserName(user, activity.userId);
// #3113: `user` is a DISPLAY name - getActivityUserName prefers the full
// name, which is what the e-mail notification text wants ("Lauri Ojansivu
// commented ..."). A webhook consumer wants the stable identifier instead,
// and had no way to get it: it received "Lauri Ojansivu" where it needed
// "xet7", and matching users by display name is wrong the moment two people
// share one. So the username travels as its own field rather than by
// changing what `user` means - nothing that already reads `user` breaks,
// and the missing identifier is simply there now.
params.username = user.username || '';
if (user.emails) {
params.userEmails = user.emails;
}
+34 -1
View File
@@ -9,8 +9,9 @@ import {
} from '/server/lib/apiResponseHelpers';
import { ReactiveCache } from '/imports/reactiveCache';
import Activities from '/models/activities';
import CardComments from '/models/cardComments';
import CardComments, { assertCanMutateComment } from '/models/cardComments';
import { ensureIndex } from '/server/lib/mongoStartup';
import { tripCanary } from '/server/lib/canary';
async function commentCreation(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
@@ -194,6 +195,38 @@ WebApp.handlers.delete(
const paramCommentId = req.params.commentId;
const paramCardId = req.params.cardId;
await Authentication.checkBoardAccess(req.userId, paramBoardId);
// GHSA-pqr4-rxgp-hv2m: board membership is not permission to delete
// ANOTHER member's comment. This used to be the only check here, and the
// object-level rule (author, or a board admin unless the board sets
// restrictCommentEditing) lived in a collection hook keyed off the Meteor
// userId — which an HTTP request does not carry, so the hook took its
// "server-internal, no user" path and allowed every deletion. Load the
// comment and apply the same rule DDP applies, before removing anything.
const comment = await ReactiveCache.getCardComment({
_id: paramCommentId,
cardId: paramCardId,
boardId: paramBoardId,
});
if (!comment) {
sendJsonResult(res, { code: 404, data: { error: 'Comment not found' } });
return;
}
// A canary with the REQUEST in hand, so the event carries the real client
// address and not just the account (docs/Security/Remediation/WeKan.md §12).
// Deleting somebody else's comment is refused exactly as before - the
// canary only records the attempt, and the 403 below is unchanged.
if (comment.userId && comment.userId !== req.userId) {
try {
await assertCanMutateComment(req.userId, comment);
} catch (refusal) {
tripCanary('comment.foreign-delete', { req, userId: req.userId });
throw refusal;
}
} else {
await assertCanMutateComment(req.userId, comment);
}
await CardComments.removeAsync({
_id: paramCommentId,
cardId: paramCardId,
+125 -6
View File
@@ -13,6 +13,7 @@ const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency'
import { titleChanged } from '/server/lib/titleChangeActivity';
import { descriptionChanged } from '/server/lib/descriptionChangeActivity';
import { buildDeleteCardActivity } from '/server/lib/deleteActivities';
import { assertParentCardIsVisible } from '/server/lib/visibleBoardIds';
import Activities from '/models/activities';
import Boards from '/models/boards';
import Cards, {
@@ -551,6 +552,43 @@ Cards.after.update(async (userId, doc, fieldNames) => {
await ChecklistItems.direct.updateAsync({ cardId: doc._id }, { $set: { boardId } }, { multi: true });
});
// #6572 / #3392: "Red Strings" only connect cards on the same board, so a card
// that moves away must also lose the INBOUND links pointing at it from the board
// it left, or those cards keep a dangling line to a card that is no longer there.
//
// The card's own dependencies are cleared by the move itself (cardDependencies:
// [] is part of the by-_id update). The inbound ones live on OTHER cards, so they
// need a multi-document update with a compound selector - and that used to sit in
// Cards.move() in models/cards.js, which the client calls directly. Meteor only
// lets untrusted code updateAsync BY ID, so the selector made every cross-board
// move fail with "Not permitted. Untrusted code may only updateAsync documents by
// ID" before the move ran at all, whether or not the card had any dependencies.
//
// Server-side the selector is allowed, and this covers every path that moves a
// card - the client helper, the REST API, and import - rather than only the one
// that happened to call the model helper. .direct like the hook above: this is a
// denormalization cleanup on other documents, not an edit anyone is watching.
//
// Two pulls, because an entry may be either shape: dependencies are stored as
// { cardId, type, color, icon } objects, and data written before #3392's rewrite
// can still hold bare card-id strings (normalizeDependencies upgrades those on
// read, so they are invisible until something has to delete one).
Cards.after.update(async function(userId, doc, fieldNames) {
if (!fieldNames.includes('boardId')) return;
const oldBoardId = (this.previous || {}).boardId;
if (!oldBoardId || oldBoardId === doc.boardId) return;
await Cards.direct.updateAsync(
{ boardId: oldBoardId, 'cardDependencies.cardId': doc._id },
{ $pull: { cardDependencies: { cardId: doc._id } } },
{ multi: true },
);
await Cards.direct.updateAsync(
{ boardId: oldBoardId, cardDependencies: doc._id },
{ $pull: { cardDependencies: doc._id } },
{ multi: true },
);
});
Cards.after.update(async function(userId, doc, fieldNames) {
const previous = this.previous || {};
const oldListId = previous.listId || doc.listId;
@@ -874,6 +912,12 @@ WebApp.handlers.post('/api/boards/:boardId/lists/:listId/cards', async function(
return;
}
// GHSA-jvv9-498p-hxrg: the same read check the linked-card branch above makes
// for `linkedId`, for the parent card. A parent on another board is only
// allowed when the caller may see that board; otherwise creating one card
// would publish that board's ancestor cards to everyone subscribed here.
await assertParentCardIsVisible(req.userId, paramParentId);
const nextCardNumber = await board.getNextCardNumber();
const customFields = await ReactiveCache.getCustomFields({ boardIds: paramBoardId });
@@ -1088,6 +1132,12 @@ WebApp.handlers.put(
updated = true;
}
if (req.body.parentId) {
// GHSA-jvv9-498p-hxrg: a parent card may live on another board, and write
// access HERE says nothing about read access THERE. Without this check a
// board member could name a card on a private board as the parent and the
// board publication would then hand that private card to everyone
// subscribed to this board.
await assertParentCardIsVisible(req.userId, req.body.parentId);
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { parentId: req.body.parentId } },
@@ -1240,14 +1290,61 @@ WebApp.handlers.put(
);
updated = true;
}
if (moveParams.swimlaneId) {
// #6572: a body that names a swimlane or list belonging to ANOTHER board is
// refused, and a cross-board move is only ever done by the isBoardMove
// branch below.
//
// The reporter of #6572 tried to work around the client-side move by sending
// boardId/listId/swimlaneId of the DESTINATION board to this route. Those are
// not the board-move parameters - isBoardMove needs newBoardId, newSwimlaneId
// and newListId - so the board move never ran, while these two branches did:
// the card kept its old boardId and got the destination board's listId and
// swimlaneId written onto it, which points it at a list and a swimlane that
// belong to a different board than the card does. It shows on neither board,
// and it took a hand-written database update to undo.
//
// These two branches are for moving WITHIN the board in the URL, so that is
// what they now enforce; anything else says which parameters to use instead.
// They are also skipped entirely during a board move: they would rewrite
// listId before the isBoardMove update runs, and that update's selector pins
// the card's original listId, so it would then match nothing and silently do
// nothing - the same inconsistent card, by a different route.
if (moveParams.swimlaneId && !moveParams.isBoardMove) {
const sameBoardSwimlane = await ReactiveCache.getSwimlane({
_id: moveParams.swimlaneId,
boardId: paramBoardId,
});
if (!sameBoardSwimlane) {
sendJsonResult(res, {
code: 400,
data: {
error: 'swimlaneId does not belong to this board. To move a card to '
+ 'another board, send newBoardId, newSwimlaneId and newListId.',
},
});
return;
}
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { swimlaneId: moveParams.swimlaneId } },
);
updated = true;
}
if (moveParams.listId) {
if (moveParams.listId && !moveParams.isBoardMove) {
const sameBoardList = await ReactiveCache.getList({
_id: moveParams.listId,
boardId: paramBoardId,
});
if (!sameBoardList) {
sendJsonResult(res, {
code: 400,
data: {
error: 'listId does not belong to this board. To move a card to '
+ 'another board, send newBoardId, newSwimlaneId and newListId.',
},
});
return;
}
// Issue #5399: a same-board list move must land the card on TOP of the
// destination list (like the Move Card dialog: getMinSort then
// minSort - 1), otherwise it only $set listId and the card landed at a
@@ -1515,23 +1612,45 @@ WebApp.handlers.post('/api/boards/:boardId/cards/labels', async function(req, re
return;
}
// ONE read for every card, not one per card.
//
// This used to await a getCard per id and then an update per id - at the
// BULK_CARDS_MAX of 500 that is a thousand round-trips, issued one at a time,
// for a single request. The reads are all the same question, so they are one
// `$in` query; the writes genuinely differ (each card merges its own labels),
// so they stay individual, but they are issued TOGETHER instead of each
// waiting for the last.
const cards = await ReactiveCache.getCards(
{ _id: { $in: cardIds }, boardId: paramBoardId, archived: false },
{ fields: { _id: 1, labelIds: 1 } },
);
const cardById = new Map((cards || []).map(card => [card._id, card]));
const updated = [];
const notFound = [];
const writes = [];
// Iterate cardIds, not the query result: the response has to preserve the
// caller's order and report the ids that matched nothing.
for (const cardId of cardIds) {
const card = await ReactiveCache.getCard({ _id: cardId, boardId: paramBoardId, archived: false });
const card = cardById.get(cardId);
if (!card) {
notFound.push(cardId);
continue;
}
// Merge: keep existing minus removed, then add new ones, de-duplicated.
const merged = mergeLabelIds(card.labelIds, addLabelIds, removeLabelIds);
await Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
writes.push(
Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
),
);
updated.push({ _id: cardId, labelIds: merged });
}
// Every write must land before the 200 says they did.
await Promise.all(writes);
sendJsonResult(res, { code: 200, data: { updated, notFound } });
});
+22 -1
View File
@@ -753,11 +753,32 @@ WebApp.handlers.get('/api/boards/:boardId/lists', async function(req, res) {
const paramBoardId = req.params.boardId;
await Authentication.checkBoardAccess(req.userId, paramBoardId);
// #5251: two dates per list, so a client can tell whether anything changed
// without fetching every card and diffing it.
//
// modifiedAt - the LIST document: title, sort, archived.
// cardsModifiedAt - the newest change among the cards IN it: a card added,
// edited or archived. The list's own modifiedAt does not
// move for those, which is what made this unanswerable.
//
// One query for the board's cards and a reduction in memory, rather than a
// query per list. Archived cards are included on purpose: archiving a card is
// one of the changes the client is asking about.
const { newestCardChangeByList } = require('/models/lib/listActivityDates');
const lists = await ReactiveCache.getLists({ boardId: paramBoardId, archived: false });
const cards = await ReactiveCache.getCards(
{ boardId: paramBoardId },
{ fields: { listId: 1, modifiedAt: 1, dateLastActivity: 1 } },
);
const newestByList = newestCardChangeByList(cards);
sendJsonResult(res, {
code: 200,
data: (await ReactiveCache.getLists({ boardId: paramBoardId, archived: false })).map(doc => ({
data: lists.map(doc => ({
_id: doc._id,
title: doc.title,
modifiedAt: doc.modifiedAt || null,
cardsModifiedAt: newestByList.get(doc._id) || null,
})),
});
} catch (error) {
+4
View File
@@ -55,6 +55,10 @@ const Lock = {
'boardId',
'comment',
'user',
// #3113: the display name (`user`) and the login name, both. WEBHOOKS_ATTRIBUTES
// overrides this list wholesale, so a deployment that already sets it keeps
// exactly the fields it asked for.
'username',
'card',
'commentId',
'swimlaneId',
+14 -29
View File
@@ -2,23 +2,15 @@ import Attachments from '/models/attachments';
import Boards from '/models/boards';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
function hasUnsafeClientVersionFields(fileObj) {
const versions = fileObj?.versions;
if (!versions || typeof versions !== 'object') {
return false;
}
return Object.values(versions).some((version) => {
if (!version || typeof version !== 'object') {
return false;
}
// Path and storage are internal server-managed metadata.
return Object.prototype.hasOwnProperty.call(version, 'path') ||
Object.prototype.hasOwnProperty.call(version, 'storage');
});
}
// GHSA-4mxf-m8pq-xc9p: these guards used to be written out here and NOT in
// server/permissions/avatars.js, which is how avatars ended up without them.
// They live in one place now, and both collections import that one.
import {
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
} from '/models/lib/fileVersionFields';
import { tripCanary } from '/server/lib/canary';
Attachments.allow({
async insert(userId, fileObj) {
@@ -27,7 +19,7 @@ Attachments.allow({
if (process.env.DEBUG === 'true') {
console.warn('Blocked attachment insert with client-supplied versions.path/storage');
}
return false;
return tripCanary('attachment.version-path', { userId });
}
// Admin-level hard stop for all non-API attachment uploads.
@@ -51,27 +43,20 @@ Attachments.allow({
// path traversal attacks via storage migration exploits.
// Block direct updates to server-managed version metadata.
const touchesVersions = fields.some(field => field === 'versions' || field.startsWith('versions.'));
if (touchesVersions) {
if (touchesVersionFields(fields)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update attachment versions metadata:', fields);
}
return false;
return tripCanary('attachment.version-path', { userId });
}
// Allow normal updates for file upload/management
const allowedFields = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta'];
const isAllowedField = fields.every(field => {
// Allow field itself or nested properties like 'versions.original'
const baseField = field.split('.')[0];
return allowedFields.includes(baseField);
});
if (!isAllowedField) {
if (!onlyTouchesAllowedFields(fields, allowedFields)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update restricted attachment fields:', fields);
}
return false;
return tripCanary('attachment.restricted-field', { userId });
}
// ReadOnly users cannot update attachments
+48 -1
View File
@@ -1,5 +1,16 @@
import Avatars from '/models/avatars';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import {
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
} from '/models/lib/fileVersionFields';
import { tripCanary } from '/server/lib/canary';
// What an avatar's owner may change from the client. `versions` is NOT here,
// and is refused separately below so the reason can be logged: it holds the
// server-managed on-disk path.
const ALLOWED_UPDATE_FIELDS = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta'];
function isOwner(userId, doc) {
return userId && userId === doc.userId;
@@ -23,12 +34,48 @@ async function avatarUploadsBlocked() {
Avatars.allow({
async insert(userId, doc) {
// GHSA-4mxf-m8pq-xc9p: the same guard attachments have. `versions.*.path`
// says where on disk the bytes are; a client that can choose it can make
// the board exporter read any file the WeKan process can read.
if (hasUnsafeClientVersionFields(doc)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked avatar insert with client-supplied versions.path/storage');
}
// A canary: no WeKan client sends this field, so an insert carrying it is
// somebody trying PathBleed (docs/Security/Remediation/WeKan.md §12).
return tripCanary('avatar.version-path', { userId });
}
if (await avatarUploadsBlocked()) {
return false;
}
return isOwner(userId, doc);
},
update: isOwner,
// GHSA-4mxf-m8pq-xc9p: this used to be `update: isOwner` - the owner could
// set ANY field, `versions.original.path` included. Being the owner is still
// necessary, but no longer sufficient: the storage metadata is server-managed
// and a client never writes it.
update(userId, doc, fields) {
if (!isOwner(userId, doc)) {
return tripCanary('avatar.not-owner', { userId });
}
if (touchesVersionFields(fields)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update avatar versions metadata:', fields);
}
return tripCanary('avatar.version-path', { userId });
}
if (!onlyTouchesAllowedFields(fields, ALLOWED_UPDATE_FIELDS)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update restricted avatar fields:', fields);
}
return tripCanary('avatar.restricted-field', { userId });
}
return true;
},
remove: isOwner,
fetch: ['userId'],
});
@@ -1,6 +1,8 @@
import CardCommentReactions from '/models/cardCommentReactions';
import Boards from '/models/boards';
import { allowIsBoardMemberCommentOnly } from '/server/lib/utils';
const { denyForeignReactionChange } = require('/models/lib/reactionOwnership');
import { tripCanaryDeny } from '/server/lib/canary';
// Reacting to a comment is a form of commenting, so it follows the same rule as
// CardComments.insert: members who may comment (Normal / Comment-only) are
@@ -19,3 +21,25 @@ CardCommentReactions.allow({
},
fetch: ['boardId'],
});
// Being allowed to react is not being allowed to react AS SOMEBODY ELSE.
//
// The rule above is board membership and nothing more, and the whole reaction
// list is one field - so a member could `$set` `reactions` to anything: add a
// colleague's userId to a reaction they never made, or remove one they did.
// Same shape as GHSA-pqr4-rxgp-hv2m (REST comment delete): a per-user object
// gated only by "are you on this board". It is integrity rather than
// confidentiality - reactions are visible to the whole board already - but it
// puts words in another person's mouth.
//
// `toggleReaction()` only ever touches Meteor.userId(), so no legitimate client
// sends anything else. Refuse an update that changes any OTHER user's presence
// in any reaction, and refuse the modifier forms ($push/$pull/dotted paths) that
// cannot be checked this way and that no client uses.
CardCommentReactions.deny({
update(userId, doc, fieldNames, modifier) {
if (!denyForeignReactionChange(userId, doc, modifier)) return false;
return tripCanaryDeny('reaction.foreign', { userId });
},
fetch: ['reactions'],
});
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more