mirror of
https://github.com/wekan/wekan.git
synced 2026-11-08 11:25:19 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae46562308 | ||
|
|
f48b2c03e2 | ||
|
|
3f9b81bcbc | ||
|
|
484eae7362 | ||
|
|
3f80161607 | ||
|
|
bf047d53c8 | ||
|
|
c623718db1 | ||
|
|
4b89394087 | ||
|
|
5269d0ba5c | ||
|
|
3b6b601085 | ||
|
|
fcc3951f40 | ||
|
|
418ea838bc | ||
|
|
09a525ae83 | ||
|
|
68dbbd8b47 | ||
|
|
1cef5ede9a | ||
|
|
71992ef546 | ||
|
|
80f5518724 | ||
|
|
097ad9160a | ||
|
|
6420819cfa | ||
|
|
184e1713d3 | ||
|
|
0c5cb99b7c | ||
|
|
b6d0be0f45 | ||
|
|
3ebc38cb40 | ||
|
|
b4ebe48d7b | ||
|
|
09ce64ecf6 | ||
|
|
8456170d45 | ||
|
|
b1e7b1e538 | ||
|
|
dac8d1da2f | ||
|
|
c452a2defd | ||
|
|
c8393f3f53 | ||
|
|
be558257cd | ||
|
|
7fc1f35bed | ||
|
|
a442959dda | ||
|
|
b6d003089c | ||
|
|
fc1e089d38 | ||
|
|
35b44a1f8b | ||
|
|
a434bb4bd5 | ||
|
|
292ffb8906 | ||
|
|
1a2ec06332 | ||
|
|
2c7a016327 | ||
|
|
18da0d2618 | ||
|
|
c0a5c2b7c9 | ||
|
|
01f691ba3d | ||
|
|
8248892e33 | ||
|
|
7b8c377099 | ||
|
|
e1c6221f22 |
No files matched your search
@@ -10,7 +10,13 @@ name: Release all missing
|
||||
# those six without running the whole thing again, version bump and all.
|
||||
#
|
||||
# This finishes a release that already exists. It never bumps a version, never
|
||||
# tags, never touches Docker, snap, the website or the charts.
|
||||
# tags, and never touches Docker, snap or the website.
|
||||
#
|
||||
# It DOES publish the Helm chart, because that is a thing a release can be
|
||||
# missing like any other: the chart entry is written once, during the release it
|
||||
# belongs to, so a release whose charts job did not run leaves a hole that the
|
||||
# next release never goes back for - v10.80 is one of those, and it was not
|
||||
# alone. See the charts job at the end.
|
||||
#
|
||||
# WHAT IT CAN BUILD, and why that is not everything. Only build-amd64 runs
|
||||
# Meteor; every other bundle WeKan ships is that same bundle repacked. This job
|
||||
@@ -279,9 +285,107 @@ jobs:
|
||||
publish: true
|
||||
only: ${{ needs.plan.outputs.flatpak }}
|
||||
|
||||
|
||||
# ─── The Helm chart, for a release that never got one ───────────────────────
|
||||
#
|
||||
# Everything else here rebuilds a missing BUNDLE; this publishes a missing
|
||||
# CHART, which goes missing the same way and for the same reason. A chart is
|
||||
# written once, by release-charts.sh, during the release it belongs to, so a
|
||||
# release whose charts job did not run has no chart and nothing ever goes back
|
||||
# for it. That is not rare - 146 of the packages already on gh-pages had no
|
||||
# index entry, and v10.80 has no package at all, because a cancelled
|
||||
# build-mac-x64 cancelled its run before the charts job started.
|
||||
#
|
||||
# It is the one part of this workflow that PUSHES to another repository, so it
|
||||
# is guarded twice: it does nothing unless a token that can push to
|
||||
# wekan/charts is configured, and nothing if the chart for this version is
|
||||
# already published. Re-publishing an existing version would re-tar the package
|
||||
# and change the digest of a chart people have already pulled.
|
||||
charts:
|
||||
name: Helm chart
|
||||
needs: plan
|
||||
if: ${{ always() && needs.plan.outputs.version != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Is a chart already published for this version?
|
||||
id: check
|
||||
env:
|
||||
V: ${{ needs.plan.outputs.version }}
|
||||
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${REPO_TOKEN:-}" ]; then
|
||||
echo "::warning::No WEKAN_REPO_TOKEN, so the Helm chart cannot be published. Add a token that can push to wekan/charts to have this finish the chart too."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The published index is the authority on what exists, not this
|
||||
# checkout. An entry's own version is at EXACTLY four spaces; the
|
||||
# mongodb subchart's version sits deeper and would match a loose grep.
|
||||
curl -fsSL --retry 3 -o index.yaml https://wekan.github.io/charts/index.yaml || {
|
||||
echo "::warning::Could not read the published chart index; not touching the charts repo."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
# -F, a FIXED string: the dots in a version are regex wildcards
|
||||
# otherwise, so "10.80" would also match a line reading 10x80. -x so it
|
||||
# is the whole line, and the four-space indent is what makes it an
|
||||
# entry's own version rather than the mongodb subchart's, which sits
|
||||
# deeper.
|
||||
if grep -qxF " version: ${V}.0" index.yaml; then
|
||||
echo "::notice::v$V already has a published Helm chart (${V}.0); nothing to do."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "v$V has no published chart; publishing ${V}.0."
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout wekan (for releases/release-charts.sh)
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan/charts (main + gh-pages)
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/charts
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
fetch-depth: 0
|
||||
path: charts
|
||||
|
||||
- name: Install Helm
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: azure/setup-helm@v5
|
||||
|
||||
- name: Publish the chart for this release
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
CHARTS_DIR: ${{ github.workspace }}/charts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd charts
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git branch --track gh-pages origin/gh-pages 2>/dev/null || true
|
||||
git checkout main
|
||||
cd ..
|
||||
./wekan/releases/release-charts.sh "${{ needs.plan.outputs.version }}"
|
||||
|
||||
- name: Job result
|
||||
if: always()
|
||||
run: |
|
||||
case "${{ job.status }}" in
|
||||
success) mark='OK ' ;;
|
||||
failure) mark='FAILED ' ;;
|
||||
cancelled) mark='CANCELLED' ;;
|
||||
*) mark='${{ job.status }}' ;;
|
||||
esac
|
||||
echo "$mark charts" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
done:
|
||||
name: what is still missing
|
||||
needs: [plan, extra-arches, appimage, flatpak]
|
||||
needs: [plan, extra-arches, appimage, flatpak, charts]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -410,6 +410,17 @@ jobs:
|
||||
# is actually on the registries. If `docker` fails, this job is skipped.
|
||||
charts:
|
||||
needs: docker
|
||||
# `always()` so that a CANCELLATION somewhere else in the run cannot skip
|
||||
# this job. It is not defensive dressing: in v10.80 the release itself, the
|
||||
# image and every bundle were published, and then charts, ucs and nextcloud
|
||||
# were all skipped the moment `docker` finished - because build-mac-x64 was
|
||||
# queued on a retired runner label and had to be cancelled by hand, and a
|
||||
# cancelled job cancels the RUN, which skips every job that has not started.
|
||||
# A job whose `if` is `always()` still runs while a run is cancelling, so the
|
||||
# chart is published for a release that IS on the registries. The guard is
|
||||
# then explicit about the only condition that ever mattered: docker SUCCEEDED
|
||||
# - the chart points at the image that job pushes, so it must exist first.
|
||||
if: ${{ always() && needs.docker.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# First step on purpose: a missing secret is then ONE named line at the top of
|
||||
@@ -475,6 +486,21 @@ jobs:
|
||||
run: |
|
||||
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
|
||||
|
||||
# What the index does NOT have, reported every release rather than
|
||||
# discovered years later. A chart entry is written once, during the release
|
||||
# it belongs to, so a release whose charts job did not run leaves a hole
|
||||
# that nothing ever fills - v10.80's, when a cancelled build-mac-x64
|
||||
# cancelled the run, is one of many. This is a PLAN ONLY: it reads the
|
||||
# releases API, the live index and ghcr, writes nothing, and prints which
|
||||
# releases have a chart, which are missing one that could be built, and
|
||||
# which are omitted because they have no container image for a chart to
|
||||
# point at. Filling them in is `backfill-charts.sh --apply --push`, run by
|
||||
# hand: rewriting a published Helm index is not a side effect of a release.
|
||||
- name: Report which releases the chart index is missing
|
||||
continue-on-error: true
|
||||
run: |
|
||||
./wekan/releases/backfill-charts.sh | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Always, as the last step: the run summary then answers "what worked and what
|
||||
# did not" for the whole release at a glance, instead of eighteen job logs. A
|
||||
# step that failed has already printed an ::error:: saying why; this names the
|
||||
@@ -2314,7 +2340,18 @@ jobs:
|
||||
# failing red, and returns on its own if a Node.js for it appears again.
|
||||
build-mac-x64:
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: macos-13 # macos-13 runners are Intel (x86_64); macos-14+ are arm64
|
||||
# Intel (x86_64) macOS. It was `macos-13`, and GitHub RETIRED that image -
|
||||
# actions/runner-images publishes macos-14, macos-15 and macos-26 only, and
|
||||
# macos-13 is gone from the hosted-runner table. A retired label does not
|
||||
# fail: the job QUEUES FOREVER waiting for a runner that will never come, so
|
||||
# every release since then had a build-mac-x64 sitting there for hours until
|
||||
# it was cancelled by hand - and cancelling it cancelled the RUN, which is
|
||||
# what skipped the charts, ucs and nextcloud jobs (they had not started yet).
|
||||
# No release has ever carried a wekan-<version>-mac-x64.zip because of it.
|
||||
# Intel macOS is still offered, under new labels: macos-15-intel and
|
||||
# macos-26-intel (macos-14/15/26 without the suffix are Apple Silicon). This
|
||||
# is the same label .tools/TSC already builds its Intel Mac on.
|
||||
runs-on: macos-15-intel
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -2622,17 +2659,37 @@ jobs:
|
||||
node_arch: x86
|
||||
ferretdb_arch: i386
|
||||
optional: true
|
||||
# ARMv6: Raspberry Pi 1 and Zero. It runs in a linux/arm/v6 container,
|
||||
# takes wekan/node-patches' node-armv6 (nobody else publishes an ARMv6
|
||||
# Node - nodejs.org dropped it after Node 11), and FerretDB and the
|
||||
# MongoDB tools build a GOARM=6 armv6 of their own. Their armel is
|
||||
# GOARM=5 and WOULD run on these boards, but in software floating
|
||||
# point, so it is not used here.
|
||||
# ARMv6: Raspberry Pi 1 and Zero. It takes wekan/node-patches'
|
||||
# node-armv6 (nobody else publishes an ARMv6 Node - nodejs.org dropped
|
||||
# it after Node 11), and FerretDB and the MongoDB tools build a GOARM=6
|
||||
# armv6 of their own. Their armel is GOARM=5 and WOULD run on these
|
||||
# boards, but in software floating point, so it is not used here.
|
||||
#
|
||||
# The CONTAINER is linux/arm/v7, not linux/arm/v6, and that is not a
|
||||
# typo. DEBIAN HAS NO ARMv6 PORT: debian:trixie publishes arm/v5 and
|
||||
# arm/v7 only, and because containerd treats a lower ARM variant as
|
||||
# compatible, asking it for linux/arm/v6 silently handed back arm/v5 -
|
||||
# Debian armel, ARMv5 SOFT-float. node-armv6 is HARD-float, so it could
|
||||
# not start there and the job died on
|
||||
# qemu-arm: Could not open '/lib/ld-linux-armhf.so.3'
|
||||
# after the whole apt install (v10.80). Debian's arm/v7 IS armhf: the
|
||||
# same hard-float ABI and the same loader, so node-armv6 runs in it.
|
||||
#
|
||||
# Nothing ARMv7 reaches the bundle. What the container does is run
|
||||
# `npm install`, and that install compiles NOTHING - it is npm-rebuild
|
||||
# over pure-JavaScript packages ("rebuilt dependencies successfully",
|
||||
# no node-gyp, no CXX). Every binary in the bundle is downloaded already
|
||||
# built FOR ARMv6: node-armv6, ferretdb-armv6 and the armv6 MongoDB
|
||||
# tools. The container only has to be a userland this arch's node can
|
||||
# RUN in, and armhf is exactly that. The one thing that would have been
|
||||
# the container's own CPU is the bundled qemu-user, and that is why
|
||||
# SAME_ARCH_CONTAINER is false below.
|
||||
- arch: armv6
|
||||
platform: linux/arm/v6
|
||||
platform: linux/arm/v7
|
||||
image: debian:trixie
|
||||
node_arch: armv6
|
||||
ferretdb_arch: armv6
|
||||
same_arch_container: false
|
||||
optional: true
|
||||
- arch: armhf
|
||||
platform: linux/arm/v7
|
||||
@@ -2778,6 +2835,7 @@ jobs:
|
||||
-e NODE_SHA256="$NODE_SHA256" \
|
||||
-e ARCH='${{ matrix.arch }}' \
|
||||
-e NODE_ARCH='${{ matrix.node_arch }}' \
|
||||
-e SAME_ARCH_CONTAINER='${{ matrix.same_arch_container }}' \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-v "$(pwd)/releases:/releases:ro" \
|
||||
-w /bundle/programs/server \
|
||||
@@ -3988,7 +4046,18 @@ jobs:
|
||||
# usually transient, so the three attempts above cover a one-off.
|
||||
# (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 BUILD LOG IS PRINTED ABOVE AND IT SAYS WHY. Read it before
|
||||
# believing any guess in this message - the guess has been wrong. For
|
||||
# four releases this line said "often an OOM in the Meteor npm install,
|
||||
# or a transient build-farm reset", and every one of the four
|
||||
# architectures was in fact failing on one deterministic line:
|
||||
# Failed to copy '.../parts/mongo42/install/mongo42':
|
||||
# no such file or directory
|
||||
# a `stage:` filter naming a path that an amd64/arm64-only part does not
|
||||
# produce anywhere else. Nothing transient about it, and "re-run" was
|
||||
# the one thing that could not help. So these are named as the things to
|
||||
# LOOK FOR in the log above, not as what probably happened.
|
||||
echo "::error::snap-launchpad ${{ matrix.arch }}: the Launchpad build log is printed above - read it first; it names the part and the line that failed. Common causes, in the order they are worth checking: (1) a 'Failed to copy .../parts/<part>/install/<path>' - a stage:/prime: filter naming something the part did not produce on THIS architecture, which is deterministic and will not pass on a re-run; (2) an npm/network error inside a part's override-build, which is transient and is retried; (3) 'Stopped' with no artifact and no error - then check that https://launchpad.net/xet7-craft-remote-build still has a licence set (MIT / X/Expat), and otherwise Launchpad ended the build itself. Builds: https://launchpad.net/~xet7/+snaps" >&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
|
||||
@@ -4361,8 +4430,13 @@ jobs:
|
||||
ucs:
|
||||
# ENABLED. `continue-on-error: true` below means a failure here can never
|
||||
# fail the release or hold up another job - the point of running it is to
|
||||
# SEE what it reports. Disable again with `if: ${{ false }}`.
|
||||
# SEE what it reports. Disable again by replacing the `if` below with
|
||||
# `if: ${{ false }}`.
|
||||
needs: [prepare, release, docker]
|
||||
# always(), for the same reason as the charts job above: a cancellation
|
||||
# elsewhere in the run skips every job that has not started yet, and in
|
||||
# v10.80 that silently dropped this one along with charts.
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.release.result == 'success' && needs.docker.result == 'success' }}
|
||||
continue-on-error: true
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
@@ -4456,8 +4530,13 @@ jobs:
|
||||
nextcloud:
|
||||
# ENABLED. `continue-on-error: true` below means a failure here can never
|
||||
# fail the release or hold up another job - the point of running it is to
|
||||
# SEE what it reports. Disable again with `if: ${{ false }}`.
|
||||
# SEE what it reports. Disable again by replacing the `if` below with
|
||||
# `if: ${{ false }}`.
|
||||
needs: [prepare, release, docker]
|
||||
# always(), for the same reason as the charts job above: a cancellation
|
||||
# elsewhere in the run skips every job that has not started yet, and in
|
||||
# v10.80 that silently dropped this one along with charts.
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.release.result == 'success' && needs.docker.result == 'success' }}
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+887
-3
@@ -276,6 +276,890 @@ browser build to verify).
|
||||
|
||||
</details>
|
||||
|
||||
# v10.83 2026-08-11 WeKan ® release
|
||||
|
||||
**In short:** a **CRITICAL SECURITY ISSUE**, **PassBleed**: the single-card
|
||||
Excel export authorised against the board named in the URL and then read the
|
||||
card named in the URL, with nothing tying the two together. Any authenticated
|
||||
user could create their own public board, name it as the board, and export any
|
||||
card from any private board on the instance - including the bytes of its image
|
||||
attachments. The identically shaped PDF route had always resolved its card
|
||||
correctly, which is what showed this was an omission rather than a decision, and
|
||||
it is what the Excel exporter now does. It also fixes **broken avatar images**,
|
||||
seen after upgrading from v6 but never actually working: the route that serves
|
||||
them asked `Meteor.userId()`, which throws in a plain HTTP handler rather than
|
||||
answering "nobody", and the handler turned that into a 500 - and, once that was
|
||||
fixed, that the same route had always ignored the `boardId` the client appends
|
||||
so a **public board** can show its members' pictures to visitors. The binaries
|
||||
below are v10.82's: nothing here rebuilds them.
|
||||
|
||||
| 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 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
|
||||
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
|
||||
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
|
||||
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
|
||||
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
|
||||
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
|
||||
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
|
||||
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
|
||||
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
|
||||
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
|
||||
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
|
||||
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
|
||||
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
|
||||
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
|
||||
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
|
||||
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
|
||||
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
|
||||
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
|
||||
|
||||
This release fixes the following CRITICAL SECURITY ISSUE of
|
||||
[PassBleed](https://wekan.fi/hall-of-fame/passbleed/):
|
||||
|
||||
**The single-card Excel export** - which card it is allowed to read.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/5269d0ba5">PassBleed: the export authorised against one board and read a card from another</a>. Thanks to TWPaMWang and xet7.</summary>
|
||||
|
||||
[PassBleed](https://wekan.fi/hall-of-fame/passbleed/) -
|
||||
[GHSA-6p5m-f9p2-wqm5](https://github.com/wekan/wekan/security/advisories/GHSA-6p5m-f9p2-wqm5),
|
||||
Moderate, CWE-639, CVSS 6.5. `GET
|
||||
/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel` checked whether
|
||||
the caller could see the board in `:boardId`, then resolved the card by
|
||||
`:cardId` alone. Nothing confirmed that the card was on that board, so the two
|
||||
identifiers came apart: one decided the authorisation, the other decided the
|
||||
data.
|
||||
|
||||
The pass is self-service. `POST /api/boards` takes `permission` straight from
|
||||
the request body, so any authenticated user could mint their own PUBLIC board,
|
||||
name it as `:boardId`, and pass the id of a card in somebody else's private
|
||||
board as `:cardId`. `:listId` was never used in a query at all and could be any
|
||||
string.
|
||||
|
||||
What came back was the card: title, full description, members and assignees,
|
||||
every comment with its author, checklists and checklist items, subtask titles,
|
||||
attachment metadata - and, because image attachments are read through
|
||||
`getReadStream()` and embedded with `workbook.addImage`, the attachment BYTES.
|
||||
The same board could be reused while `:cardId` was substituted, which made it a
|
||||
scriptable bulk read rather than a single disclosure. The REST API is on by
|
||||
default in the shipped Docker configuration.
|
||||
|
||||
The fix was already in the codebase one file away: the identically shaped PDF
|
||||
route has always resolved `getCard({ _id, boardId, listId })` and 404s a
|
||||
cross-board id. That control is what shows the Excel exporter's omission was a
|
||||
defect rather than a decision, and it is what the Excel exporter now does.
|
||||
Constraining the QUERY matters more than a check after it - the exporter fans
|
||||
out on the same card id for checklists, subtasks, comments and attachments, none
|
||||
of which carry a board constraint of their own, so a card that cannot resolve
|
||||
outside the authorised board makes all of them safe by construction.
|
||||
|
||||
The route binds the two identifiers as well, before either branch builds -
|
||||
deliberate duplication, because that is where both arrive together and it covers
|
||||
the public-board branch, which skips authentication entirely. A card that is not
|
||||
on the named board is a 404 rather than a 403, so the difference does not reveal
|
||||
whether a card id exists.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
**Avatars** - the routes that serve a profile picture, and who they serve it to.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/bf047d53c">Ask the request who it is, because Meteor.userId() cannot</a>. Thanks to markusst1982 and xet7.</summary>
|
||||
|
||||
Following the same upgrade as [#6583](https://github.com/wekan/wekan/issues/6583),
|
||||
profile pictures came back as broken images - initials rendered fine, and the
|
||||
Admin Panel showed a user's picture while a board showed the missing-picture
|
||||
icon for the same person.
|
||||
|
||||
Nothing was lost, and the migration is not at fault. The avatar files migrate,
|
||||
and the Meteor-Files record made from a CollectionFS filerecord even reuses its
|
||||
`_id`, so a 6.x URL still names the right object. What broke is the request for
|
||||
it. A 6.x install stores `profile.avatarUrl` as `/cfs/files/avatars/<id>`; that
|
||||
route serves the legacy bytes if they are still there and otherwise redirects to
|
||||
`/cdn/storage/avatars/<id>`, which asked who was asking with `Meteor.userId()`.
|
||||
|
||||
That reads the current DDP invocation's environment, which exists inside a
|
||||
method or a publication and NOT in a WebApp handler - where it does not return
|
||||
"nobody", it THROWS. The handler wraps its body in a `try`/`catch` that answers
|
||||
500, so the throw was swallowed into a broken image, and no avatar served
|
||||
through that route ever reached anybody on any install. The upgrade did not
|
||||
cause it; it moved every avatar URL onto the route where it already applied.
|
||||
`server/routes/legacyAttachments.js` had the identical call, so legacy
|
||||
attachment URLs failed the same way.
|
||||
|
||||
An HTTP request carries its identity in the request: a bearer token, an
|
||||
`X-Auth-Token` header, an `?authToken=` parameter, or the login cookie - and on
|
||||
Sandstorm, a platform-injected user id and no Meteor token at all.
|
||||
`server/routes/universalFileServer.js` has always resolved it that way and
|
||||
serves attachments correctly today. `server/lib/requestUser.js` lifts that
|
||||
resolution out so the two routes that were guessing share it rather than grow a
|
||||
third copy. It never throws: a caller deciding whether to serve a file wants an
|
||||
answer, not an exception its own `catch` will turn back into a 500.
|
||||
|
||||
`tests/requestUserAuth.test.cjs` pins that neither route calls
|
||||
`Meteor.userId()`, that both `await` the resolver - an unawaited Promise is
|
||||
truthy and would authorise everybody - that all four token carriers and the
|
||||
Sandstorm path are handled, and that the migration still reuses the id the old
|
||||
URL names. Confirming the served image needs an upgraded instance.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/484eae736">Honour the boardId parameter the client has been sending all along</a>. Thanks to markusst1982 and xet7.</summary>
|
||||
|
||||
Found while checking why the Admin Panel showed a picture that a board did not.
|
||||
The two URLs differ in one thing: the `avatarUrl` helper in
|
||||
`client/components/users/userAvatar.js` appends `?boardId=<id>`, and says why in
|
||||
its own comment - *"so public viewers can access avatars on public boards"*. The
|
||||
Admin Panel uses `profile.avatarUrl` raw.
|
||||
|
||||
`/cdn/storage/avatars/:fileName` never read that parameter. It required a
|
||||
signed-in user and nothing else, so on a public board every visitor who was not
|
||||
logged in got a 401 and the missing-picture icon - the exact case the parameter
|
||||
was added for. Fixing `Meteor.userId()` alone would have left that half broken.
|
||||
|
||||
The named board must now exist, be public, AND have the avatar's owner as a
|
||||
member. The last part is not ceremony: without it, naming any public board would
|
||||
unlock any avatar on the instance, and a public board publishes its own members,
|
||||
not everybody.
|
||||
|
||||
The legacy redirect keeps the query string too. `/cfs/files/avatars/<id>` 301s
|
||||
to `/cdn/storage/avatars/<id>`, and that is the path EVERY migrated 6.x avatar
|
||||
URL takes, so dropping `?boardId=` there would 401 exactly the installs the
|
||||
entry above sets out to fix.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.82 2026-08-11 WeKan ® release
|
||||
|
||||
**In short:** a **CRITICAL SECURITY ISSUE**, **WhereBleed**: eight Admin Panel
|
||||
handlers took a query selector from the client and checked only its type, so a
|
||||
`$where` in one made the database run the caller's JavaScript - a repeatable
|
||||
denial of service, reachable by a per-tenant admin. The detector for it was
|
||||
already in the codebase and wired into one publication; the eight siblings never
|
||||
called it, and now share the one copy. Then **the snap**, where two permanent
|
||||
markers meant an instance that had failed on an older revision never retried on
|
||||
the fixed one, so the MongoDB 4.2 reader added for it never ran.
|
||||
**Notifications** grew an unbounded array inside the user document that SQLite
|
||||
was rewriting on every addition, which is the slow login and the pinned CPU.
|
||||
**Clicking an open card** closes it again, and a focused Admin Panel checkbox is
|
||||
no longer drawn as a diamond. It also adds the first new feature in this
|
||||
release: **checklists and card feature groups fold away**, on the opened card
|
||||
and on the minicard, asked for since 2018. Below that: a typed two-digit year
|
||||
refused rather than stored as the year 26, the **Helm chart index** listing only
|
||||
charts that can be installed, and a way to remove the Templates containers made
|
||||
for accounts that never used them. And two developer-facing fixes: the
|
||||
database-conformance stage no longer opens a debug port nothing in it uses - one
|
||||
taken by an unrelated FerretDB made every backend report a database problem that
|
||||
was not one - and two more snap give-up paths that deleted the directory their
|
||||
own stage filter names. The binaries below are v10.81's: nothing here rebuilds
|
||||
them.
|
||||
|
||||
| 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 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
|
||||
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
|
||||
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
|
||||
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
|
||||
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
|
||||
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
|
||||
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
|
||||
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
|
||||
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
|
||||
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
|
||||
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
|
||||
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
|
||||
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
|
||||
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
|
||||
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
|
||||
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
|
||||
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
|
||||
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
|
||||
|
||||
This release fixes the following CRITICAL SECURITY ISSUE of
|
||||
[WhereBleed](https://wekan.fi/hall-of-fame/wherebleed/):
|
||||
|
||||
**The Admin Panel's People, Org, Team and Translation panes** - what a query
|
||||
from the client is allowed to be.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b4ebe48d7">WhereBleed: eight Admin Panel handlers ran the caller's selector unchecked</a>. Thanks to TungNGo02 and xet7.</summary>
|
||||
|
||||
[WhereBleed](https://wekan.fi/hall-of-fame/wherebleed/) -
|
||||
[GHSA-phm4-4v26-j2vq](https://github.com/wekan/wekan/security/advisories/GHSA-phm4-4v26-j2vq),
|
||||
Moderate, CWE-943, CVSS 5.8. The people, org, team and translation publications
|
||||
and their companion count/page methods take a query selector from the client and
|
||||
validate only its TYPE - `check(query, Match.OneOf(Object, null))` - which is
|
||||
not validation, because a MongoDB selector is executable data. `$where` makes
|
||||
the database run the caller's JavaScript once per document scanned, so
|
||||
`Meteor.subscribe('team', { $where: 'while(true){}' }, 25, 0)` pins a database
|
||||
worker for as long as the caller likes, repeatably: denial of service for every
|
||||
tenant on the instance from one narrowly-scoped account.
|
||||
|
||||
The reporter demonstrated both halves on v10.81 against a real MongoDB 7:
|
||||
`$where: 'sleep(2000) || true'` made the subscription take 2.03s and return the
|
||||
document, `$where: 'false'` returned nothing in 0.00s - the caller deciding, in
|
||||
JavaScript, which documents come back.
|
||||
|
||||
It needs an authenticated admin session, so no board member or visitor can reach
|
||||
it. It matters at this severity because the people and org surfaces are open to
|
||||
a **per-tenant Global Admin**, a role meant to be confined to one Organization,
|
||||
and those two wrap the caller's selector as `{ $and: [query, restriction] }`
|
||||
rather than stripping execution operators out of it - so merging the tenant
|
||||
restriction never removed the `$where`, and a role scoped to one tenant reached
|
||||
instance-wide impact.
|
||||
|
||||
What makes this one particular is that **the defence was already in the
|
||||
codebase**. `classifySelector` and `hasWhere` were written for exactly this
|
||||
class, are unit-tested, and were wired into the card-window publication. Eight
|
||||
sibling handlers taking the identical shape of selector simply never called
|
||||
them. So the fix adds no new detection logic: that publication's own helper
|
||||
moves unchanged into a shared module and all nine call sites use the one copy -
|
||||
a second copy would be the same bug set up to happen again.
|
||||
|
||||
Each handler refuses with the "match nothing" selector the card window already
|
||||
uses in production, `{ _id: { $in: [] } }`, so a refused request returns an
|
||||
empty result instead of throwing at an admin mid-page. Ordinary behaviour is
|
||||
untouched: none of the searches, filters, regexes,
|
||||
`$or`/`$and`/`$in`/`$elemMatch` or date ranges those panes send carries an
|
||||
execution operator.
|
||||
|
||||
FerretDB, WeKan's default database, rejects `$where` itself, so this degrades to
|
||||
a rejected query there; the supported MongoDB path is where it was reproduced.
|
||||
MongoDB 7 also happens to reject `$where` inside the aggregation pipeline the
|
||||
count methods use - but that is an engine accident for one operator on one call
|
||||
path, so those methods are guarded like the rest rather than left to it.
|
||||
|
||||
</details>
|
||||
|
||||
and adds the following new feature:
|
||||
|
||||
**Cards** - folding away what you are not reading.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/1cef5ede9">Checklists and card feature groups collapse, on the opened card and on the minicard</a>. Thanks to czinkos, MikeRatcliffe, JannetGen and xet7.</summary>
|
||||
|
||||
Asked for in 2018: "It would be great to have collapsable checklists on cards",
|
||||
and again this week - "Long checklists can make a card pretty cluttered, so
|
||||
being able to collapse them and expand only when needed would keep the board
|
||||
much cleaner."
|
||||
|
||||
WeKan had something adjacent and it was not this. A checklist carries
|
||||
`hideAllChecklistItems`, reachable through a toggle switch inside the checklist
|
||||
actions popup - but that is a field ON THE CHECKLIST, so flipping it changes
|
||||
what everyone on the board sees, and it is an edit to the card rather than a
|
||||
view preference. It is untouched; it has its own uses.
|
||||
|
||||
Collapsing is per-user, which WeKan already says twice in its own models for
|
||||
lists and swimlanes, so this follows them: one map in the user profile keyed by
|
||||
card. A feature group uses its own section name, an individual checklist uses a
|
||||
key of its own - which is why folding a checklist on the opened card folds it on
|
||||
the minicard too.
|
||||
|
||||
The control is a caret on the title rather than another entry in a menu, since
|
||||
the point is to fold at a glance while reading the card. It carries
|
||||
aria-expanded and answers Enter and Space. For the sixteen feature groups on the
|
||||
opened card it is done once, with a delegated handler and CSS: they all open
|
||||
with a title but only three wrap what follows in a content element, so folding
|
||||
hides every sibling after the title, which works whatever a section puts there.
|
||||
|
||||
A checklist's progress bar stays visible when folded - it is the summary of what
|
||||
was folded away - and the fold survives reopening the card.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
**The snap, upgrading from an old MongoDB** - and why a fixed version changed
|
||||
nothing.
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/097ad9160">A new snap revision is a new chance, so the MongoDB 4.2 reader actually gets to run</a>. Thanks to Philippe-Bentegeac, JDeepix, imlit and xet7.</summary>
|
||||
|
||||
Reported against 10.81: "I still have the exact same issue, MongoDB cannot
|
||||
start. The web interface is still unreachable, and I do not see the messages you
|
||||
added in the last commits." The messages were missing because the code that
|
||||
prints them never ran.
|
||||
|
||||
Two markers in `$SNAP_COMMON` stop the snap doing work, and both were PERMANENT.
|
||||
`.mongodb-data-too-old` is written when no reader in the snap could open the
|
||||
data, after which mongod is not started at all; `.mongod-start-failures` is a
|
||||
counter that, past three, stops the migration being re-run so a migration/mongod
|
||||
restart loop cannot form. Both are right, and both record a conclusion about
|
||||
what THAT snap could do.
|
||||
|
||||
An instance that had already failed on 10.79 or 10.80 - before mongod 4.2 was
|
||||
bundled - carried a marker saying "no reader can open this" and a counter far
|
||||
past three. So 10.81 never started mongod, never attempted the migration, and
|
||||
printed nothing new. Upgrading to the version with the fix changed nothing,
|
||||
which is exactly what their log shows: the database-selection line, then
|
||||
"Waiting for MongoDB replica set primary..." forever.
|
||||
|
||||
Each marker now records the revision that wrote it, and one from a different
|
||||
revision is ignored and cleared - a marker with no revision recorded at all is
|
||||
stale by definition, which is precisely what the affected instances carry.
|
||||
Within one revision nothing changes, so the loop protection still holds; and not
|
||||
knowing the revision is never taken as evidence that it changed.
|
||||
|
||||
</details>
|
||||
|
||||
**Notifications** - and the database write behind a slow login.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/184e1713d">The notification tray is capped, so SQLite is not rewriting an ever-growing array</a>. Thanks to Nissulya and xet7.</summary>
|
||||
|
||||
An instance reports FerretDB at 737% CPU, logins over a minute, boards not
|
||||
appearing, and logs full of `database is locked (5) (SQLITE_BUSY)`. The stack
|
||||
names the same write every time: `addNotification`.
|
||||
|
||||
That is an `$addToSet` on `profile.notifications`, an array inside the user
|
||||
document - so adding one entry reads the whole document, scans the array and
|
||||
writes the document back, at a cost proportional to the array. FerretDB on
|
||||
SQLite has a single writer, so those rewrites queue and start failing, and
|
||||
login, which also writes to the user document, queues behind them.
|
||||
|
||||
It grows without limit because the existing cleanup only removes notifications
|
||||
that have been READ. A user who never clears their tray accumulates entries
|
||||
forever. The same pass now also keeps the newest
|
||||
`NOTIFICATION_TRAY_MAX_PER_USER` (default 1000) and drops the rest. It is
|
||||
applied to what is left after the expiry pull, a user needing no change is not
|
||||
written to at all, and it stays one write per user. This bounds the array; it
|
||||
does not make SQLite a multi-writer engine, and a busy instance still wants the
|
||||
PostgreSQL backend.
|
||||
|
||||
</details>
|
||||
|
||||
**Cards and the Admin Panel** - two things people asked for in the same thread.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/6420819cf">Clicking an open card closes it, and a focused checkbox is not drawn as a diamond</a>. Thanks to csonkaoszimt, Heart1010 and xet7.</summary>
|
||||
|
||||
Closing a card by clicking it again was not a missing feature - it was an
|
||||
unreachable one. The handler already ended with a branch that closed the open
|
||||
card, but the TITLE branch above it returned first, and a minicard's title
|
||||
covers most of the minicard. So the second click almost always re-opened the
|
||||
card that was already open, and the toggle worked only if you managed to miss
|
||||
the title. The title branch now makes the same decision, and on a phone, where
|
||||
the card is a popup, the second click closes that.
|
||||
|
||||
The Problems page glitch in the screenshot is the focus ring. The Admin Panel
|
||||
draws its checkboxes as a square that morphs into a tick, and the tick IS a
|
||||
40-degree rotation of the element - so a browser draws its focus ring around a
|
||||
rotated box, and a checkbox that is both checked and focused (which is what one
|
||||
you just clicked is) comes out as a blue diamond. The ring moves to the row that
|
||||
contains it, which is not rotated; keyboard focus stays visible.
|
||||
|
||||
</details>
|
||||
|
||||
**Card dates** - what a typed date actually becomes.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/0c5cb99b7">A typed two-digit year is refused instead of stored as the year 26</a>. Thanks to xet7.</summary>
|
||||
|
||||
From email feedback: "If i write the expiration date with the keyboard it turns
|
||||
red, if i choose it with the date picker it is yellow. Can you please tell me
|
||||
the difference?" The colour was never the difference - the YEAR was.
|
||||
|
||||
`<input type="date">` reports its value as YYYY-MM-DD, but a browser lets the
|
||||
year sub-field be typed as two digits and reports exactly that: entering
|
||||
31-12-26 gives "0026-12-31", the year 26 AD. That is a valid Date, so nothing
|
||||
refused it, and the card was saved with a due date two thousand years in the
|
||||
past. Red is what an overdue date looks like. The attached screenshot shows it:
|
||||
the yellow badges read "31-12-2026" and the red ones "31-12-26".
|
||||
|
||||
Saving now refuses a year outside 1000-9999 and says which digits are missing.
|
||||
It refuses rather than silently correcting 0026 to 2026, because that would be a
|
||||
guess about a date other people's reminders hang off.
|
||||
|
||||
</details>
|
||||
|
||||
**Old template containers** - the boards nobody asked for.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/80f551872">Remove the Templates containers that were made for accounts which never used them</a>. Thanks to xet7.</summary>
|
||||
|
||||
From email feedback: FerretDB at 190-350% CPU on an instance with 14490 boards,
|
||||
of which 13404 are template containers, for 9264 accounts of which 478 have ever
|
||||
logged in.
|
||||
|
||||
Before v10.00 every new account got a "Templates" container board at signup
|
||||
whether or not the person ever saved a template. v10.00 made that lazy
|
||||
(\#2339, \#5850), so no new account creates one - but nothing removed the ones
|
||||
already made, and they are not visible enough for anybody to delete by hand. On
|
||||
that instance they are 13x the boards collection, and every query that touches
|
||||
boards carries it.
|
||||
|
||||
Because this deletes boards, the rule for what may go is narrow: only a
|
||||
container nobody ever used. A template saved into it, a list, swimlane or card,
|
||||
a second member, a rename, a star or a manual archive all keep it, and every
|
||||
board that is kept reports why. A rename is judged only against the titles the
|
||||
app itself used, so a container whose default name is in another language is not
|
||||
deleted for it. The default is a dry run showing what WOULD go; deleting takes a
|
||||
second, explicit request.
|
||||
|
||||
</details>
|
||||
|
||||
**The snap database** - which copy of the data it serves.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b1e7b1e53">A FerretDB copy older than the MongoDB beside it is never served</a>. Thanks to markusst1982 and xet7.</summary>
|
||||
|
||||
After upgrading from v6 the reporter saw *"the state of the data from days ago"*
|
||||
and suspected a `snap revert` done four weeks earlier. They were right about the
|
||||
cause, and nothing was lost.
|
||||
|
||||
The MongoDB to FerretDB migration copies MongoDB into SQLite **once** and writes
|
||||
`.migration-to-ferretdb-done`. That copy is a snapshot; nothing keeps it in
|
||||
step. Revert the snap to a revision that runs `mongod` and WeKan carries on
|
||||
writing to MongoDB - for four weeks here - while the finished SQLite sits frozen
|
||||
at the date it was made. Refresh forward again and the snap saw a marker plus a
|
||||
non-empty SQLite, called that a completed migration and switched onto it. Every
|
||||
board and card written during the revert was still on disk and simply not being
|
||||
served.
|
||||
|
||||
The data was never in danger: it lives in `$SNAP_COMMON`, which is shared across
|
||||
revisions and is **not** rolled back by a revert (unlike `$SNAP_DATA`, which is
|
||||
per-revision). What was wrong was *which* of the two copies got served, and
|
||||
nothing compared their ages.
|
||||
|
||||
A new check answers exactly that: `mongod` rewrites its WiredTiger files on
|
||||
every commit, so the newest mtime among them is when MongoDB was last written
|
||||
to, and later than the marker means the copy is behind. It counts data files
|
||||
only - a newer `mongodb.log` means the snap was started, not that the database
|
||||
changed - and allows a margin, because the migration stops its own temporary
|
||||
source `mongod` moments after writing the marker. Anything it cannot tell is
|
||||
reported as NOT stale, since the callers act on a yes.
|
||||
|
||||
Where `mongod` runs, the snap now stays on MongoDB, which has the newest data.
|
||||
Where `mongod` cannot start at all - the case that forces the migration in the
|
||||
first place - it migrates again from scratch rather than serve the old copy; the
|
||||
migration reads with its own temporary `mongod` and the 4.2/3.2 readers, so it
|
||||
still reaches everything written since. And `wekan-control` gains the second
|
||||
half of a guard it already had: it refused to start an *empty* FerretDB while
|
||||
MongoDB had data, and a *full but out-of-date* one looks worse, because WeKan
|
||||
comes up with everything present except the last weeks.
|
||||
|
||||
</details>
|
||||
|
||||
**Board and card drag** - what scrolls while a card is held.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/8456170d4">Dragging a card down scrolls the list, not the whole board</a>. Thanks to markusst1982 and xet7.</summary>
|
||||
|
||||
*"Upwards is no problem, the Line scrolls automaticly up, but this does not work
|
||||
downwards. The whole Page/Site scrolls down and not ne Line"*.
|
||||
|
||||
The card drag auto-scrolls at the edges. Horizontally it picks the lane under
|
||||
the pointer ([#443](https://github.com/wekan/wekan/issues/443)); vertically it picked
|
||||
nothing, and always scrolled `.board-canvas` - which holds the swimlanes -
|
||||
rather than the `.list-body` under the pointer, which is `overflow-y: scroll`
|
||||
and holds the cards. Scrolling the canvas moves the whole board.
|
||||
|
||||
The asymmetry is what makes it reproducible. Dragging **up**, the canvas is
|
||||
usually already at the top, so the handler did nothing and jQuery UI's own
|
||||
scroll option - which acts on the placeholder's scroll parent, the list body -
|
||||
scrolled the list, which is why up always worked. Dragging **down**, the canvas
|
||||
nearly always has room left, so the handler fired first and scrolled the board
|
||||
instead.
|
||||
|
||||
The list under the pointer is scrolled first now, and the board only once that
|
||||
list cannot go further - so a drag down a long list scrolls the list, and a drag
|
||||
past the end of it moves on to the board, which is what dragging a card into
|
||||
another swimlane needs.
|
||||
|
||||
</details>
|
||||
|
||||
**The Helm chart index** - which charts it lists.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/a442959dd">List only the charts whose container images still exist</a>. Thanks to xet7.</summary>
|
||||
|
||||
Backfilling the index taught this within the hour: Artifact Hub scans every
|
||||
entry and mailed a list of errors.
|
||||
|
||||
```
|
||||
error scanning image ghcr.io/wekan/wekan:v9.62: image not found error scanning
|
||||
image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found
|
||||
```
|
||||
|
||||
That was this side's doing. The rebuild listed every package on gh-pages, and a
|
||||
chart is a POINTER TO CONTAINER IMAGES - one whose images have been deleted
|
||||
installs and then fails at the pull, so listing it says the repository is broken
|
||||
when the repository is fine and the images are gone. 135 of the 360 packages are
|
||||
in that state, from two unrelated causes: **six WeKan images were never pushed**
|
||||
(v8.30, v9.12, v9.14, v9.38, v9.39, v9.62 - releases whose own docker job
|
||||
failed, and exactly the six Artifact Hub named), and **129 older charts vendor
|
||||
the Bitnami mongodb subchart** and pin tags Bitnami has since deleted. Charts
|
||||
from 8.41 on vendor groundhog2k's mongodb, which uses the official `mongo` image
|
||||
and is unaffected.
|
||||
|
||||
The index now holds 225 entries: every one of the 216 that were listed before -
|
||||
none dropped - plus the 9 backfilled packages whose images all resolve. The
|
||||
exclusions are recorded in `unindexed.txt` beside the packages, not decided per
|
||||
run, so a rebuild during a release cannot depend on reaching two registries, and
|
||||
an image that comes back is one deleted line away from being listed again. The
|
||||
`.tgz` files stay, so direct URLs keep working.
|
||||
|
||||
Two things this shook out. `--check-images` asks each registry with ITS OWN 401
|
||||
challenge instead of a hard-coded token URL per host - the first attempt
|
||||
reported every quay.io image as missing, including `quay.io/wekan/wekan:latest`,
|
||||
which plainly exists. And an image that cannot be checked is never treated as
|
||||
missing, only a definite 404, so a registry hiccup cannot silently unpublish
|
||||
charts. `release-charts.sh` now refuses to publish a chart at all when
|
||||
`ghcr.io/wekan/wekan:v<version>` does not exist, which is what created these six
|
||||
in the first place.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following developer-facing fixes:
|
||||
|
||||
**The test run** - a stage that failed for a reason that was not about WeKan.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/09a525ae8">The database-conformance run no longer opens a debug port nothing in it uses</a>. Thanks to xet7.</summary>
|
||||
|
||||
Every backend of the conformance stage failed before a single query was
|
||||
compared: "Failed to create debug handler ... listen tcp 127.0.0.1:8088: bind:
|
||||
address already in use", then "FerretDB did not start on this backend" for each
|
||||
of them.
|
||||
|
||||
FerretDB opens a debug handler for metrics and profiling at 127.0.0.1:8088 by
|
||||
default and EXITS when that address is taken, so an unrelated FerretDB running
|
||||
on the machine made the whole stage report a database problem that was nothing
|
||||
of the sort - as it would for anyone with anything on that port.
|
||||
|
||||
The script already takes this seriously for the two ports it knows about: it
|
||||
picks a free wire port, makes both overridable, and says in its own comment that
|
||||
they are chosen so it can run while something else is running. The debug port
|
||||
was simply never passed. Nothing in the run queries it, so it is not opened at
|
||||
all - which is also what FerretDB's own integration tests effectively do,
|
||||
choosing a random debug port rather than the default.
|
||||
|
||||
</details>
|
||||
|
||||
**The snap build** - the part that could end it.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/b6d0be0f4">Two more mongo42 give-up paths deleted the directory their stage filter names</a>. Thanks to xet7.</summary>
|
||||
|
||||
The earlier fix for this covered one of the three ways the mongo42 part gives up
|
||||
- the unsupported-architecture exit. The other two removed the whole staged
|
||||
directory and exited 0, which leaves `stage: mongo42` naming a path that is not
|
||||
there, and snapcraft ends the build on that rather than skipping it.
|
||||
|
||||
Those two are reached on amd64 and arm64, where the binary IS downloaded:
|
||||
OpenSSL 1.1 unavailable for the architecture, or the staged mongod 4.2 failing
|
||||
the check that it actually runs. Either would have ended the snap build for the
|
||||
two architectures that matter most, the same way it ended all four Launchpad
|
||||
ones. Both now clear the CONTENTS and keep the directory; an empty one still
|
||||
means "no 4.2 reader", because every use is guarded on the binary rather than
|
||||
the directory.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.81 2026-08-11 WeKan ® release
|
||||
|
||||
**In short:** the release pipeline, and two bugs that stopped WeKan starting at
|
||||
all. **FerretDB** advertises its own listen address as the member list of a
|
||||
one-node replica set, so the MongoDB driver threw away the host in `MONGO_URL`
|
||||
and dialled `0.0.0.0:27017` instead - a fresh `docker compose up` could not
|
||||
reach its database, and every FerretDB compose file now says
|
||||
`directConnection=true`. **All Boards on a phone** could not be scrolled to its
|
||||
last boards, its Table view could not be scrolled at all, single rows of board
|
||||
icons were drawn several times their proper height, and the icons sat too close
|
||||
together - four separate causes, one of them a `calc(100dvh - 48px)` in
|
||||
`layouts.css` that guessed at a header height which is measured at runtime
|
||||
precisely because it is never one number. On the release side: the **Mac x64
|
||||
bundle** has never once been built, because its runner label was retired by
|
||||
GitHub and a retired label queues forever instead of failing - and cancelling
|
||||
that queue is what silently skipped the **charts**, **ucs** and **nextcloud**
|
||||
jobs. The **armv6 bundle** was being assembled in a soft-float ARMv5 userland
|
||||
that its own hard-float Node.js cannot start in, and **every Launchpad snap**
|
||||
was ended by an optional MongoDB-migration part that stages a path it does not
|
||||
build outside amd64 and arm64. Below that: a report of the WeKan releases the
|
||||
Helm chart index is missing, and the duplicate entries found in it. The
|
||||
binaries below are v10.80's: nothing here rebuilds them.
|
||||
|
||||
| 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 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
|
||||
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
|
||||
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
|
||||
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
|
||||
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
|
||||
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
|
||||
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
|
||||
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
|
||||
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
|
||||
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
|
||||
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
|
||||
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
|
||||
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
|
||||
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
|
||||
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
|
||||
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
|
||||
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
|
||||
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**FerretDB** - the default database, and how WeKan is told to reach it.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/e1c6221f2">Connect with directConnection=true, so a fresh docker compose up starts</a>. Thanks to Dandrass and xet7.</summary>
|
||||
|
||||
A new install with nothing changed but the port and `ROOT_URL` could not reach
|
||||
the database:
|
||||
|
||||
```
|
||||
MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
|
||||
reason: TopologyDescription { type: 'ReplicaSetNoPrimary',
|
||||
servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
|
||||
setName: 'rs0', ... }
|
||||
```
|
||||
|
||||
`0.0.0.0` is in no compose file. It is FerretDB's own listen address, and the
|
||||
driver was handed it by the server. The `ferretdb` service runs with
|
||||
`--repl-set-name=rs0` - added in
|
||||
[#6480](https://github.com/wekan/wekan/issues/6480) so Meteor can tail an OpLog
|
||||
instead of poll-and-diff - so FerretDB answers the `hello` handshake as a
|
||||
one-member replica set and fills `hosts`, `me` and `primary` with its
|
||||
`--listen-addr`. A driver not in direct-connection mode reads that as an
|
||||
invitation to do replica-set discovery: it adopts the advertised member list and
|
||||
drops the seed it was given, because the server reports a name other than the
|
||||
one that was dialled. `mongodb://ferretdb:27017` became `0.0.0.0:27017`, which
|
||||
inside the `wekan-app` container is that container itself.
|
||||
|
||||
Measured against FerretDB v1.49.0 with the driver the bundle ships:
|
||||
without the parameter the topology ends up `ReplicaSetWithPrimary` with the seed
|
||||
discarded, with it the topology is `Single` on the host that was given.
|
||||
It costs nothing else - the handshake still reports `setName: rs0`, the only
|
||||
thing Meteor checks before it will tail an OpLog. All five FerretDB v1 compose
|
||||
files carry it; the MongoDB ones deliberately do not, being real replica sets
|
||||
whose members are reachable under the names they advertise.
|
||||
|
||||
</details>
|
||||
|
||||
**All Boards on a phone** - the scroll, the tiles and the spacing between them.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/c0a5c2b7c">The board list scrolls to its last board, the Table view scrolls at all, and the tiles are their own size</a>. Thanks to mimZD and xet7.</summary>
|
||||
|
||||
Four things at once, in an issue reopened against 10.10, 10.37, 10.38, 10.72 and
|
||||
10.77.
|
||||
|
||||
THE SCROLL. Every earlier fix removed viewport arithmetic from `boardsList.css`.
|
||||
The last piece of it was in `layouts.css`:
|
||||
`body.mobile-mode #content { height: calc(100dvh - 48px) }`. 48px is a guess at
|
||||
the height of the header, and the header is not 48px and is not any one number -
|
||||
`--wekan-header-height` is published from a `ResizeObserver` for exactly this
|
||||
reason. On a phone whose bar wraps, `#content` was taller than the room under
|
||||
the header, its bottom sat below the screen, and `body.mobile-mode` is
|
||||
`position: fixed` and `overflow: hidden`, so that strip is unreachable. Nothing
|
||||
needs computing: `body` is a flex column of one viewport and `#content` is its
|
||||
`flex: 1` item.
|
||||
|
||||
THE TABLE VIEW. The right column has two branches - the board icons and
|
||||
`+tablePage` - and only the icons were ever given a scroller, so in Table view
|
||||
the rows past the fold were clipped with nothing to scroll.
|
||||
|
||||
THE 4x-TALL TILES. A grid defaults to `align-content: stretch`, so a list
|
||||
shorter than its column has the leftover height divided among its rows and each
|
||||
tile grown into it - and a board tile paints its colour over the whole cell.
|
||||
This was fixed once, but only on `.board-list.mobile-view`; the phone media
|
||||
query builds a grid too, and a narrow window that is not a mini screen took
|
||||
that path.
|
||||
|
||||
THE SPACING. An 8px grid gap plus a `margin-bottom: 0.5rem` per tile: 16px
|
||||
between rows and 8px between columns, which reads as crowded sideways. One value
|
||||
now, in both directions. The mobile full-screen popup had the same class of bug
|
||||
as the first and states `dvh` now too. Desktop is unaffected.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following release-pipeline failures:
|
||||
|
||||
**The release workflow** - which jobs run, and on what.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/7b8c37709">The retired Mac runner, the jobs a cancellation skipped, and the armv6 userland</a>. Thanks to xet7.</summary>
|
||||
|
||||
`build-mac-x64` asked for `macos-13`, and GitHub has retired that image -
|
||||
`actions/runner-images` publishes macos-14, macos-15 and macos-26 only. A
|
||||
retired label does not fail: the job QUEUES, for a runner that is never coming.
|
||||
It has been sitting there every release until cancelled by hand, and no WeKan
|
||||
release has ever carried a `wekan-<version>-mac-x64.zip` because of it. Intel
|
||||
macOS was renamed, not removed: `macos-15-intel`, which is what TSC already
|
||||
builds on.
|
||||
|
||||
Cancelling it is what skipped `charts`, `ucs` and `nextcloud`: a cancelled job
|
||||
cancels the run, and a cancelling run skips every job that has not started - so
|
||||
all three were skipped the second the `docker` job they wait on succeeded, after
|
||||
everything had already shipped. A job whose `if` is `always()` still runs while
|
||||
a run is cancelling, so the three now say `always()` and name the needs that
|
||||
must have succeeded.
|
||||
|
||||
The armv6 bundle died on
|
||||
`qemu-arm: Could not open '/lib/ld-linux-armhf.so.3'`. Debian has no ARMv6 port
|
||||
- `debian:trixie` publishes arm/v5 and arm/v7 - and containerd treats a lower
|
||||
ARM variant as compatible, so `--platform linux/arm/v6` quietly resolved to
|
||||
arm/v5: Debian armel, soft-float, with no hard-float loader in it. The preflight
|
||||
compared the architecture and ignored the variant, so it never said so; it
|
||||
compares both now, and armv6 is built in Debian's arm/v7 container, which is
|
||||
armhf and runs node-armv6 fine. Nothing ARMv7 reaches the bundle - the container
|
||||
compiles nothing, and node, FerretDB and the MongoDB tools are all downloaded
|
||||
already built for ARMv6 - except the bundled qemu-user, which is copied out of
|
||||
the container and is therefore skipped for that one bundle.
|
||||
|
||||
</details>
|
||||
|
||||
**The snap** - what the four Launchpad architectures were really failing on.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/18da0d261">An optional part was ending every Launchpad build</a>. Thanks to xet7.</summary>
|
||||
|
||||
armhf, s390x, ppc64el and riscv64 all failed, three attempts each, and the job
|
||||
reported it as a Launchpad problem: "often an OOM in the Meteor npm install, or
|
||||
a transient build-farm reset; re-run". It was neither. The build log said the
|
||||
same deterministic thing every time:
|
||||
|
||||
```
|
||||
Staging mongo42
|
||||
Failed to copy '/build/.../parts/mongo42/install/mongo42': no such file or
|
||||
directory. Build failed
|
||||
```
|
||||
|
||||
The `mongo42` part downloads MongoDB 4.2 so a database from an old MongoDB snap
|
||||
can still be migrated ([#6471](https://github.com/wekan/wekan/issues/6471)), and
|
||||
MongoDB publishes 4.2 for amd64 and arm64 only. Everywhere else the part prints
|
||||
"nothing to migrate from there; skipping" and exits 0 having created nothing -
|
||||
its own comment calls that optional by design. It was not: the part also carries
|
||||
`stage: [mongo42]`, and snapcraft does not skip a filter whose path is missing,
|
||||
it ends the build. So a migration helper no exotic architecture has ever needed
|
||||
took the whole snap down on exactly the four that can only be built on
|
||||
Launchpad. The directory is created before anything can decide to skip; the
|
||||
binary is still downloaded only where it exists.
|
||||
|
||||
riscv64 additionally lost its last attempt to `npm ERR! code ECONNRESET` on one
|
||||
tarball, after half an hour of building - every request there goes through
|
||||
Launchpad's proxy - so that `npm install` is retried three times. The
|
||||
`::error::` no longer guesses: it sends the reader to the build log printed
|
||||
above it.
|
||||
|
||||
</details>
|
||||
|
||||
and improves the release tooling:
|
||||
|
||||
**The Helm chart index** - which WeKan releases it lists.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/8248892e3">Report the releases the chart index is missing, and repair its duplicates</a>. Thanks to xet7.</summary>
|
||||
|
||||
A chart entry is written once, during the release it belongs to, so a release
|
||||
whose `charts` job did not run leaves a hole nothing ever fills. Counted against
|
||||
the live index: 216 of 690 WeKan releases have a chart entry.
|
||||
|
||||
`releases/backfill-charts.sh` answers which releases the index should list - the
|
||||
ones that exist and can be installed. 216 are kept (never repackaged; that would
|
||||
change a digest helm clients have seen), 369 could be built, and 162 are OMITTED
|
||||
because they have no container image on ghcr: a chart is a pointer to an image,
|
||||
so an entry for one of those is an install that fails at the pull. The index is
|
||||
rebuilt from the packages actually present, so omission needs no bookkeeping.
|
||||
|
||||
It also found something already wrong: the published index has four entries for
|
||||
9.36.0 and two for 10.30.0, each with a different digest and the same url,
|
||||
because the release script prepends an entry every time it runs. At most one of
|
||||
those digests can be the one of the package really served, and a helm client
|
||||
that picks another fails the integrity check on a good file. The rebuild keeps
|
||||
the entry whose digest matches the package, falling back to the newest only when
|
||||
there is no package to compare against. Nothing is written without `--apply` and
|
||||
nothing is pushed without `--push`; the charts job runs it in plan mode only,
|
||||
into the run summary.
|
||||
|
||||
</details>
|
||||
|
||||
- [The chart-index report is a Release menu entry in build.sh and build.bat](https://github.com/wekan/wekan/commit/01f691ba3). Thanks to xet7.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/1a2ec0633">Rebuild the Helm index.yaml from the chart packages it actually serves</a>. Thanks to xet7.</summary>
|
||||
|
||||
With the charts repository cloned it became clear the index had drifted from its
|
||||
own packages in four ways at once, all from the same habit: an entry was written
|
||||
by COPYING the previous entry and substituting a few fields, so the index was
|
||||
being kept as the record rather than as a description of the `.tgz` files beside
|
||||
it.
|
||||
|
||||
**146 packages had no entry at all** - 362 packages, 220 entries. They are
|
||||
downloadable by URL but invisible to `helm search`, to
|
||||
`helm install wekan --version 9.63.0` and to Artifact Hub. **9.36.0 had four
|
||||
entries and 10.30.0 two**, each copy with a different digest and the same url,
|
||||
so a client could pick a digest matching no file and fail integrity on a good
|
||||
package. **appVersion was the CHART version on every entry** - `10.79.0` where
|
||||
the package says `10.79`, which is the WeKan version a reader is looking for.
|
||||
And **every entry claimed the mongodb subchart was 0.7.2**, a field nobody
|
||||
substituted, while the packages had long since moved to 0.7.6.
|
||||
|
||||
`releases/reindex-charts.py` derives the index from the packages, reading each
|
||||
one's `Chart.yaml` out of the archive, so an entry cannot describe a package
|
||||
wrongly. Nothing is invented: `created` is preserved exactly on every entry
|
||||
whose digest matches its package, and a package being added takes the date
|
||||
it was committed to `gh-pages`, which is when it was really published. Two 2023
|
||||
packaging slips - `wekan-1.2.7.tgz` containing chart 1.2.6, and `wekan-6.96.tgz`
|
||||
containing 6.9.6 - are reported and left out rather than making one version mean
|
||||
two files; both files stay on the server.
|
||||
|
||||
The index went from 220 entries to 360, and was checked after writing: no
|
||||
repeated version, every digest equal to the sha256 of its file, every file
|
||||
present, and no version that was listed before missing.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/a434bb4bd">Both release workflows publish the chart, and the index is derived rather than edited</a>. Thanks to xet7.</summary>
|
||||
|
||||
The charts repository keeps the chart SOURCE on `main` and the published
|
||||
packages plus `index.yaml` on `gh-pages`, and its own two scripts move between
|
||||
them: `release.sh` commits the source, tars `wekan/` into
|
||||
`wekan-<version>.0.tgz`, checks out gh-pages and drops the package there;
|
||||
`release2.sh` commits and pushes. `release-charts.sh` drives both and owns the
|
||||
index in between - and that middle part is what changes here.
|
||||
|
||||
**The index is now rebuilt from the packages** instead of being edited. It used
|
||||
to copy the newest entry, substitute a few fields and prepend the result, and
|
||||
all four of the index's defects came from exactly that. Deriving it makes them
|
||||
impossible rather than fixed: one entry per package, digest computed from the
|
||||
file, fields read out of the archive, and any package that was missed picked up
|
||||
on the next run.
|
||||
|
||||
**The package is checked against its own filename before it is indexed.**
|
||||
`release.sh` names the tarball from its argument while the version INSIDE comes
|
||||
from the `Chart.yaml` that was just edited, so when those drift the repository
|
||||
gains a file called one version that declares another - which is how
|
||||
`wekan-1.2.7.tgz` (containing 1.2.6) and `wekan-6.96.tgz` (containing 6.9.6)
|
||||
came to exist. Both are now removed, and a package like them stops the release
|
||||
with a message instead of being published.
|
||||
|
||||
**Release All Missing publishes a chart too.** It could rebuild any missing
|
||||
bundle but not a missing chart, so a release that never got one never would -
|
||||
its header even said the charts were out of scope. It has a charts job now,
|
||||
guarded twice: nothing happens without a token that can push to wekan/charts,
|
||||
and nothing happens if the chart for that version is already published, because
|
||||
re-publishing would re-tar the package and change the digest of a chart people
|
||||
have already pulled.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v10.80 2026-08-10 WeKan ® release
|
||||
|
||||
**In short:** the **Admin Panel**, in the two panes v10.79 had just changed.
|
||||
@@ -1891,7 +2775,7 @@ one security event is pivot on it.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/a260a53e">The database marks the operations WeKan never issues, so an operator sees them</a>. Thanks to xet7.</summary>
|
||||
<summary><a href="https://github.com/wekan/FerretDB/commit/a260a53e">The database marks the operations WeKan never issues, so an operator sees them</a>. Thanks to xet7.</summary>
|
||||
|
||||
FerretDB 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
|
||||
@@ -40468,9 +41352,9 @@ separate option to hide logo at Admin Panel/Layout
|
||||
</details>
|
||||
|
||||
- Added and then reverted option to redirect OIDC OAuth2 login
|
||||
[part1](https://github.com/wekan/wekan-ldap/commit/82a894ac20ba9e7c6fdf053cff1721cab709bf8a),
|
||||
[part1](https://github.com/wekan/wekan/commit/188d42dcd),
|
||||
[part
|
||||
2](https://github.com/wekan/wekan-ldap/commit/36900cc360d0d406f8fba5e43378f85c92747870)
|
||||
2](https://github.com/wekan/wekan/commit/d173844f9)
|
||||
and
|
||||
[part3](https://github.com/wekan/wekan/commit/7919ae362866c0cacf2a486bf91b12e4d25807d7).
|
||||
This does not work yet. In Progress. Thanks to xet7.
|
||||
|
||||
+1
-1
@@ -20,7 +20,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.80
|
||||
ARG VERSION=10.83
|
||||
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
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v10.80.0"
|
||||
appVersion: "v10.83.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -253,6 +253,8 @@ echo 19^) Delete a git tag, locally and on the remote
|
||||
echo 20^) Move the 'stable' tag to HEAD
|
||||
echo 21^) Release the wekan-ondra / wekan-gantt-gpl variants, part 1
|
||||
echo 22^) Release the wekan-ondra / wekan-gantt-gpl variants, part 2
|
||||
echo 23^) Report ^(or repair^) the Helm chart index for past releases
|
||||
echo 24^) Rebuild the Helm index.yaml from the chart packages
|
||||
set "choice="
|
||||
set /p "choice=Choose: "
|
||||
if "%choice%"=="1" call :rel_run "releases/release-all.sh" ""
|
||||
@@ -277,6 +279,8 @@ if "%choice%"=="19" call :rel_run "releases/delete-tag.sh" "Version tag, e.g. v1
|
||||
if "%choice%"=="20" call :rel_cmd "git tag --force stable HEAD && git push --tags --force && git push --follow-tags" ""
|
||||
if "%choice%"=="21" call :rel_run "releases/release-ondra-1.sh" ""
|
||||
if "%choice%"=="22" call :rel_run "releases/release-ondra-2.sh" ""
|
||||
if "%choice%"=="23" call :rel_run "releases/backfill-charts.sh" ""
|
||||
if "%choice%"=="24" call :rel_run "releases/reindex-charts.py" ""
|
||||
if "%choice%"=="0" goto menu_releases
|
||||
goto rel_release
|
||||
|
||||
@@ -628,6 +632,8 @@ if /I "%K%"=="release-ln" (set "CMD=bash releases/release-ln.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="release-x2" (set "CMD=bash releases/release-x2.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="release-cleanup" (set "CMD=bash releases/release-cleanup.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="release-charts" (set "CMD=bash releases/release-charts.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="backfill-charts" (set "CMD=bash releases/backfill-charts.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="reindex-charts" (set "CMD=python3 releases/reindex-charts.py" ^& goto cli_go)
|
||||
if /I "%K%"=="release-website" (set "CMD=bash releases/release-website.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="npm-publish" (set "CMD=bash releases/npm-publish.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="test-download-urls" (set "CMD=bash releases/test-download-urls.sh" ^& goto cli_go)
|
||||
|
||||
@@ -1867,6 +1867,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
|
||||
"Release|Move the 'stable' tag to HEAD|!git tag --force stable HEAD && git push --tags --force && git push --follow-tags|||stable-tag"
|
||||
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 1|releases/release-ondra-1.sh|||"
|
||||
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 2|releases/release-ondra-2.sh|||"
|
||||
"Release|Report (or repair) the Helm chart index for past releases|releases/backfill-charts.sh|||"
|
||||
"Release|Rebuild the Helm index.yaml from the chart packages|releases/reindex-charts.py|||"
|
||||
"Snap|Build the snap from snapcraft.yaml|releases/snap-build.sh|||"
|
||||
"Snap|Install the locally built .snap|releases/snap-install.sh|||"
|
||||
"Snap|Push one .snap to the Snap Store|releases/snap-push-to-store.sh|Path to the .snap file||"
|
||||
|
||||
@@ -1193,7 +1193,8 @@
|
||||
(e.g. a laptop in mobile mode) gets more columns instead of two stretched ones. */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(46%, 200px), 1fr));
|
||||
gap: 8px;
|
||||
/* The same single spacing value as the phone grid above, in both directions. */
|
||||
gap: 10px;
|
||||
/* #6488: sized by the column it lives in (the flex chain in the phone block
|
||||
below), not by the viewport minus a guess at the bars above it. */
|
||||
flex: 1 1 auto;
|
||||
@@ -1210,7 +1211,8 @@
|
||||
width: auto; /* the grid controls the column width */
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
/* The grid gap is the spacing; this added a second one, downwards only. */
|
||||
margin-bottom: 0;
|
||||
/* NO handle reserve here. The drag handle is positioned INSIDE the tile, and
|
||||
the tile already keeps its text clear of it - so 32px here was a second,
|
||||
invisible reserve on top of that one. On a phone the tile is only ~97px
|
||||
@@ -1340,18 +1342,42 @@
|
||||
.board-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
/* ONE spacing value, in both directions. It used to be an 8px gap PLUS a
|
||||
`margin-bottom: 0.5rem` on each li, so the tiles were 16px apart vertically
|
||||
and 8px apart horizontally - which reads as "the icons are too close
|
||||
together" side to side even though there is space between the rows. The
|
||||
margin is gone (below) and the gap is the whole answer. */
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0;
|
||||
/* #6488. Without these a grid's rows default to `align-content: stretch` and
|
||||
its items to `align-items: stretch`, so when the list is SHORTER than the
|
||||
column it is given - one or two rows of boards - the leftover height is
|
||||
divided among the rows and each tile is grown to fill it. A board tile
|
||||
paints its board colour over the whole cell, so a single row of boards
|
||||
became one band several times the height of a normal icon: "some board
|
||||
icons are 4x normal height or more".
|
||||
This was fixed once, but only on `.board-list.mobile-view` - the class the
|
||||
template sets from Utils.isMiniScreen(). A narrow WINDOW that is not a mini
|
||||
screen (a desktop browser resized under 800px, a tablet in desktop mode)
|
||||
matches this media query, becomes a grid here, and never got the fix. It
|
||||
belongs on the rule that creates the grid. */
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
}
|
||||
.board-list li {
|
||||
width: auto; /* the grid controls the column width */
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
/* The grid gap spaces the tiles; a margin here spaced them a second time,
|
||||
and only downwards. */
|
||||
margin-bottom: 0;
|
||||
/* ...and the tile is its own height, not the height of a stretched cell. */
|
||||
align-self: start;
|
||||
height: auto;
|
||||
/* Same as `.board-list.mobile-view li` above: the tile keeps its own text
|
||||
clear of the handle, so reserving the width a second time out here only
|
||||
narrowed the title. */
|
||||
@@ -1661,6 +1687,35 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ...and so does the pane's heading. Without this it is a flex item with the
|
||||
default `flex-shrink: 1`, so in a column too short for its contents the
|
||||
TITLE is what gets squeezed instead of the scroller below it growing a
|
||||
scrollbar. */
|
||||
.boards-right-grid > .admin-pane-title {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* #6488. The right column has TWO branches, not one: the board icons, and -
|
||||
when the view is Table - `+tablePage`, which is a `.table-page` (see
|
||||
boardsList.jade). Only the icons were ever given a scroller, so in Table view
|
||||
the table simply grew past the bottom of the column, and `#content .wrapper`
|
||||
is `overflow: hidden`, so the rows past the fold were clipped with no way to
|
||||
reach them. Every section of All Boards can be shown as a table - Starred,
|
||||
Templates, Remaining, Workspaces, Archive - so that was the whole page in
|
||||
one of its two views.
|
||||
The same shape as the board list: take what the column has left, be allowed
|
||||
to be shorter than the content, and scroll inside. The table is also WIDER
|
||||
than a phone, so this one scrolls in both directions. */
|
||||
.boards-right-grid > .table-page {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
/* The left menu (Starred / Templates / Remaining / Workspaces / Add board) is its
|
||||
own vertical scroll area, so when it is taller than the screen you can drag-scroll
|
||||
down to its lower items — the board icons already scroll in their own column on the
|
||||
|
||||
@@ -1146,3 +1146,33 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
user-select: none;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* #1591: foldable feature groups on the opened card.
|
||||
The title is the handle. Everything after it inside the section is what folds
|
||||
away - a sibling rule rather than "hide .card-details-item-content", because
|
||||
the sections are not uniform inside: all sixteen start with an
|
||||
h3.card-details-item-title, but only three wrap what follows in a content
|
||||
element. Hiding the siblings works whatever a section puts there. */
|
||||
.card-details-item > .card-details-item-title {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The caret, drawn on the title rather than added to sixteen templates. It
|
||||
points down when open and right when folded, like the checklist one. */
|
||||
.card-details-item > .card-details-item-title::after {
|
||||
content: "\f0d7"; /* fa-caret-down */
|
||||
font-family: FontAwesome;
|
||||
font-size: 12px;
|
||||
color: #808080;
|
||||
margin-inline-start: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card-details-item.is-collapsed > .card-details-item-title::after {
|
||||
content: "\f0da"; /* fa-caret-right */
|
||||
}
|
||||
|
||||
.card-details-item.is-collapsed > *:not(.card-details-item-title) {
|
||||
display: none;
|
||||
}
|
||||
@@ -513,6 +513,31 @@ Template.cardDetails.onRendered(function () {
|
||||
// below throws "Can't select in removed DomRange", so bail out early.
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
|
||||
// #1591: re-apply the sections this user folded. The click handler toggles the
|
||||
// class as well as storing it, but on a fresh render - reopening the card, a
|
||||
// reload - only the stored state exists, and the title is plain markup that
|
||||
// nothing re-renders from the profile.
|
||||
this.autorun(() => {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const card = Template.currentData();
|
||||
if (!user || !card || !card._id) return;
|
||||
// Read inside the autorun so a fold made in another tab arrives here too.
|
||||
const stored = (user.profile && user.profile.collapsedCardSections) || {};
|
||||
const forCard = stored[card._id] || {};
|
||||
Meteor.defer(() => {
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
this.$('.card-details-item').each((index, item) => {
|
||||
const named = Array.from(item.classList).find(
|
||||
c => c.startsWith('card-details-item-') &&
|
||||
c !== 'card-details-item-title' &&
|
||||
c !== 'card-details-item-content');
|
||||
if (!named) return;
|
||||
const key = named.replace('card-details-item-', '');
|
||||
item.classList.toggle('is-collapsed', forCard[key] === true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// #6465: place the window beside its minicard (X only) once it has been laid
|
||||
// out, so the width measured here is the one the stylesheet gave it.
|
||||
const $cardDetails = this.$('.card-details').first();
|
||||
@@ -828,7 +853,44 @@ Template.cardDetails.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// #1591: fold any feature group on the opened card - Labels, Members, Dates,
|
||||
// Custom Fields, and the rest.
|
||||
//
|
||||
// Done here, once, rather than by editing sixteen sections in cardDetails.jade,
|
||||
// because the sections are NOT uniform inside: they all begin with an
|
||||
// `h3.card-details-item-title`, but only three of them wrap what follows in a
|
||||
// `.card-details-item-content`. So the title is the handle, and "collapsed"
|
||||
// hides every sibling after it (the CSS rule) - which works whatever the section
|
||||
// puts there.
|
||||
//
|
||||
// The state is the same per-user map the checklists use
|
||||
// (profile.collapsedCardSections), keyed by the section's own name, so it
|
||||
// survives a reload and belongs to the reader rather than to the card.
|
||||
function cardSectionKeyOf(element) {
|
||||
const item = element.closest('.card-details-item');
|
||||
if (!item) return null;
|
||||
// 'card-details-item-labels' -> 'labels'. The class that is not the generic
|
||||
// one IS the section's name; there is no separate id to invent.
|
||||
const named = Array.from(item.classList).find(
|
||||
c => c.startsWith('card-details-item-') &&
|
||||
c !== 'card-details-item-title' &&
|
||||
c !== 'card-details-item-content');
|
||||
return named ? named.replace('card-details-item-', '') : null;
|
||||
}
|
||||
|
||||
Template.cardDetails.events({
|
||||
'click .card-details-item > .card-details-item-title'(event) {
|
||||
const key = cardSectionKeyOf(event.currentTarget);
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const card = Template.currentData();
|
||||
if (!key || !user || !card || !card._id) return;
|
||||
const collapsed = user.getCollapsedCardSection(card._id, key) === true;
|
||||
user.setCollapsedCardSection(card._id, key, !collapsed);
|
||||
// Applied straight away as well as stored: the title is plain markup, so
|
||||
// nothing re-renders on its own when the profile changes.
|
||||
event.currentTarget.closest('.card-details-item')
|
||||
.classList.toggle('is-collapsed', !collapsed);
|
||||
},
|
||||
[`${CSSEvents.transitionend} .js-card-details`](event, tpl) {
|
||||
tpl.isLoaded.set(true);
|
||||
},
|
||||
|
||||
@@ -191,3 +191,26 @@ textarea.js-edit-checklist-item {
|
||||
.edit-controls label.toggle-label {
|
||||
margin-inline-start: 2px;
|
||||
}
|
||||
|
||||
/* #1591: the fold caret on a checklist title. Sized and coloured like the
|
||||
actions menu beside it so the two read as one row of controls, and given a
|
||||
real hit area - it is a single glyph, and a caret you have to aim at is worse
|
||||
than no caret. */
|
||||
.checklist-title .checklist-collapse {
|
||||
/* Logical, so the caret sits on the reading-start side under dir=rtl too
|
||||
(tests/rtl.test.js enforces this across component CSS). */
|
||||
float: inline-start;
|
||||
margin-block-start: 2px;
|
||||
margin-inline-end: 4px;
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checklist-title .checklist-collapse:hover,
|
||||
.checklist-title .checklist-collapse:focus-visible {
|
||||
color: #2980b9;
|
||||
}
|
||||
@@ -38,6 +38,15 @@ template(name="checklistDetail")
|
||||
else
|
||||
.checklist-title
|
||||
span
|
||||
//- #1591: fold a long checklist out of the way. A caret rather than
|
||||
another entry in the actions menu, because the whole point is to do it
|
||||
at a glance while reading the card - and it is a view preference of
|
||||
this user, so it does not touch the card.
|
||||
a.fa.checklist-collapse.js-collapse-checklist(
|
||||
class="{{#if checklistCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}"
|
||||
title="{{#if checklistCollapsed}}{{_ 'expand-checklist'}}{{else}}{{_ 'collapse-checklist'}}{{/if}}"
|
||||
role="button" tabindex="0"
|
||||
aria-expanded="{{#if checklistCollapsed}}false{{else}}true{{/if}}")
|
||||
if canModifyCard
|
||||
a.fa.fa-navicon.checklist-details-menu.js-open-checklist-details-menu(title="{{_ 'checklistActionsPopup-title'}}")
|
||||
|
||||
@@ -52,12 +61,15 @@ template(name="checklistDetail")
|
||||
+viewer
|
||||
= checklist.title
|
||||
|
||||
//- The progress bar stays visible when folded: it is the summary of what was
|
||||
folded away, so hiding it too would leave a title that says nothing.
|
||||
if $gt finishedPercent 0
|
||||
.checklist-progress-bar-container
|
||||
.checklist-progress-text {{finishedPercent}}%
|
||||
.checklist-progress-bar
|
||||
.checklist-progress(style="width:{{finishedPercent}}%")
|
||||
+checklistItems(checklist = checklist card = card)
|
||||
unless checklistCollapsed
|
||||
+checklistItems(checklist = checklist card = card)
|
||||
|
||||
template(name="checklistDeletePopup")
|
||||
p {{_ 'confirm-checklist-delete-popup'}}
|
||||
|
||||
@@ -82,6 +82,47 @@ Template.checklistDetail.helpers({
|
||||
const ret = this.checklist.finishedPercent();
|
||||
return ret;
|
||||
},
|
||||
/** #1591: is this checklist folded for THIS user?
|
||||
*
|
||||
* Per-user, keyed by card and checklist, exactly like collapsed lists and
|
||||
* swimlanes - not the checklist's own hideAllChecklistItems, which is a field
|
||||
* on the checklist and so changes what everyone on the board sees. `null` from
|
||||
* the getter means "never set", which is what makes the default (expanded)
|
||||
* distinguishable from somebody having deliberately expanded it. */
|
||||
checklistCollapsed() {
|
||||
const checklist = this.checklist;
|
||||
if (!checklist || !checklist._id) return false;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return false;
|
||||
const cardId = (this.card && this.card._id) || checklist.cardId;
|
||||
const stored = user.getCollapsedCardSection(
|
||||
cardId, user.checklistSectionKey(checklist._id));
|
||||
return stored === true;
|
||||
},
|
||||
});
|
||||
|
||||
Template.checklistDetail.events({
|
||||
'click .js-collapse-checklist'(event) {
|
||||
// The caret sits inside the title, which opens the inline rename form when
|
||||
// clicked - so this must not reach it.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const checklist = this.checklist;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!checklist || !checklist._id || !user) return;
|
||||
const cardId = (this.card && this.card._id) || checklist.cardId;
|
||||
const key = user.checklistSectionKey(checklist._id);
|
||||
const collapsed = user.getCollapsedCardSection(cardId, key) === true;
|
||||
user.setCollapsedCardSection(cardId, key, !collapsed);
|
||||
},
|
||||
'keydown .js-collapse-checklist'(event) {
|
||||
// It is a link acting as a button, so it has to answer the keys a button
|
||||
// answers or it is unreachable without a mouse.
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
$(event.currentTarget).trigger('click');
|
||||
},
|
||||
});
|
||||
|
||||
Template.checklists.helpers({
|
||||
|
||||
@@ -265,6 +265,13 @@ template(name="editCardSortOrderPopup")
|
||||
template(name="minicardChecklist")
|
||||
.minicard-checklist
|
||||
.checklist-header
|
||||
//- #1591: the same caret as on the opened card, and the same stored key,
|
||||
so folding a checklist folds it in both places.
|
||||
a.fa.checklist-collapse.js-collapse-checklist(
|
||||
class="{{#if checklistCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}"
|
||||
title="{{#if checklistCollapsed}}{{_ 'expand-checklist'}}{{else}}{{_ 'collapse-checklist'}}{{/if}}"
|
||||
role="button" tabindex="0"
|
||||
aria-expanded="{{#if checklistCollapsed}}false{{else}}true{{/if}}")
|
||||
if canModifyCard
|
||||
+inlinedForm(classNames="js-edit-checklist-title" checklist=checklist)
|
||||
+editChecklistForm(checklist=checklist)
|
||||
@@ -275,16 +282,17 @@ template(name="minicardChecklist")
|
||||
if canModifyCard
|
||||
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
.checklist-items.js-checklist-items
|
||||
each visibleItems
|
||||
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
|
||||
+editChecklistItemForm(type='item' item=. checklist=checklist)
|
||||
unless checklistCollapsed
|
||||
.checklist-items.js-checklist-items
|
||||
each visibleItems
|
||||
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
|
||||
+editChecklistItemForm(type='item' item=. checklist=checklist)
|
||||
else
|
||||
+checklistItemDetail(item=. checklist=checklist card=card)
|
||||
if canModifyCard
|
||||
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
|
||||
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
|
||||
else
|
||||
+checklistItemDetail(item=. checklist=checklist card=card)
|
||||
if canModifyCard
|
||||
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
|
||||
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
|
||||
else
|
||||
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
|
||||
i.fa.fa-plus
|
||||
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
@@ -375,6 +375,18 @@ Template.minicard.events({
|
||||
});
|
||||
|
||||
Template.minicardChecklist.helpers({
|
||||
/** #1591: folded for THIS user, under the same key the opened card uses, so
|
||||
* the two agree. `this.checklist || this` because this template is called both
|
||||
* with an explicit checklist and with one as the data context. */
|
||||
checklistCollapsed() {
|
||||
const checklist = this.checklist || this;
|
||||
if (!checklist || !checklist._id) return false;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return false;
|
||||
const cardId = (this.card && this.card._id) || checklist.cardId;
|
||||
return user.getCollapsedCardSection(
|
||||
cardId, user.checklistSectionKey(checklist._id)) === true;
|
||||
},
|
||||
visibleItems() {
|
||||
const checklist = this.checklist || this;
|
||||
const items = checklist.items();
|
||||
@@ -416,6 +428,26 @@ function addMinicardChecklistItems(textarea, checklist) {
|
||||
}
|
||||
|
||||
Template.minicardChecklist.events({
|
||||
'click .js-collapse-checklist'(event) {
|
||||
// A minicard is a link to the card, and the title opens the rename form -
|
||||
// neither must happen because somebody folded a checklist.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const data = Template.currentData();
|
||||
const checklist = (data && data.checklist) || data || this;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!checklist || !checklist._id || !user) return;
|
||||
const cardId = (data && data.card && data.card._id) || checklist.cardId;
|
||||
const key = user.checklistSectionKey(checklist._id);
|
||||
const collapsed = user.getCollapsedCardSection(cardId, key) === true;
|
||||
user.setCollapsedCardSection(cardId, key, !collapsed);
|
||||
},
|
||||
'keydown .js-collapse-checklist'(event) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
$(event.currentTarget).trigger('click');
|
||||
},
|
||||
'click .js-convert-checklist-item-to-card'(event) {
|
||||
event.stopPropagation();
|
||||
const formData = Blaze.getData(event.currentTarget);
|
||||
|
||||
@@ -423,8 +423,46 @@ Template.list.onRendered(function () {
|
||||
scrolled = true;
|
||||
}
|
||||
}
|
||||
// vertical auto-scroll stays on the board canvas
|
||||
const canvas = document.querySelector('.board-canvas');
|
||||
// #6584: VERTICALLY, the thing to scroll is the LIST the pointer is over,
|
||||
// not the board canvas. `.list-body` is `overflow-y: scroll` (list.css) and
|
||||
// is what holds the cards; `.board-canvas` is `overflow-y: auto` and holds
|
||||
// the swimlanes. Scrolling the canvas moves the whole board, which is what
|
||||
// the report describes: "The whole Page/Site scrolls down and not the Line".
|
||||
//
|
||||
// It only showed up DOWNWARDS, and the asymmetry is the tell. Dragging up,
|
||||
// the canvas is usually already at scrollTop 0, so computeEdgeScroll
|
||||
// returned null, nothing here fired, and jQuery UI's own `scroll` option -
|
||||
// which acts on the placeholder's scrollParent, i.e. the list body - scrolled
|
||||
// the list, exactly as expected. Dragging down, the canvas nearly always HAS
|
||||
// room, so this fired first and scrolled the board instead of the list.
|
||||
//
|
||||
// The canvas is still scrolled, but only once the list under the pointer
|
||||
// cannot go further that way - so a drag down a long list scrolls the list,
|
||||
// and a drag past the end of it moves on to the board. Same shape as the
|
||||
// horizontal case above, which already picks the lane under the pointer.
|
||||
let scrolledList = false;
|
||||
const listBodies = document.querySelectorAll('.list-body');
|
||||
const listRects = Array.prototype.map.call(listBodies, el =>
|
||||
el.getBoundingClientRect());
|
||||
// findLaneUnderPointer is a plain rectangle hit-test; the name is about
|
||||
// where it was first used, not what it can be given.
|
||||
const listIndex = findLaneUnderPointer(listRects, event.clientX, event.clientY);
|
||||
if (listIndex !== -1) {
|
||||
const nextListTop = computeEdgeScroll({
|
||||
pointer: event.clientY,
|
||||
lowEdge: listRects[listIndex].top,
|
||||
highEdge: listRects[listIndex].bottom,
|
||||
scrollPos: listBodies[listIndex].scrollTop,
|
||||
scrollSize: listBodies[listIndex].scrollHeight,
|
||||
clientSize: listBodies[listIndex].clientHeight,
|
||||
});
|
||||
if (nextListTop !== null) {
|
||||
listBodies[listIndex].scrollTop = nextListTop;
|
||||
scrolled = true;
|
||||
scrolledList = true;
|
||||
}
|
||||
}
|
||||
const canvas = scrolledList ? null : document.querySelector('.board-canvas');
|
||||
if (canvas) {
|
||||
const crect = canvas.getBoundingClientRect();
|
||||
const nextTop = computeEdgeScroll({
|
||||
|
||||
@@ -256,6 +256,16 @@ Template.listBody.onCreated(function () {
|
||||
if (clickedTitle && !clickedLinkedReference) {
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
// #6465: clicking the OPEN card again closes it. The toggle below has
|
||||
// always been here, but this branch returned before ever reaching it - and
|
||||
// the title covers most of the minicard, so in practice the second click
|
||||
// almost always lands here and just re-opened the card that was already
|
||||
// open. That is why it read as a missing feature rather than a dead
|
||||
// branch. Same call as below, so both click targets behave alike.
|
||||
if (Session.equals('currentCard', card._id)) {
|
||||
Utils.goBoardId(Session.get('currentBoard'));
|
||||
return;
|
||||
}
|
||||
Session.delete('popupCardId');
|
||||
Session.delete('popupCardBoardId');
|
||||
Session.set('currentCard', card._id);
|
||||
@@ -265,6 +275,12 @@ Template.listBody.onCreated(function () {
|
||||
|
||||
if (Utils.isMiniScreen()) {
|
||||
evt.preventDefault();
|
||||
// ...and on a phone, where the card is a popup rather than a pane, the
|
||||
// second click closes that popup for the same reason.
|
||||
if (Session.equals('popupCardId', card._id) && Popup.isOpen()) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
Session.set('popupCardId', card._id);
|
||||
this.cardDetailsPopup(evt);
|
||||
} else if (Session.equals('currentCard', card._id)) {
|
||||
|
||||
@@ -243,8 +243,30 @@ body.mobile-mode #content {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100dvh - 48px);
|
||||
/* #6488, and the reason that issue kept coming back after being closed three
|
||||
times: THE LAST PIECE OF VIEWPORT ARITHMETIC. This said
|
||||
`height: calc(100dvh - 48px)`, and 48px is a guess at how tall the header is.
|
||||
The header is not 48px and is not any one number - Utils publishes
|
||||
--wekan-header-height from a ResizeObserver for exactly this reason, because
|
||||
the quick-access bar WRAPS to a second and third row depending on the
|
||||
language and the window width, and its own comment says "every fixed number
|
||||
for it has been wrong for some of those".
|
||||
On a phone, where the bar does wrap, #content was therefore taller than the
|
||||
room under the header by however much the header exceeded 48px, so its
|
||||
bottom sat BELOW the bottom of the screen. body.mobile-mode is
|
||||
`position: fixed` and the phone rules make it `overflow: hidden`, so that
|
||||
strip is unreachable: everything below it inherits the error through the
|
||||
`height: 100%` chain (boardsList.css), and the last rows of the board list
|
||||
end up where no gesture can reach them. That is "in mobile board menu you
|
||||
cant scroll boards" - the list scrolls, it just ends off screen.
|
||||
Nothing has to be computed here. body is a flex COLUMN of exactly one
|
||||
viewport and this is its `flex: 1` item (below), so the space left under the
|
||||
header - whatever the header turns out to be this paint - IS this box. The
|
||||
arithmetic was overriding a correct answer with a wrong one.
|
||||
`min-height: 0` so it can be SHORTER than its content: a flex item will not
|
||||
shrink below its content otherwise, and an inner scroller whose ancestor
|
||||
refuses to shrink never scrolls. */
|
||||
min-height: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
#content {
|
||||
|
||||
@@ -893,14 +893,29 @@ body.mobile-mode .pop-over {
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: 100vw !important;
|
||||
/* #6488. `100vh` is the viewport with the browser's toolbars HIDDEN, so a
|
||||
`position: fixed` box sized that way reaches under them: on a phone the
|
||||
bottom of this popup is off screen, and being fixed there is no gesture that
|
||||
brings it back. `100dvh` is the viewport as it is right now. The `vh` pair
|
||||
stays first as the fallback where `dvh` is unknown - the same fix
|
||||
body.mobile-mode already carries in layouts.css. */
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
height: 100dvh !important;
|
||||
max-height: 100dvh !important;
|
||||
}
|
||||
body.mobile-mode .pop-over .content-wrapper {
|
||||
width: 100% !important;
|
||||
height: calc(100vh - 48px) !important;
|
||||
max-height: calc(100vh - 48px) !important;
|
||||
/* #6488. This was `calc(100vh - 48px)`, which is two guesses at once: the
|
||||
viewport measured with the toolbars hidden, minus a fixed 48px for the
|
||||
popup's own title bar. When the title wraps - a long board name, a
|
||||
translated label - the bar is taller than 48px and the list inside this
|
||||
wrapper ends below the screen, so its last rows cannot be scrolled to.
|
||||
Nothing needs measuring: `.pop-over` above is a flex column of exactly one
|
||||
viewport, `.pop-over .header` is `flex-shrink: 0`, and this wrapper is
|
||||
already `flex: 1 1 auto; min-height: 0` - so it is whatever the title bar
|
||||
leaves, at whatever height the title bar really is. */
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
@@ -456,6 +456,34 @@ body.left-menu-resizing-active {
|
||||
|
||||
/* …and exactly .materialCheckBox.is-checked: the square becomes the tick, which is
|
||||
what the transition above animates. */
|
||||
/* #6465: THE FOCUS RING MUST NOT BE DRAWN ON THE ROTATED BOX.
|
||||
The checked state below turns the square into a tick by making two borders
|
||||
transparent and rotating the element 40deg. A browser draws its focus ring
|
||||
around the element as it is - rotated - so a checkbox that is both checked and
|
||||
focused (which is exactly what a checkbox you just clicked is) comes out as a
|
||||
blue DIAMOND sitting where the tick should be. That is the glitch reported on
|
||||
the Problems page.
|
||||
|
||||
The ring is not simply removed: keyboard users need to see where they are. It
|
||||
moves to the row that CONTAINS the checkbox, which is not rotated - every one
|
||||
of these lives inside a label (problemsSummary.jade: `label.admin-problem-item
|
||||
> input.js-problem-check`) - so the indicator is a normal rectangle around the
|
||||
whole row, which is easier to see than a 13px box anyway.
|
||||
|
||||
`:focus-visible` rather than `:focus`, so clicking with a mouse does not leave
|
||||
a ring behind; `:focus-within` is the fallback where :has() is not supported,
|
||||
and lands on the same element. */
|
||||
.setting-content input[type="checkbox"]:focus,
|
||||
.setting-content input[type="checkbox"]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
|
||||
outline: 2px solid #2980b9;
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.setting-content input[type="checkbox"]:checked {
|
||||
top: -4px;
|
||||
inset-inline-start: -3px;
|
||||
|
||||
@@ -3,6 +3,13 @@ import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { getCurrentCardFromContext } from '/client/lib/currentCard';
|
||||
import { normalizeDigits } from '/imports/lib/dateUtils';
|
||||
|
||||
// The window a typed year has to fall in. Wide on purpose: WeKan holds real
|
||||
// historical received dates and long-dated deadlines, so this is only meant to
|
||||
// catch a year that cannot have been intended - the two-digit one a browser
|
||||
// reports verbatim (0026), and the five-digit slip in the other direction.
|
||||
const MIN_PLAUSIBLE_YEAR = 1000;
|
||||
const MAX_PLAUSIBLE_YEAR = 9999;
|
||||
import {
|
||||
isValidDate,
|
||||
formatTime,
|
||||
@@ -165,6 +172,26 @@ export function datePickerEvents({ storeDate, deleteDate }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A TWO-DIGIT YEAR is a real date that is not the one anybody meant.
|
||||
// `<input type="date">` reports YYYY-MM-DD, but the browser lets the year
|
||||
// sub-field be typed as two digits and reports that literally: typing
|
||||
// 31-12-26 gives "0026-12-31", the year 26 AD. Nothing above rejects it -
|
||||
// it is a perfectly valid Date - so the card got a due date two thousand
|
||||
// years in the past, which is why the reporter saw a date they had TYPED
|
||||
// come out red (overdue) while the same date picked from the calendar,
|
||||
// which always fills four digits, came out yellow (due soon). The colour
|
||||
// was right; the year was wrong.
|
||||
//
|
||||
// Refused rather than corrected: 0026 could be meant as 2026, but guessing
|
||||
// silently rewrites what somebody typed, and this is a date other people's
|
||||
// reminders hang off. The error names the year so the fix is obvious.
|
||||
const year = newCompleteDate.getFullYear();
|
||||
if (year < MIN_PLAUSIBLE_YEAR || year > MAX_PLAUSIBLE_YEAR) {
|
||||
tpl.datePicker.error.set('invalid-year');
|
||||
evt.target.date.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
storeDate.call(tpl, newCompleteDate);
|
||||
Popup.back();
|
||||
},
|
||||
|
||||
@@ -218,8 +218,18 @@ services:
|
||||
#- S3_SECRET_FILE=/run/secrets/s3_secret
|
||||
#-----------------------------------------------------------------
|
||||
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan
|
||||
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
|
||||
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
|
||||
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
|
||||
# directConnection the MongoDB driver does replica-set discovery: it drops the
|
||||
# host written here and dials the advertised 0.0.0.0:27017 instead, which
|
||||
# inside the wekan container is the wekan container itself — that is the
|
||||
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
|
||||
# `docker compose up` died with. directConnection=true keeps the driver on
|
||||
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
|
||||
# the handshake, which is still there.
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
|
||||
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
|
||||
#---------------------------------------------------------------
|
||||
# ==== ROOT_URL SETTING ====
|
||||
@@ -274,7 +284,8 @@ services:
|
||||
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
|
||||
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
|
||||
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
|
||||
# directConnection=true for the same reason as MONGO_URL above (#6582).
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
|
||||
@@ -220,8 +220,18 @@ services:
|
||||
#- S3_SECRET_FILE=/run/secrets/s3_secret
|
||||
#-----------------------------------------------------------------
|
||||
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan
|
||||
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
|
||||
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
|
||||
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
|
||||
# directConnection the MongoDB driver does replica-set discovery: it drops the
|
||||
# host written here and dials the advertised 0.0.0.0:27017 instead, which
|
||||
# inside the wekan container is the wekan container itself — that is the
|
||||
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
|
||||
# `docker compose up` died with. directConnection=true keeps the driver on
|
||||
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
|
||||
# the handshake, which is still there.
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
|
||||
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
|
||||
#---------------------------------------------------------------
|
||||
# ==== ROOT_URL SETTING ====
|
||||
@@ -276,7 +286,8 @@ services:
|
||||
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
|
||||
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
|
||||
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
|
||||
# directConnection=true for the same reason as MONGO_URL above (#6582).
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
|
||||
@@ -207,8 +207,18 @@ services:
|
||||
#- S3_SECRET_FILE=/run/secrets/s3_secret
|
||||
#-----------------------------------------------------------------
|
||||
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan
|
||||
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
|
||||
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
|
||||
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
|
||||
# directConnection the MongoDB driver does replica-set discovery: it drops the
|
||||
# host written here and dials the advertised 0.0.0.0:27017 instead, which
|
||||
# inside the wekan container is the wekan container itself — that is the
|
||||
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
|
||||
# `docker compose up` died with. directConnection=true keeps the driver on
|
||||
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
|
||||
# the handshake, which is still there.
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
|
||||
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
|
||||
#---------------------------------------------------------------
|
||||
# ==== ROOT_URL SETTING ====
|
||||
@@ -263,7 +273,8 @@ services:
|
||||
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
|
||||
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
|
||||
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
|
||||
# directConnection=true for the same reason as MONGO_URL above (#6582).
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
|
||||
@@ -224,8 +224,18 @@ services:
|
||||
#- S3_SECRET_FILE=/run/secrets/s3_secret
|
||||
#-----------------------------------------------------------------
|
||||
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan
|
||||
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
|
||||
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
|
||||
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
|
||||
# directConnection the MongoDB driver does replica-set discovery: it drops the
|
||||
# host written here and dials the advertised 0.0.0.0:27017 instead, which
|
||||
# inside the wekan container is the wekan container itself — that is the
|
||||
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
|
||||
# `docker compose up` died with. directConnection=true keeps the driver on
|
||||
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
|
||||
# the handshake, which is still there.
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
|
||||
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
|
||||
#---------------------------------------------------------------
|
||||
# ==== ROOT_URL SETTING ====
|
||||
@@ -280,7 +290,8 @@ services:
|
||||
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
|
||||
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
|
||||
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
|
||||
# directConnection=true for the same reason as MONGO_URL above (#6582).
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
|
||||
+14
-3
@@ -178,8 +178,18 @@ services:
|
||||
#- S3_SECRET_FILE=/run/secrets/s3_secret
|
||||
#-----------------------------------------------------------------
|
||||
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan
|
||||
# directConnection=true is REQUIRED (wekan/wekan#6582). The ferretdb service
|
||||
# above runs with --repl-set-name=rs0, so its handshake advertises a replica
|
||||
# set whose only member is its own LISTEN address, 0.0.0.0:27017. Without
|
||||
# directConnection the MongoDB driver does replica-set discovery: it drops the
|
||||
# host written here and dials the advertised 0.0.0.0:27017 instead, which
|
||||
# inside the wekan container is the wekan container itself — that is the
|
||||
# "MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017" a fresh
|
||||
# `docker compose up` died with. directConnection=true keeps the driver on
|
||||
# exactly this host. OpLog tailing is unaffected: Meteor only needs setName in
|
||||
# the handshake, which is still there.
|
||||
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan?directConnection=true
|
||||
- MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true
|
||||
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
|
||||
#---------------------------------------------------------------
|
||||
# ==== ROOT_URL SETTING ====
|
||||
@@ -234,7 +244,8 @@ services:
|
||||
# MONGO_OPLOG_URL makes Meteor start an OpLog tail regardless of the
|
||||
# reactivity order (#6498), so it is commented out. To try OpLog tailing,
|
||||
# uncomment the next line AND set METEOR_REACTIVITY_ORDER=oplog,polling above.
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
|
||||
# directConnection=true for the same reason as MONGO_URL above (#6582).
|
||||
#- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0&directConnection=true
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
|
||||
@@ -7,8 +7,10 @@ the **default database of WeKan**: `docker-compose.yml` runs FerretDB v1 on its
|
||||
embedded SQLite, so there is no separate database server to install at all.
|
||||
|
||||
Whichever backend is used, WeKan talks only to FerretDB
|
||||
(`MONGO_URL=mongodb://ferretdb:27017/wekan`) — nothing in WeKan knows what is
|
||||
behind it.
|
||||
(`MONGO_URL=mongodb://ferretdb:27017/wekan?directConnection=true`) — nothing in
|
||||
WeKan knows what is behind it. See
|
||||
[directConnection](#why-the-url-says-directconnectiontrue) for why that parameter
|
||||
is not optional.
|
||||
|
||||
## Which one to use
|
||||
|
||||
@@ -91,6 +93,53 @@ merely setting `MONGO_OPLOG_URL` starts a tail regardless of the reactivity orde
|
||||
|
||||
FerretDB has no MongoDB change streams at all, in either version.
|
||||
|
||||
## Why the URL says `directConnection=true`
|
||||
|
||||
Every FerretDB v1 compose file connects with
|
||||
`mongodb://ferretdb:27017/wekan?directConnection=true`, and dropping that
|
||||
parameter breaks the stack on the first start
|
||||
([#6582](https://github.com/wekan/wekan/issues/6582)):
|
||||
|
||||
```
|
||||
MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
|
||||
reason: TopologyDescription {
|
||||
type: 'ReplicaSetNoPrimary',
|
||||
servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
|
||||
setName: 'rs0', ... }
|
||||
```
|
||||
|
||||
`0.0.0.0` appears in no compose file. It is FerretDB's own **listen** address,
|
||||
and the driver was handed it by the server.
|
||||
|
||||
Because the `ferretdb` service runs with `--repl-set-name=rs0` (see
|
||||
[The OpLog](#the-oplog) above), FerretDB answers the `hello` handshake as a
|
||||
one-member replica set, and fills the `hosts`, `me` and `primary` fields with its
|
||||
`--listen-addr` — the wildcard `0.0.0.0:27017`. A MongoDB driver that is not in
|
||||
direct-connection mode treats such a reply as an invitation to do replica-set
|
||||
**discovery**: it adopts the member list the server advertised, and drops the
|
||||
seed it was given, because the server reports a name other than the one that was
|
||||
dialled. So `mongodb://ferretdb:27017` turns into `0.0.0.0:27017`, which inside
|
||||
the `wekan-app` container means that container itself — where nothing is
|
||||
listening. Hence `ECONNREFUSED`, on a compose file that was never edited.
|
||||
|
||||
`directConnection=true` tells the driver to stay on exactly the host it was
|
||||
given and skip discovery entirely. Measured against FerretDB v1.49.0 with the
|
||||
driver the bundle ships:
|
||||
|
||||
| MONGO\_URL | Topology the driver ends up with |
|
||||
| --- | --- |
|
||||
| `mongodb://localhost:27017/wekan` | `ReplicaSetWithPrimary`, servers: `0.0.0.0:27017` — the seed was discarded |
|
||||
| `mongodb://localhost:27017/wekan?directConnection=true` | `Single`, servers: `localhost:27017` |
|
||||
|
||||
It costs nothing else. The handshake still reports `setName: 'rs0'`, and that is
|
||||
the only thing Meteor checks before it will tail an OpLog, so
|
||||
`MONGO_OPLOG_URL=...?replicaSet=rs0&directConnection=true` keeps working exactly
|
||||
as [#6480/#6481](https://github.com/wekan/wekan/issues/6480) left it.
|
||||
|
||||
This applies only to FerretDB. The MongoDB compose files talk to a real replica
|
||||
set whose members are configured with names that do resolve, so they keep plain
|
||||
`replicaSet=rs0` and must **not** gain `directConnection`.
|
||||
|
||||
## Do they all answer the same?
|
||||
|
||||
[Conformance.md](Conformance.md) — `./build.sh` → Tests → **All databases
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
FerretDB speaks the MongoDB wire protocol and stores the data in something else,
|
||||
so WeKan runs unchanged on top of it — WeKan only ever connects to
|
||||
`mongodb://ferretdb:27017/wekan` and knows nothing about what is behind it.
|
||||
`mongodb://ferretdb:27017/wekan?directConnection=true` and knows nothing about
|
||||
what is behind it. The `directConnection=true` is required, not decoration:
|
||||
without it the driver follows FerretDB's replica-set handshake to the wildcard
|
||||
address FerretDB listens on and the stack cannot start
|
||||
([#6582](https://github.com/wekan/wekan/issues/6582), explained in
|
||||
[1/README.md](1/README.md#why-the-url-says-directconnectiontrue)).
|
||||
|
||||
There are two versions, and they are different products:
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-10.80-win64.zip](https://github.com/wekan/wekan/releases/download/v10.80/wekan-10.80-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-10.83-win64.zip](https://github.com/wekan/wekan/releases/download/v10.83/wekan-10.83-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.80-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-10.83-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
@@ -710,6 +710,7 @@
|
||||
"initials": "Initials",
|
||||
"invalid-date": "Invalid date",
|
||||
"invalid-time": "Invalid time",
|
||||
"invalid-year": "Invalid year. Please type all four digits, for example 2026.",
|
||||
"invalid-user": "Invalid user",
|
||||
"joined": "joined",
|
||||
"just-invited": "You are just invited to this board",
|
||||
@@ -1846,6 +1847,8 @@
|
||||
"convert-to-markdown": "Convert to markdown",
|
||||
"import-board-zip": "Add .zip file that has board JSON files, and board name subdirectories with attachments",
|
||||
"collapse": "Collapse",
|
||||
"collapse-checklist": "Collapse checklist",
|
||||
"expand-checklist": "Expand checklist",
|
||||
"uncollapse": "Uncollapse",
|
||||
"hideCheckedChecklistItems": "Hide checked checklist items",
|
||||
"hideAllChecklistItems": "Hide all checklist items",
|
||||
|
||||
@@ -46,6 +46,28 @@ runOnServer(function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// GHSA-6p5m-f9p2-wqm5: the card has to BELONG to the board being
|
||||
// authorised. Every check below is about `board` - isPublic() on one
|
||||
// branch, canExport() on the other - and both were deciding access to one
|
||||
// object while the export read a different one, named by a path parameter
|
||||
// the caller also controls. Bound here, at the routing layer, as well as in
|
||||
// the exporter's own query: the two identifiers arrive together, so this is
|
||||
// where their relationship is cheapest to state, and it holds for the
|
||||
// public branch too, which skips authentication entirely.
|
||||
//
|
||||
// 404, not 403: whether a given card id exists at all is not something an
|
||||
// unauthorised caller should learn from the difference.
|
||||
const containedCard = await ReactiveCache.getCard({
|
||||
_id: paramCardId,
|
||||
boardId,
|
||||
listId: paramListId,
|
||||
});
|
||||
if (!containedCard) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Card not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Public boards skip authentication
|
||||
if (board.isPublic()) {
|
||||
const fieldsParam = req.query.fields;
|
||||
|
||||
@@ -58,4 +58,52 @@ function expiredNotificationActivityIds(notifications, removeAgeDays, now) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
module.exports = { expiredNotificationActivityIds };
|
||||
// #6533: THE ARRAY ALSO HAS TO BE BOUNDED, and the rule above cannot do it.
|
||||
// Only notifications that have been READ are ever removed, so a user who does
|
||||
// not clear their tray accumulates entries with no upper limit - and they live
|
||||
// in an array INSIDE the user document, `profile.notifications`.
|
||||
//
|
||||
// That is what shows up as a database problem rather than a tray problem. Every
|
||||
// new notification is an `$addToSet` on that array, which means reading the whole
|
||||
// user document, scanning the array for a duplicate, and writing the whole
|
||||
// document back. FerretDB on SQLite has ONE writer, so those rewrites queue
|
||||
// behind each other and start failing:
|
||||
//
|
||||
// [db-retry] the database was busy: ... 3 given up on
|
||||
// ValidationError: ... [collection.go:191 sqlite.(*collection).UpdateAll]
|
||||
// database is locked (5) (SQLITE_BUSY)
|
||||
// at _helpersConstructor.addNotification (models/users.js)
|
||||
//
|
||||
// with FerretDB at 737% CPU. The work is proportional to the array, so it gets
|
||||
// worse exactly as the array gets longer, and login - which writes to the user
|
||||
// document - queues behind it.
|
||||
//
|
||||
// So the newest KEEP entries are kept and the rest dropped. Newest, not oldest:
|
||||
// the tray shows recent activity, and an unread notification from last year is
|
||||
// not something anyone is going to act on. Order is positional - `$addToSet`
|
||||
// appends - so the tail is the newest.
|
||||
//
|
||||
// Returns the entries to KEEP, or null when nothing needs dropping (so the
|
||||
// caller can skip the write entirely rather than rewrite the array with itself).
|
||||
function cappedNotifications(notifications, maxPerUser) {
|
||||
if (!Array.isArray(notifications)) return null;
|
||||
const cap = Number.isInteger(maxPerUser) && maxPerUser > 0 ? maxPerUser : 0;
|
||||
if (cap === 0 || notifications.length <= cap) return null;
|
||||
return notifications.slice(notifications.length - cap);
|
||||
}
|
||||
|
||||
// How many the tray keeps per user. Generous - this is a backstop against
|
||||
// unbounded growth, not a retention policy - and overridable for an instance
|
||||
// whose users really do want a longer history.
|
||||
function notificationCapFromEnv(env) {
|
||||
const raw = (env || {}).NOTIFICATION_TRAY_MAX_PER_USER;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
expiredNotificationActivityIds,
|
||||
cappedNotifications,
|
||||
notificationCapFromEnv,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
// Which template-container boards are safe to remove, and which are not.
|
||||
//
|
||||
// WHY THIS EXISTS. Before v10.00 every new account got a "Templates" container
|
||||
// board created for it at signup, whether or not the person ever saved a
|
||||
// template. That was removed in v10.00 (#2339, #5850) - the container is created
|
||||
// lazily by `ensureTemplatesBoard` now, only when a template is actually saved -
|
||||
// but nothing ever removed the ones already made, and they are not visible enough
|
||||
// for anyone to delete by hand.
|
||||
//
|
||||
// On an instance that provisions accounts automatically the leftovers dominate
|
||||
// the boards collection. One reported instance: 14490 boards, of which 13404 are
|
||||
// template containers, for 9264 accounts of which 478 have ever logged in. Every
|
||||
// query that touches boards - and under polling reactivity that is every poll -
|
||||
// carries that 13x.
|
||||
//
|
||||
// WHAT IS SAFE TO REMOVE is the whole question, and the answer is deliberately
|
||||
// narrow: a container that is EMPTY. If somebody ever saved a template into it,
|
||||
// or renamed it, or invited anyone to it, it is theirs and it stays - the point
|
||||
// is to remove boards that were created FOR people who never used them, not to
|
||||
// tidy up after people who did.
|
||||
//
|
||||
// Nothing here deletes anything. It decides, and returns the decision with a
|
||||
// reason attached, so the caller can show what WOULD go before anything does.
|
||||
|
||||
// A container is removable only when every one of these holds.
|
||||
//
|
||||
// type === 'template-container' it is a container, not a board or a template
|
||||
// no template boards inside it nothing was ever saved into it
|
||||
// no lists, swimlanes or cards nothing was put in it directly either
|
||||
// exactly one member only the person it was made for
|
||||
// not renamed still called whatever WeKan called it
|
||||
// not starred / not archived nobody has expressed any interest in it
|
||||
//
|
||||
// `templateCount`, `listCount`, `swimlaneCount` and `cardCount` are supplied by
|
||||
// the caller, which is what queries the database; this stays pure.
|
||||
function classifyTemplateContainer(board, counts = {}, options = {}) {
|
||||
const reasons = [];
|
||||
if (!board || typeof board !== 'object') {
|
||||
return { removable: false, reasons: ['not a board'] };
|
||||
}
|
||||
if (board.type !== 'template-container') {
|
||||
return { removable: false, reasons: ['not a template container'] };
|
||||
}
|
||||
|
||||
const {
|
||||
templateCount = 0,
|
||||
listCount = 0,
|
||||
swimlaneCount = 0,
|
||||
cardCount = 0,
|
||||
} = counts;
|
||||
|
||||
if (templateCount > 0) reasons.push(`holds ${templateCount} template(s)`);
|
||||
if (cardCount > 0) reasons.push(`holds ${cardCount} card(s)`);
|
||||
if (listCount > 0) reasons.push(`holds ${listCount} list(s)`);
|
||||
if (swimlaneCount > 0) reasons.push(`holds ${swimlaneCount} swimlane(s)`);
|
||||
|
||||
const members = Array.isArray(board.members) ? board.members : [];
|
||||
if (members.length > 1) {
|
||||
reasons.push(`shared with ${members.length} members`);
|
||||
}
|
||||
|
||||
if (board.archived) reasons.push('archived by hand');
|
||||
if (board.starred) reasons.push('starred');
|
||||
|
||||
// Renamed = somebody made it theirs. The default title is whatever the
|
||||
// creating code used; the caller passes the set of titles that count as
|
||||
// untouched, because that string is translated and has changed over time.
|
||||
const defaultTitles = options.defaultTitles;
|
||||
if (Array.isArray(defaultTitles) && defaultTitles.length > 0) {
|
||||
const title = typeof board.title === 'string' ? board.title.trim() : '';
|
||||
if (title && !defaultTitles.includes(title)) {
|
||||
reasons.push(`renamed to "${title}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return { removable: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
// The selector for candidate containers. Deliberately only narrows by TYPE: what
|
||||
// makes one removable needs counts from four other collections, so the caller
|
||||
// walks these and asks classifyTemplateContainer about each.
|
||||
function templateContainersSelector() {
|
||||
return { type: 'template-container' };
|
||||
}
|
||||
|
||||
// Split a classified list into what would go and what would stay, so a caller
|
||||
// can report both without repeating the rule.
|
||||
function planTemplateContainerCleanup(entries) {
|
||||
const remove = [];
|
||||
const keep = [];
|
||||
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||
if (!entry || !entry.board) continue;
|
||||
const verdict = classifyTemplateContainer(
|
||||
entry.board, entry.counts, entry.options);
|
||||
(verdict.removable ? remove : keep).push({
|
||||
boardId: entry.board._id,
|
||||
title: entry.board.title,
|
||||
reasons: verdict.reasons,
|
||||
});
|
||||
}
|
||||
return { remove, keep };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyTemplateContainer,
|
||||
templateContainersSelector,
|
||||
planTemplateContainerCleanup,
|
||||
};
|
||||
@@ -210,7 +210,32 @@ class ExporterExcelCard {
|
||||
// ── Internal build ───────────────────────────────────────────────────────
|
||||
|
||||
async _buildAndWrite(res) {
|
||||
const card = await ReactiveCache.getCard(this._cardId);
|
||||
// GHSA-6p5m-f9p2-wqm5: the card must be looked up INSIDE the board and list
|
||||
// the caller was authorised for, not by its id alone.
|
||||
//
|
||||
// canExport() above answers "may this user see the board in :boardId?" - it
|
||||
// never sees :cardId. So with a bare primary-key lookup here the two
|
||||
// identifiers came apart: :boardId decided the authorisation and :cardId
|
||||
// decided the data. Any authenticated user could create their own public
|
||||
// board (POST /api/boards takes `permission` straight from the body), name
|
||||
// it as :boardId, and pass the id of a card in somebody's private board as
|
||||
// :cardId. The export then returned that card's title, description, members,
|
||||
// every comment with its author, checklists, subtasks, attachment metadata -
|
||||
// and, because image attachments are read through getReadStream() and
|
||||
// embedded in the workbook further down, the attachment BYTES.
|
||||
//
|
||||
// The same route shape for PDF has always been right
|
||||
// (ExporterCardPDF._getCardData: getCard({ _id, boardId, listId })), which is
|
||||
// what makes this an omission rather than a decision. Constraining the query
|
||||
// is the fix rather than a check bolted on after it: a card outside the
|
||||
// authorised board now does not resolve at all, so nothing downstream - the
|
||||
// checklist, subtask, comment and attachment fan-out, all keyed on the same
|
||||
// card id - can read anything either.
|
||||
const card = await ReactiveCache.getCard({
|
||||
_id: this._cardId,
|
||||
boardId: this._boardId,
|
||||
listId: this._listId,
|
||||
});
|
||||
if (!card) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Card not found');
|
||||
|
||||
@@ -850,6 +850,27 @@ Users.attachSchema(
|
||||
defaultValue: {},
|
||||
blackbox: true,
|
||||
},
|
||||
'profile.collapsedCardSections': {
|
||||
/**
|
||||
* Per-user collapsed state for the sections of a card (#1591).
|
||||
* profile[cardId][sectionKey] = true|false
|
||||
*
|
||||
* ONE map for every foldable thing on a card, because they are all the
|
||||
* same question: a whole feature group ('labels', 'members', 'due', ...)
|
||||
* uses the section's own name, and an individual checklist uses
|
||||
* 'checklist-<checklistId>'. The same key works on the opened card and on
|
||||
* the minicard, so folding a checklist folds it in both.
|
||||
*
|
||||
* Per USER, like collapsedLists and collapsedSwimlanes above, and unlike
|
||||
* the checklist's own hideAllChecklistItems, which is a field on the
|
||||
* checklist and therefore changes what EVERYONE on the board sees.
|
||||
* Folding something to get it out of your way is a view preference, not
|
||||
* an edit to the card.
|
||||
*/
|
||||
type: Object,
|
||||
defaultValue: {},
|
||||
blackbox: true,
|
||||
},
|
||||
'profile.keyboardShortcuts': {
|
||||
/**
|
||||
* User-specified state of keyboard shortcut activation.
|
||||
@@ -1935,6 +1956,20 @@ Users.helpers({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/** #1591: null means "never set", so the caller can apply its own default
|
||||
* (expanded) instead of a stored false being indistinguishable from absent. */
|
||||
getCollapsedCardSection(cardId, sectionKey) {
|
||||
const { collapsedCardSections = {} } = this.profile || {};
|
||||
if (collapsedCardSections[cardId] &&
|
||||
typeof collapsedCardSections[cardId][sectionKey] === 'boolean') {
|
||||
return collapsedCardSections[cardId][sectionKey];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/** The key for one checklist, so the opened card and the minicard agree. */
|
||||
checklistSectionKey(checklistId) {
|
||||
return `checklist-${checklistId}`;
|
||||
},
|
||||
setCollapsedListToStorage(boardId, listId, collapsed) {
|
||||
// Logged-in users: save to profile
|
||||
if (this._id) {
|
||||
@@ -2319,6 +2354,13 @@ Users.helpers({
|
||||
return await Users.updateAsync(this._id, { $set: { 'profile.collapsedSwimlanes': current } });
|
||||
},
|
||||
|
||||
async setCollapsedCardSection(cardId, sectionKey, collapsed) {
|
||||
const current = (this.profile && this.profile.collapsedCardSections) || {};
|
||||
if (!current[cardId]) current[cardId] = {};
|
||||
current[cardId][sectionKey] = !!collapsed;
|
||||
return await Users.updateAsync(this._id, { $set: { 'profile.collapsedCardSections': current } });
|
||||
},
|
||||
|
||||
async setMobileMode(enabled) {
|
||||
return await Users.updateAsync(this._id, { $set: { 'profile.mobileMode': enabled } });
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v10.80.0",
|
||||
"version": "v10.83.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v10.80.0",
|
||||
"version": "v10.83.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v10.80.0",
|
||||
"version": "v10.83.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v10.80</title>
|
||||
<title>WeKan REST API v10.83</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.80<!-- -->)</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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
" 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.83<!-- -->)</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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
<p>If you are an end-user and not a dev or a tester, <a href="https://github.com/wekan/wekan/issues/new">create an issue</a> to request new APIs.</p>
|
||||
<blockquote>
|
||||
<p>All API calls in the documentation are made using <code>curl</code>. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.</p>
|
||||
@@ -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.80","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.83","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<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);
|
||||
|
||||
+19
-1
@@ -1,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v10.80
|
||||
version: v10.83
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
@@ -6800,6 +6800,23 @@ definitions:
|
||||
Per-user collapsed state for swimlanes.
|
||||
profile[boardId][swimlaneId] = true|false
|
||||
type: object
|
||||
collapsedCardSections:
|
||||
description: |
|
||||
Per-user collapsed state for the sections of a card (#1591).
|
||||
profile[cardId][sectionKey] = true|false
|
||||
|
||||
ONE map for every foldable thing on a card, because they are all the
|
||||
same question: a whole feature group ('labels', 'members', 'due', ...)
|
||||
uses the section's own name, and an individual checklist uses
|
||||
'checklist-<checklistId>'. The same key works on the opened card and on
|
||||
the minicard, so folding a checklist folds it in both.
|
||||
|
||||
Per USER, like collapsedLists and collapsedSwimlanes above, and unlike
|
||||
the checklist's own hideAllChecklistItems, which is a field on the
|
||||
checklist and therefore changes what EVERYONE on the board sees.
|
||||
Folding something to get it out of your way is a view preference, not
|
||||
an edit to the card.
|
||||
type: object
|
||||
keyboardShortcuts:
|
||||
description: |
|
||||
User-specified state of keyboard shortcut activation.
|
||||
@@ -6843,6 +6860,7 @@ definitions:
|
||||
- swimlaneHeights
|
||||
- collapsedLists
|
||||
- collapsedSwimlanes
|
||||
- collapsedCardSections
|
||||
- keyboardShortcuts
|
||||
- verticalScrollbars
|
||||
- showWeekOfYear
|
||||
|
||||
Executable
+370
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ── Catch the Helm chart repo up with the WeKan releases that exist ──────────
|
||||
#
|
||||
# The wekan/charts gh-pages index lists one chart per WeKan release. It is
|
||||
# written one entry at a time by releases/release-charts.sh, during a release -
|
||||
# so a release whose `charts` job did not run leaves a hole, and the hole is
|
||||
# permanent: the next release adds ITS entry and never looks back. v10.80 is one
|
||||
# (its charts job was skipped when a cancelled build-mac-x64 cancelled the run),
|
||||
# and it is not the only one - 474 of the 690 WeKan releases have no chart entry.
|
||||
#
|
||||
# This script closes the gap from the outside, and is the answer to "which WeKan
|
||||
# releases should the index list?": the ones that EXIST AND CAN BE INSTALLED.
|
||||
#
|
||||
# keep - a chart package is already published for it. Never rebuilt: the
|
||||
# published .tgz and its digest are what people's helm clients have
|
||||
# already seen, and repackaging changes the digest for no reason.
|
||||
# build - the release exists, its container image exists, and no chart is
|
||||
# published. This is the hole to fill.
|
||||
# omit - the release exists but its container image DOES NOT. A chart is a
|
||||
# pointer to an image (`tag: v<version>`), so an entry for a version
|
||||
# with no image is an install that fails at the pull. Those releases
|
||||
# are LEFT OUT of the index rather than listed and broken - most are
|
||||
# releases whose own docker job failed, which is exactly why there is
|
||||
# no chart for them either.
|
||||
#
|
||||
# The index is then REBUILT from the .tgz files that are actually in the
|
||||
# directory (`helm repo index --merge`), so "omitted" needs no bookkeeping: a
|
||||
# version with no package is simply not in the index, and an entry can never
|
||||
# point at a package that is not there.
|
||||
#
|
||||
# Usage:
|
||||
# ./releases/backfill-charts.sh # PLAN ONLY - prints what it would do
|
||||
# ./releases/backfill-charts.sh --apply # package the missing charts, rebuild index.yaml
|
||||
# ./releases/backfill-charts.sh --apply --push
|
||||
#
|
||||
# Without --apply nothing is written, and the plan needs no helm - only curl and
|
||||
# python3 - so it can be read anywhere. --apply needs helm and a charts checkout,
|
||||
# and even then it does NOT push unless --push is given: rewriting a live Helm
|
||||
# repository's index is the maintainer's call, not a side effect of a dry run.
|
||||
#
|
||||
# CHARTS_DIR points at the wekan/charts checkout, as in release-charts.sh; it
|
||||
# defaults to ../w/charts beside this repo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APPLY=false
|
||||
PUSH=false
|
||||
LIMIT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--apply) APPLY=true ;;
|
||||
--push) PUSH=true ;;
|
||||
--limit=*) LIMIT="${arg#--limit=}" ;;
|
||||
-h|--help)
|
||||
sed -n '2,45p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "backfill-charts.sh: unknown argument '$arg'" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CHART_BASE_URL="https://wekan.github.io/charts"
|
||||
|
||||
# .tools/charts first: companion repositories are kept under .tools/ (CLAUDE.md),
|
||||
# which is where the wekan/charts clone lives. ../w/charts stays as the older
|
||||
# location, so an existing checkout there keeps working.
|
||||
if [ -z "${CHARTS_DIR:-}" ]; then
|
||||
for candidate in "$REPO_DIR/.tools/charts" "$REPO_DIR/../w/charts"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
CHARTS_DIR="$(cd "$candidate" && pwd)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${CHARTS_DIR:-}" ]; then
|
||||
echo "Charts repo not found at $REPO_DIR/.tools/charts or $REPO_DIR/../w/charts."
|
||||
echo "Set CHARTS_DIR to the wekan/charts checkout, or run with no --apply for a plan."
|
||||
[ "$APPLY" = true ] && exit 1
|
||||
fi
|
||||
|
||||
# ── 1. The plan ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Three questions, all answered over the network so the plan is about what is
|
||||
# really published rather than what this checkout happens to contain:
|
||||
# which WeKan releases exist - the GitHub releases API
|
||||
# which charts are published - the live index.yaml
|
||||
# which container images exist - ghcr.io's manifest endpoint
|
||||
#
|
||||
# The image check is the slow one (one request per release without a chart), so
|
||||
# it is the only one that is concurrent, and a release is only asked about when
|
||||
# its answer can change the outcome.
|
||||
PLAN_JSON="$(mktemp)"
|
||||
trap 'rm -f "$PLAN_JSON"' EXIT
|
||||
|
||||
CHART_BASE_URL="$CHART_BASE_URL" LIMIT="$LIMIT" python3 - "$PLAN_JSON" <<'PYEOF'
|
||||
import json, os, re, sys, urllib.error, urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
out_path = sys.argv[1]
|
||||
base_url = os.environ["CHART_BASE_URL"]
|
||||
limit = int(os.environ.get("LIMIT") or 0)
|
||||
|
||||
def get(url, headers=None, timeout=60):
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
# Every WeKan release, newest first. Only vNN.MM tags: those are the releases a
|
||||
# chart is ever cut for.
|
||||
releases = []
|
||||
for page in range(1, 20):
|
||||
with get(f"https://api.github.com/repos/wekan/wekan/releases?per_page=100&page={page}") as r:
|
||||
batch = json.load(r)
|
||||
if not batch:
|
||||
break
|
||||
releases += batch
|
||||
versions = []
|
||||
for rel in releases:
|
||||
m = re.fullmatch(r"v(\d+)\.(\d+)", rel.get("tag_name", ""))
|
||||
if m:
|
||||
versions.append((int(m.group(1)), int(m.group(2))))
|
||||
versions = sorted(set(versions), reverse=True)
|
||||
print(f"WeKan releases: {len(versions)}", file=sys.stderr)
|
||||
|
||||
# The charts already published, read from the live index.
|
||||
try:
|
||||
with get(f"{base_url}/index.yaml") as r:
|
||||
index_text = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"cannot read {base_url}/index.yaml: HTTP {e.code}")
|
||||
# EXACTLY four spaces. An entry's own `version:` is at that indent; the mongodb
|
||||
# subchart's `version:` under `dependencies:` is at six, and a `\s*` here counts
|
||||
# it as a published chart (0.7.2, 220 times over).
|
||||
published = set(re.findall(r"^ {4}version:\s*(\d+\.\d+\.\d+)\s*$", index_text, re.M))
|
||||
print(f"charts published: {len(published)}", file=sys.stderr)
|
||||
|
||||
# ghcr.io serves public manifests with an anonymous pull token.
|
||||
with get("https://ghcr.io/token?scope=repository:wekan/wekan:pull&service=ghcr.io") as r:
|
||||
token = json.load(r)["token"]
|
||||
ACCEPT = ",".join([
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
])
|
||||
|
||||
def image_exists(tag):
|
||||
req = urllib.request.Request(
|
||||
f"https://ghcr.io/v2/wekan/wekan/manifests/{tag}",
|
||||
headers={"Authorization": f"Bearer {token}", "Accept": ACCEPT},
|
||||
method="HEAD")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return r.status == 200
|
||||
except urllib.error.HTTPError:
|
||||
return False
|
||||
except Exception:
|
||||
# A transport hiccup must not be read as "no image": that would OMIT a
|
||||
# release that has one. Unknown is reported and the release is left as
|
||||
# it is, which for a release with no chart means no chart this run.
|
||||
return None
|
||||
|
||||
keep, candidates = [], []
|
||||
for major, minor in versions:
|
||||
ver = f"{major}.{minor:02d}"
|
||||
if f"{ver}.0" in published:
|
||||
keep.append(ver)
|
||||
else:
|
||||
candidates.append(ver)
|
||||
|
||||
if limit:
|
||||
candidates = candidates[:limit]
|
||||
|
||||
print(f"asking ghcr about {len(candidates)} releases with no chart ...", file=sys.stderr)
|
||||
with ThreadPoolExecutor(max_workers=16) as ex:
|
||||
have_image = list(ex.map(lambda v: image_exists(f"v{v}"), candidates))
|
||||
|
||||
build = [v for v, ok in zip(candidates, have_image) if ok is True]
|
||||
omit = [v for v, ok in zip(candidates, have_image) if ok is False]
|
||||
unknown = [v for v, ok in zip(candidates, have_image) if ok is None]
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({"keep": keep, "build": build, "omit": omit, "unknown": unknown}, fh)
|
||||
PYEOF
|
||||
|
||||
KEEP_N=$(jq '.keep | length' "$PLAN_JSON")
|
||||
BUILD_N=$(jq '.build | length' "$PLAN_JSON")
|
||||
OMIT_N=$(jq '.omit | length' "$PLAN_JSON")
|
||||
UNKNOWN_N=$(jq '.unknown | length' "$PLAN_JSON")
|
||||
|
||||
echo
|
||||
echo "=== Helm chart backfill plan ==="
|
||||
printf ' keep %-5s chart already published; not rebuilt\n' "$KEEP_N"
|
||||
printf ' build %-5s release and image exist, no chart yet\n' "$BUILD_N"
|
||||
printf ' omit %-5s release exists but has NO container image, so a chart for it\n' "$OMIT_N"
|
||||
printf ' would install and then fail at the image pull\n'
|
||||
[ "$UNKNOWN_N" != "0" ] && printf ' ? %-5s ghcr did not answer; left alone this run\n' "$UNKNOWN_N"
|
||||
|
||||
echo
|
||||
echo " to build:"
|
||||
jq -r '.build[]' "$PLAN_JSON" | head -40 | sed 's/^/ /'
|
||||
[ "$BUILD_N" -gt 40 ] && echo " ... and $((BUILD_N - 40)) more"
|
||||
echo " omitted (no image):"
|
||||
jq -r '.omit[]' "$PLAN_JSON" | head -20 | sed 's/^/ /'
|
||||
[ "$OMIT_N" -gt 20 ] && echo " ... and $((OMIT_N - 20)) more"
|
||||
|
||||
if [ "$APPLY" != true ]; then
|
||||
echo
|
||||
echo "Plan only. Nothing was written. Re-run with --apply to package the ${BUILD_N} missing"
|
||||
echo "charts and rebuild index.yaml, and --apply --push to publish them."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. Apply ─────────────────────────────────────────────────────────────────
|
||||
command -v helm >/dev/null 2>&1 || { echo "backfill-charts.sh: --apply needs helm." >&2; exit 1; }
|
||||
[ -d "${CHARTS_DIR:-}/wekan" ] || { echo "backfill-charts.sh: no chart source at ${CHARTS_DIR:-<unset>}/wekan." >&2; exit 1; }
|
||||
|
||||
sedi() { if [ "$(uname)" = "Darwin" ]; then sed -i '' "$@"; else sed -i "$@"; fi; }
|
||||
|
||||
cd "$CHARTS_DIR"
|
||||
|
||||
# The chart SOURCE is on main; the packages and the index live on gh-pages. Both
|
||||
# are needed, so the source is copied out before the branch is switched.
|
||||
git checkout main
|
||||
git pull --ff-only || true
|
||||
SRC="$(mktemp -d)"
|
||||
trap 'rm -f "$PLAN_JSON"; rm -rf "$SRC"' EXIT
|
||||
cp -a wekan "$SRC/wekan"
|
||||
|
||||
git checkout gh-pages
|
||||
git pull --ff-only || true
|
||||
|
||||
# Each missing version is packaged from the CURRENT chart templates with its own
|
||||
# version numbers - which is all release-charts.sh ever varies between releases
|
||||
# anyway (appVersion, version, and the image tag; dependency versions are not
|
||||
# touched). A backfilled chart is therefore the chart as it is today, pointed at
|
||||
# that release's image, which is the only thing that can be reconstructed after
|
||||
# the fact and the only thing the version in the index means.
|
||||
built=0
|
||||
for VERSION in $(jq -r '.build[]' "$PLAN_JSON"); do
|
||||
CHART_VERSION="${VERSION}.0"
|
||||
if [ -f "wekan-${CHART_VERSION}.tgz" ]; then
|
||||
continue
|
||||
fi
|
||||
work="$(mktemp -d)"
|
||||
cp -a "$SRC/wekan" "$work/wekan"
|
||||
sedi -E "s|^appVersion: \"[^\"]*\"|appVersion: \"${VERSION}\"|" "$work/wekan/Chart.yaml"
|
||||
sedi -E "s|^version: [0-9]+\.[0-9]+\.[0-9]+|version: ${CHART_VERSION}|" "$work/wekan/Chart.yaml"
|
||||
sedi -E "s|tag: v[0-9]+\.[0-9]+(\.[0-9]+)?|tag: v${VERSION}|" "$work/wekan/values.yaml"
|
||||
( cd "$work" && helm dependency build wekan >/dev/null 2>&1 || helm dependency update wekan >/dev/null 2>&1 || true )
|
||||
if helm package "$work/wekan" --destination . >/dev/null; then
|
||||
built=$((built + 1))
|
||||
echo " packaged wekan-${CHART_VERSION}.tgz"
|
||||
else
|
||||
echo "::warning::could not package a chart for ${VERSION}; it stays out of the index."
|
||||
fi
|
||||
rm -rf "$work"
|
||||
done
|
||||
echo "packaged ${built} chart(s)."
|
||||
|
||||
# --merge keeps every entry that is already there, with its original `created`
|
||||
# and digest, and adds one for each package that is new. A version with no .tgz
|
||||
# in this directory gets no entry, which is how "omit" is enforced: not by a
|
||||
# list, but by there being nothing to index.
|
||||
helm repo index . --merge index.yaml --url "$CHART_BASE_URL"
|
||||
|
||||
# --merge preserves what is already there, INCLUDING entries that should never
|
||||
# have been there twice. The live index has four entries for 9.36.0 and two for
|
||||
# 10.30.0, each with a DIFFERENT digest and the SAME url - written by
|
||||
# release-charts.sh, which prepends an entry every time it runs, so re-running a
|
||||
# release duplicated its version instead of replacing it. At most one of those
|
||||
# digests can be the digest of the wekan-9.36.0.tgz that is actually served, and
|
||||
# a helm client that picks one of the others fails the integrity check on a file
|
||||
# that is perfectly fine. So: for any version with more than one entry, keep the
|
||||
# one whose digest matches the package on disk, and if none does, the newest.
|
||||
python3 - <<'PYEOF'
|
||||
import hashlib, os, re, sys
|
||||
from collections import defaultdict
|
||||
|
||||
path = "index.yaml"
|
||||
lines = open(path, encoding="utf-8").readlines()
|
||||
|
||||
hdr = next((i for i, ln in enumerate(lines) if ln.rstrip("\n") == " wekan:"), None)
|
||||
if hdr is None:
|
||||
sys.exit("index.yaml has no ' wekan:' entries block")
|
||||
|
||||
# Split the block into entries: each starts at a " - " line and runs until the
|
||||
# next one, or until a line that dedents out of the block.
|
||||
starts = []
|
||||
end_of_block = len(lines)
|
||||
for i in range(hdr + 1, len(lines)):
|
||||
ln = lines[i]
|
||||
if ln.startswith(" - "):
|
||||
starts.append(i)
|
||||
elif ln.strip() and not ln.startswith(" "):
|
||||
end_of_block = i
|
||||
break
|
||||
bounds = [(s, (starts[k + 1] if k + 1 < len(starts) else end_of_block))
|
||||
for k, s in enumerate(starts)]
|
||||
|
||||
def field(block, key):
|
||||
for ln in block:
|
||||
m = re.match(r"^ {4}" + re.escape(key) + r":\s*\"?([^\"\n]*)\"?\s*$", ln)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
entries = []
|
||||
for a, b in bounds:
|
||||
block = lines[a:b]
|
||||
entries.append({"block": block,
|
||||
"version": field(block, "version"),
|
||||
"digest": field(block, "digest"),
|
||||
"created": field(block, "created") or ""})
|
||||
|
||||
by_version = defaultdict(list)
|
||||
for e in entries:
|
||||
by_version[e["version"]].append(e)
|
||||
|
||||
def real_digest(version):
|
||||
tgz = f"wekan-{version}.tgz"
|
||||
if not os.path.exists(tgz):
|
||||
return None
|
||||
h = hashlib.sha256()
|
||||
with open(tgz, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
kept, dropped = [], []
|
||||
for e in entries:
|
||||
dupes = by_version[e["version"]]
|
||||
if len(dupes) == 1:
|
||||
kept.append(e)
|
||||
continue
|
||||
want = real_digest(e["version"])
|
||||
if want is not None:
|
||||
winner = next((d for d in dupes if d["digest"] == want), None)
|
||||
else:
|
||||
winner = None
|
||||
if winner is None:
|
||||
winner = max(dupes, key=lambda d: d["created"])
|
||||
(kept if e is winner else dropped).append(e)
|
||||
|
||||
if dropped:
|
||||
out = lines[:hdr + 1]
|
||||
for e in entries:
|
||||
if e in kept:
|
||||
out += e["block"]
|
||||
out += lines[end_of_block:]
|
||||
open(path, "w", encoding="utf-8").write("".join(out))
|
||||
for e in dropped:
|
||||
print(f" dropped a duplicate entry for {e['version']} "
|
||||
f"(digest {e['digest'][:12]}..., created {e['created']})")
|
||||
|
||||
print(f"index.yaml: {len(kept)} entries, {len(dropped)} duplicate(s) removed.")
|
||||
PYEOF
|
||||
|
||||
if [ "$PUSH" != true ]; then
|
||||
echo
|
||||
echo "Not pushed (--push not given). Review with: git -C \"$CHARTS_DIR\" status"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "Backfill the chart index for the WeKan releases that have an image." || {
|
||||
echo "Nothing to commit."; exit 0; }
|
||||
git push origin gh-pages
|
||||
echo "=== gh-pages pushed ==="
|
||||
@@ -72,10 +72,52 @@ have() { curl -fsSLI -o /dev/null --retry 3 --retry-delay 5 "$1"; }
|
||||
# Without this the job dies 400 lines in, with docker saying "no matching
|
||||
# manifest for linux/386 in the manifest list entries" - which is true of
|
||||
# ubuntu:26.04 and was how the i386 and loong64 jobs failed.
|
||||
#
|
||||
# The VARIANT is part of the platform, and leaving it out is not a near miss: on
|
||||
# 32-bit ARM it turns a clean "this image does not exist" into a bundle built in
|
||||
# the WRONG userland. `docker manifest inspect debian:trixie` lists arm/v5 and
|
||||
# arm/v7 and no arm/v6 - Debian has no ARMv6 port - but an architecture-only
|
||||
# grep for "arm" matches, so the check passed and `docker run --platform
|
||||
# linux/arm/v6` then resolved DOWNWARDS to arm/v5, Debian armel: ARMv5,
|
||||
# SOFT-float, with no /lib/ld-linux-armhf.so.3 in it. The hard-float node-armv6
|
||||
# cannot start there, and that is how the armv6 job died in v10.80:
|
||||
#
|
||||
# qemu-arm: Could not open '/lib/ld-linux-armhf.so.3': No such file or directory
|
||||
#
|
||||
# 400 lines in, after the whole apt install. Comparing architecture AND variant
|
||||
# is what makes "publishes no linux/arm/v6" the answer at the top instead.
|
||||
if [ -n "$image" ] && [ -n "$platform" ]; then
|
||||
want_arch="$(printf '%s' "$platform" | cut -d/ -f2)"
|
||||
want_variant="$(printf '%s' "$platform" | cut -d/ -f3-)"
|
||||
if docker manifest inspect "$image" >/tmp/manifest.json 2>/dev/null; then
|
||||
if ! grep -q "\"architecture\": *\"${want_arch}\"" /tmp/manifest.json; then
|
||||
if ! WANT_ARCH="$want_arch" WANT_VARIANT="$want_variant" python3 - <<'PYEOF'
|
||||
import json, os, sys
|
||||
|
||||
want_arch = os.environ["WANT_ARCH"]
|
||||
want_variant = os.environ["WANT_VARIANT"]
|
||||
|
||||
with open("/tmp/manifest.json", encoding="utf-8") as fh:
|
||||
doc = json.load(fh)
|
||||
|
||||
# A single-platform image has no "manifests" list; its own config names the
|
||||
# architecture. Both shapes are answered the same way.
|
||||
if "manifests" in doc:
|
||||
plats = [m.get("platform", {}) for m in doc["manifests"]]
|
||||
else:
|
||||
plats = [{"architecture": doc.get("architecture", ""),
|
||||
"variant": doc.get("variant", "")}]
|
||||
|
||||
for p in plats:
|
||||
if p.get("architecture") != want_arch:
|
||||
continue
|
||||
# arm64 is published as arm64/v8 and everyone writes it linux/arm64, so an
|
||||
# unrequested variant matches anything; a REQUESTED one must be exact,
|
||||
# because arm/v6 and arm/v5 are different userlands, not near neighbours.
|
||||
if not want_variant or p.get("variant", "") == want_variant:
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
then
|
||||
if [ "$optional" = "true" ]; then
|
||||
# A best-effort arch with no base image for its own CPU (loong64:
|
||||
# node-loong64 and ferretdb-loong64 exist, but no debian/ubuntu
|
||||
|
||||
@@ -186,6 +186,19 @@ skipped=0
|
||||
# WEKAN_CONFORMANCE_PORT FerretDB itself (default 37017)
|
||||
# WEKAN_CONFORMANCE_DB_PORT the database server (default 35432)
|
||||
#
|
||||
# There is a THIRD port, and it is the one that bit: FerretDB also opens a debug
|
||||
# handler for metrics and profiling, at 127.0.0.1:8088 by default, and it EXITS
|
||||
# when that address is taken. Nothing here uses it, but every backend died with
|
||||
#
|
||||
# Failed to create debug handler ... listen tcp 127.0.0.1:8088: bind: address
|
||||
# already in use
|
||||
# ERROR sqlite FerretDB did not start on this backend
|
||||
#
|
||||
# because an unrelated FerretDB was running on the machine. Choosing a free port
|
||||
# for it would work; not opening it at all is better, since the run never asks it
|
||||
# anything - so every launch below passes `--debug-addr=-`, which is how
|
||||
# FerretDB's main.go spells "no debug handler".
|
||||
#
|
||||
# is_free: a port nothing is listening on. bash's /dev/tcp needs no extra tools;
|
||||
# a refused connection means free.
|
||||
is_free() {
|
||||
@@ -385,16 +398,16 @@ for entry in "${BACKENDS[@]}"; do
|
||||
case "$handler" in
|
||||
sqlite) "$FERRET_BIN" --handler=sqlite --sqlite-url="$url" \
|
||||
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
|
||||
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
|
||||
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
|
||||
postgresql) "$FERRET_BIN" --handler=postgresql --postgresql-url="$url" \
|
||||
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
|
||||
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
|
||||
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
|
||||
mysql) "$FERRET_BIN" --handler=mysql --mysql-url="$url" \
|
||||
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
|
||||
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
|
||||
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
|
||||
hana) "$FERRET_BIN" --handler=hana --hana-url="$url" \
|
||||
--listen-addr=127.0.0.1:$FERRET_PORT --repl-set-name=rs0 \
|
||||
--telemetry=disable --log-level=error >>"$log" 2>&1 & ;;
|
||||
--telemetry=disable --debug-addr=- --log-level=error >>"$log" 2>&1 & ;;
|
||||
esac
|
||||
FERRET_PID=$!
|
||||
|
||||
|
||||
@@ -177,8 +177,20 @@ chmod +x /bundle/node
|
||||
# by bundle/cpu-exec to emulate CPU features a binary needs but the host CPU
|
||||
# lacks. Tolerant: if the package is unavailable on this arch, the bundle just
|
||||
# ships without it - cpu-exec then falls back to a system qemu-user, or reports.
|
||||
#
|
||||
# It is the ONE thing here taken from the CONTAINER rather than downloaded built
|
||||
# for the target, so it is the one thing a container that is not the target's own
|
||||
# CPU must not contribute. SAME_ARCH_CONTAINER=false says this is such a build:
|
||||
# the armv6 bundle is assembled in Debian's arm/v7 (armhf) container, because
|
||||
# Debian publishes no ARMv6 port and the armel one cannot run a hard-float node.
|
||||
# There `uname -m` is armv7l, so this would drop an ARMv7 qemu-armv7l into an
|
||||
# ARMv6 bundle - a binary a Raspberry Pi 1 cannot execute, under a name its
|
||||
# cpu-exec (which looks for qemu-armv6l) would never ask for anyway. Ship
|
||||
# without it and let cpu-exec fall back, which is what it is written to do.
|
||||
rm -f /bundle/qemu-x86_64
|
||||
if apt-get install -y -q qemu-user-static >/dev/null 2>&1 &&
|
||||
if [ "${SAME_ARCH_CONTAINER:-true}" != "true" ]; then
|
||||
echo "Bundle ${ARCH} is built in a $(uname -m) container that is not its own CPU; no qemu-user is bundled (cpu-exec falls back to the system one)."
|
||||
elif apt-get install -y -q qemu-user-static >/dev/null 2>&1 &&
|
||||
[ -x "/usr/bin/qemu-$(uname -m)-static" ]; then
|
||||
cp "/usr/bin/qemu-$(uname -m)-static" "/bundle/qemu-$(uname -m)"
|
||||
chmod +x "/bundle/qemu-$(uname -m)"
|
||||
|
||||
Executable
+516
@@ -0,0 +1,516 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild the wekan/charts Helm index from the packages it actually serves.
|
||||
|
||||
An index.yaml is a description of the .tgz files sitting beside it, and this
|
||||
regenerates it from those files instead of editing it in place. That matters
|
||||
because the index had drifted from them in four separate ways, none of which an
|
||||
in-place edit can notice:
|
||||
|
||||
MISSING ENTRIES. 145 packages on gh-pages had no entry at all - they are
|
||||
downloadable but no `helm search`, no `helm install wekan --version 9.63.0`
|
||||
and no Artifact Hub listing can find them. release-charts.sh writes one
|
||||
entry per release, so a release whose charts job did not run leaves a hole,
|
||||
and nothing ever goes back for it.
|
||||
|
||||
DUPLICATE ENTRIES WITH STALE DIGESTS. 9.36.0 appeared four times and 10.30.0
|
||||
twice, each copy with a DIFFERENT digest and the SAME url, because
|
||||
release-charts.sh PREPENDS an entry every time it runs and a re-run of a
|
||||
release duplicated the version instead of replacing it. Only one of those
|
||||
digests is the digest of the file being served; a helm client that picks
|
||||
another fails the integrity check on a perfectly good package.
|
||||
|
||||
A WRONG appVersion, ON EVERY ENTRY. The index said appVersion "10.79.0" - the
|
||||
CHART version - where the packaged Chart.yaml says "10.79", the WeKan
|
||||
version. appVersion is what a user reads to see which WeKan they get.
|
||||
|
||||
FROZEN DEPENDENCY VERSIONS. Each new entry was copied from the previous one
|
||||
with a few fields substituted, so fields nobody substituted never changed:
|
||||
the index claimed the mongodb subchart was 0.7.2 for every release, while
|
||||
the packages have moved on to 0.7.6.
|
||||
|
||||
Every one of those is the same mistake - treating the index as the record rather
|
||||
than as a description of the packages - so the fix is to derive it from them.
|
||||
|
||||
`created` is preserved, never invented: an entry that already exists and whose
|
||||
digest matches its package keeps the timestamp it has, and a package being added
|
||||
takes the date it was COMMITTED to the gh-pages branch, which is when it really
|
||||
was published (verified: wekan-10.79.0.tgz was committed at 2026-08-10T02:46:52Z
|
||||
and its existing entry says created 2026-08-10T02:46:52.308983+00:00). Only a
|
||||
package with no entry and no git history falls back to now.
|
||||
|
||||
Usage:
|
||||
releases/reindex-charts.py [--charts-dir DIR] # report only, writes nothing
|
||||
releases/reindex-charts.py --write [--charts-dir DIR]
|
||||
|
||||
DIR defaults to $CHARTS_DIR, then .tools/charts, then ../w/charts. It must be on
|
||||
the gh-pages branch, where the packages and index.yaml live.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("reindex-charts.py needs PyYAML (pip install pyyaml, or apt install python3-yaml).")
|
||||
|
||||
CHART_NAME = "wekan"
|
||||
BASE_URL = "https://wekan.github.io/charts"
|
||||
|
||||
|
||||
def find_charts_dir(explicit):
|
||||
"""Where the charts checkout is, in the order the repo keeps things."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
repo = os.path.dirname(here)
|
||||
for candidate in (explicit, os.environ.get("CHARTS_DIR"),
|
||||
os.path.join(repo, ".tools", "charts"),
|
||||
os.path.join(repo, "..", "w", "charts")):
|
||||
if candidate and os.path.isdir(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def chart_metadata(path):
|
||||
"""The packaged Chart.yaml - which IS the index entry, bar created/digest/urls.
|
||||
|
||||
Read from the archive rather than from the chart source, because the archive
|
||||
is what is served: a source tree that has moved on since cannot make an entry
|
||||
describe a package wrongly.
|
||||
"""
|
||||
with tarfile.open(path) as tar:
|
||||
members = [m for m in tar.getnames()
|
||||
if m.count("/") == 1 and m.endswith("/Chart.yaml")]
|
||||
if not members:
|
||||
return None
|
||||
with tar.extractfile(members[0]) as fh:
|
||||
return yaml.safe_load(fh.read())
|
||||
|
||||
|
||||
def git_added(directory, filename):
|
||||
"""When this package was committed to gh-pages - i.e. when it was published."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", directory, "log", "--diff-filter=A", "--format=%aI",
|
||||
"-1", "--", filename],
|
||||
capture_output=True, text=True, timeout=60, check=False).stdout.strip()
|
||||
return out or None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def version_key(version):
|
||||
parts = re.findall(r"\d+", version or "")
|
||||
return [int(p) for p in parts] or [0]
|
||||
|
||||
|
||||
# ── Charts that are published but cannot be installed ────────────────────────
|
||||
#
|
||||
# A chart is a pointer to container images, so a chart whose images have been
|
||||
# deleted from their registries installs and then fails at the pull. Listing one
|
||||
# is worse than not listing it: Artifact Hub scans every entry in the index for
|
||||
# vulnerabilities and reports each unpullable image as an error against the
|
||||
# repository -
|
||||
#
|
||||
# error scanning image ghcr.io/wekan/wekan:v9.62: image not found
|
||||
# error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found
|
||||
#
|
||||
# - so the index says the repository is broken when the repository is fine and
|
||||
# the images are gone.
|
||||
#
|
||||
# Two separate causes, both real here. Six WeKan images were never pushed or have
|
||||
# since been removed (v8.30, v9.12, v9.14, v9.38, v9.39, v9.62 - the releases
|
||||
# whose own docker job failed). And 129 older charts vendor the BITNAMI mongodb
|
||||
# subchart, pinning tags such as bitnami/mongodb:7.0.14-debian-12-r3 that Bitnami
|
||||
# has deleted; charts from 8.x on vendor groundhog2k's, which uses the official
|
||||
# `mongo` image and is unaffected.
|
||||
#
|
||||
# The exclusion is RECORDED IN THE REPOSITORY, in UNINDEXED_FILE, rather than
|
||||
# decided on every run. Two reasons. A rebuild during a release must not depend
|
||||
# on reaching two registries - a network blip would silently drop half the index.
|
||||
# And the decision is reversible and reviewable: the file says which package was
|
||||
# left out and why, so if an image comes back, deleting a line puts the chart
|
||||
# back in the index. `--check-images` is what regenerates it.
|
||||
UNINDEXED_FILE = "unindexed.txt"
|
||||
|
||||
REGISTRY_ACCEPT = ",".join([
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
])
|
||||
|
||||
|
||||
def split_image(registry, repository, tag):
|
||||
"""Normalise an image reference to (host, repository, tag).
|
||||
|
||||
Older WeKan charts put the whole reference in `repository`
|
||||
("ghcr.io/wekan/wekan") and leave `registry` unset, so the docker.io default
|
||||
must not be believed when the repository itself begins with a host - reading
|
||||
those as docker.io/ghcr.io/wekan/wekan is how a first pass came back
|
||||
"unknown" for every one of them.
|
||||
"""
|
||||
head = repository.split("/")[0]
|
||||
if "." in head or head == "localhost":
|
||||
registry, repository = head, repository.split("/", 1)[1]
|
||||
registry = registry or "docker.io"
|
||||
if registry in ("docker.io", "index.docker.io", "registry-1.docker.io"):
|
||||
host = "registry-1.docker.io"
|
||||
if "/" not in repository:
|
||||
repository = "library/" + repository
|
||||
else:
|
||||
host = registry
|
||||
return host, repository, tag or "latest"
|
||||
|
||||
|
||||
def image_exists(host, repository, tag, _tokens={}):
|
||||
"""True / False / None, where None means "could not tell" - never "missing".
|
||||
|
||||
The auth handshake is the REGISTRY'S OWN, not a guess per host: ask for the
|
||||
manifest with no credentials, and if the registry answers 401 it says in its
|
||||
WWW-Authenticate header which realm to get a token from and for what scope.
|
||||
Hard-coding one token URL per registry is what made a first version report
|
||||
every quay.io image as missing - including quay.io/wekan/wekan:latest, which
|
||||
plainly exists - because the wrong endpoint gave a token quay would not
|
||||
accept and the manifest came back 404. A chart must never leave the index on
|
||||
the strength of a bad guess about a registry's API.
|
||||
|
||||
A registry that refuses anonymous auth, or a request that times out, returns
|
||||
None: the list is only ever allowed to say what is really gone.
|
||||
"""
|
||||
url = f"https://{host}/v2/{repository}/manifests/{tag}"
|
||||
headers = {"Accept": REGISTRY_ACCEPT}
|
||||
|
||||
def ask(extra=None):
|
||||
request = urllib.request.Request(url, headers={**headers, **(extra or {})},
|
||||
method="HEAD")
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
return response.status
|
||||
|
||||
try:
|
||||
cached = _tokens.get((host, repository))
|
||||
if cached:
|
||||
return ask({"Authorization": f"Bearer {cached}"}) == 200
|
||||
return ask() == 200
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404:
|
||||
return False
|
||||
if error.code != 401:
|
||||
return None
|
||||
challenge = error.headers.get("WWW-Authenticate", "")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
fields = dict(re.findall(r'(\w+)="([^"]*)"', challenge))
|
||||
realm = fields.pop("realm", None)
|
||||
if not realm:
|
||||
return None
|
||||
fields.setdefault("scope", f"repository:{repository}:pull")
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{realm}?{urllib.parse.urlencode(fields)}", timeout=60) as response:
|
||||
payload = json.load(response)
|
||||
token = payload.get("token") or payload.get("access_token")
|
||||
if not token:
|
||||
return None
|
||||
_tokens[(host, repository)] = token
|
||||
return ask({"Authorization": f"Bearer {token}"}) == 200
|
||||
except urllib.error.HTTPError as error:
|
||||
return False if error.code == 404 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def chart_images(path):
|
||||
"""Every image the chart and its vendored subcharts pin."""
|
||||
found = []
|
||||
|
||||
def from_values(values):
|
||||
image = (values or {}).get("image")
|
||||
if isinstance(image, dict) and image.get("repository"):
|
||||
found.append(split_image(image.get("registry"), image["repository"],
|
||||
str(image.get("tag") or "latest")))
|
||||
|
||||
with tarfile.open(path) as tar:
|
||||
top = [m for m in tar.getnames()
|
||||
if m.count("/") == 1 and m.endswith("/values.yaml")]
|
||||
if top:
|
||||
with tar.extractfile(top[0]) as fh:
|
||||
from_values(yaml.safe_load(fh.read()))
|
||||
for sub in [m for m in tar.getnames()
|
||||
if "/charts/" in m and m.endswith(".tgz")]:
|
||||
with tar.extractfile(sub) as fh:
|
||||
blob = fh.read()
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(blob)) as subtar:
|
||||
inner = [m for m in subtar.getnames()
|
||||
if m.count("/") == 1 and m.endswith("/values.yaml")]
|
||||
if inner:
|
||||
with subtar.extractfile(inner[0]) as sfh:
|
||||
from_values(yaml.safe_load(sfh.read()))
|
||||
except (tarfile.TarError, yaml.YAMLError):
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
def read_unindexed(directory):
|
||||
"""{filename: reason} - the packages this repository deliberately does not list."""
|
||||
path = os.path.join(directory, UNINDEXED_FILE)
|
||||
out = {}
|
||||
if not os.path.exists(path):
|
||||
return out
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
name, _, reason = line.partition("#")
|
||||
out[name.strip()] = reason.strip()
|
||||
return out
|
||||
|
||||
|
||||
def check_images(directory, packages):
|
||||
"""Probe every image every package pins; return {filename: reason} for the
|
||||
ones with an image that is definitely gone."""
|
||||
per_package, wanted = {}, set()
|
||||
for filename in packages:
|
||||
try:
|
||||
images = chart_images(os.path.join(directory, filename))
|
||||
except (tarfile.TarError, yaml.YAMLError):
|
||||
continue
|
||||
per_package[filename] = images
|
||||
wanted.update(images)
|
||||
|
||||
print(f"checking {len(wanted)} distinct images from {len(per_package)} packages ...",
|
||||
file=sys.stderr)
|
||||
with ThreadPoolExecutor(max_workers=12) as pool:
|
||||
verdicts = dict(zip(wanted, pool.map(lambda i: image_exists(*i), wanted)))
|
||||
|
||||
unknown = [i for i, ok in verdicts.items() if ok is None]
|
||||
if unknown:
|
||||
print(f" {len(unknown)} image(s) could not be checked; they are treated as "
|
||||
f"PRESENT, never as missing", file=sys.stderr)
|
||||
|
||||
out = {}
|
||||
for filename, images in per_package.items():
|
||||
gone = [f"{h}/{r}:{t}" for h, r, t in images if verdicts.get((h, r, t)) is False]
|
||||
if gone:
|
||||
out[filename] = f"image not found: {', '.join(sorted(gone))}"
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--charts-dir")
|
||||
ap.add_argument("--write", action="store_true",
|
||||
help="rewrite index.yaml (without this, nothing is written)")
|
||||
ap.add_argument("--check-images", action="store_true",
|
||||
help="probe every image the charts pin and refresh "
|
||||
f"{UNINDEXED_FILE} from the result (needs network)")
|
||||
args = ap.parse_args()
|
||||
|
||||
directory = find_charts_dir(args.charts_dir)
|
||||
if not directory:
|
||||
sys.exit("reindex-charts.py: no charts checkout found. Pass --charts-dir "
|
||||
"or set CHARTS_DIR to a wekan/charts clone on its gh-pages branch.")
|
||||
|
||||
index_path = os.path.join(directory, "index.yaml")
|
||||
packages = sorted(f for f in os.listdir(directory) if f.endswith(".tgz"))
|
||||
if not packages:
|
||||
sys.exit(f"reindex-charts.py: no .tgz packages in {directory} - is it on "
|
||||
f"the gh-pages branch?")
|
||||
|
||||
# What the index says now. Missing or unreadable is not fatal: the packages
|
||||
# are the source of truth and an index can be built from them alone.
|
||||
old_entries = []
|
||||
if os.path.exists(index_path):
|
||||
with open(index_path, encoding="utf-8") as fh:
|
||||
old = yaml.safe_load(fh) or {}
|
||||
old_entries = (old.get("entries") or {}).get(CHART_NAME) or []
|
||||
old_by_version = {}
|
||||
for entry in old_entries:
|
||||
old_by_version.setdefault(entry.get("version"), []).append(entry)
|
||||
|
||||
# Which packages are published but must not be listed, and why.
|
||||
if args.check_images:
|
||||
unindexed = check_images(directory, packages)
|
||||
if args.write:
|
||||
path = os.path.join(directory, UNINDEXED_FILE)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(
|
||||
"# Packages that are published here but are NOT listed in\n"
|
||||
"# index.yaml, because an image they pin no longer exists in its\n"
|
||||
"# registry - so the chart would install and then fail at the\n"
|
||||
"# pull, and Artifact Hub reports each one as a scan error\n"
|
||||
"# against this repository.\n"
|
||||
"#\n"
|
||||
"# Regenerate with: releases/reindex-charts.py --check-images --write\n"
|
||||
"# An image that comes back: delete its line and rebuild.\n"
|
||||
"# The .tgz files themselves stay, so a direct URL keeps working.\n\n")
|
||||
for name in sorted(unindexed, key=lambda f: version_key(f)):
|
||||
fh.write(f"{name} # {unindexed[name]}\n")
|
||||
print(f"wrote {path} - {len(unindexed)} package(s) not listed.")
|
||||
else:
|
||||
unindexed = read_unindexed(directory)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
entries, skipped, added, retimed, corrected = [], [], [], [], []
|
||||
excluded = []
|
||||
|
||||
for filename in packages:
|
||||
if filename in unindexed:
|
||||
excluded.append(f"{filename}: {unindexed[filename]}")
|
||||
continue
|
||||
path = os.path.join(directory, filename)
|
||||
meta = chart_metadata(path)
|
||||
if not meta or not meta.get("version"):
|
||||
skipped.append(f"{filename}: no <chart>/Chart.yaml inside it")
|
||||
continue
|
||||
|
||||
entry = dict(meta)
|
||||
digest = sha256(path)
|
||||
entry["digest"] = digest
|
||||
entry["urls"] = [f"{BASE_URL}/{filename}"]
|
||||
|
||||
# created: keep what the matching entry already says, so a rebuild does
|
||||
# not restamp the whole repository; otherwise take the publish date from
|
||||
# git; only then fall back to now.
|
||||
previous = old_by_version.get(meta["version"], [])
|
||||
match = next((e for e in previous if str(e.get("digest", "")).strip() == digest), None)
|
||||
if match and match.get("created"):
|
||||
entry["created"] = str(match["created"])
|
||||
else:
|
||||
when = git_added(directory, filename)
|
||||
entry["created"] = when or now
|
||||
if previous:
|
||||
retimed.append(f"{meta['version']} ({filename}): "
|
||||
f"{len(previous)} stale entr{'y' if len(previous) == 1 else 'ies'} "
|
||||
f"replaced, created taken from {'git' if when else 'now'}")
|
||||
else:
|
||||
added.append(f"{meta['version']} ({filename}), published {entry['created'][:10]}")
|
||||
|
||||
if match:
|
||||
for field in ("appVersion", "dependencies"):
|
||||
if field in meta and match.get(field) != meta.get(field):
|
||||
corrected.append(f"{meta['version']}: {field} "
|
||||
f"{match.get(field)!r} -> {meta[field]!r}")
|
||||
entries.append(entry)
|
||||
|
||||
# TWO PACKAGES CLAIMING ONE CHART VERSION. Two 2023 packaging slips do this:
|
||||
# wekan-1.2.7.tgz contains a chart whose Chart.yaml says version 1.2.6, and
|
||||
# wekan-6.96.tgz and wekan-6.9.6.tgz both say 6.9.6. `helm repo index` would
|
||||
# list both, leaving `--version 6.9.6` to resolve to whichever entry came
|
||||
# first - so the index would name a version that means two different files.
|
||||
# The package whose FILENAME matches the version it declares is the one that
|
||||
# was meant; a mismatch is the slip. The other file stays on the server, so a
|
||||
# direct URL anyone already has keeps working - it is just not offered as a
|
||||
# version. If neither filename matches, the one published first wins, because
|
||||
# that is the one that may already have been installed from.
|
||||
by_version = {}
|
||||
for entry in entries:
|
||||
by_version.setdefault(entry["version"], []).append(entry)
|
||||
unindexed = []
|
||||
for version, group in by_version.items():
|
||||
if len(group) == 1:
|
||||
continue
|
||||
def preference(entry):
|
||||
filename = entry["urls"][0].rsplit("/", 1)[-1]
|
||||
return (filename != f"{CHART_NAME}-{version}.tgz", entry.get("created", ""))
|
||||
group.sort(key=preference)
|
||||
for loser in group[1:]:
|
||||
entries.remove(loser)
|
||||
unindexed.append(
|
||||
f"{loser['urls'][0].rsplit('/', 1)[-1]}: its Chart.yaml declares "
|
||||
f"version {version}, which "
|
||||
f"{group[0]['urls'][0].rsplit('/', 1)[-1]} also declares - kept the "
|
||||
f"one whose filename matches. The file is still served, just not "
|
||||
f"offered as a version.")
|
||||
|
||||
entries.sort(key=lambda e: version_key(e.get("version")), reverse=True)
|
||||
|
||||
seen = {}
|
||||
for entry in entries:
|
||||
seen.setdefault(entry["version"], []).append(entry)
|
||||
collisions = {v: len(e) for v, e in seen.items() if len(e) > 1}
|
||||
|
||||
print(f"charts dir : {directory}")
|
||||
print(f"packages : {len(packages)}")
|
||||
print(f"index entries : {len(old_entries)} before -> {len(entries)} after")
|
||||
print(f" added : {len(added)} (a package that had no entry at all)")
|
||||
# The duplicates are counted in the OLD index, not derived from the sizes:
|
||||
# entries added and packages not offered move that difference too, and
|
||||
# folding all three into one subtraction reported six duplicates where there
|
||||
# were four.
|
||||
print(f" de-duplicated : {len(old_entries) - len({e.get('version') for e in old_entries})}"
|
||||
f" (stale repeat entries for a version already listed)")
|
||||
print(f" corrected : {len(corrected)} (fields that disagreed with the package)")
|
||||
if excluded:
|
||||
print(f" not listed : {len(excluded)} (an image they pin is gone - "
|
||||
f"see {UNINDEXED_FILE})")
|
||||
for line in excluded[:4]:
|
||||
print(f" {line}")
|
||||
if len(excluded) > 4:
|
||||
print(f" ... and {len(excluded) - 4} more")
|
||||
if skipped:
|
||||
print(f" skipped : {len(skipped)}")
|
||||
for line in skipped:
|
||||
print(f" {line}")
|
||||
if unindexed:
|
||||
print(f" not offered : {len(unindexed)} (a version two packages claim)")
|
||||
for line in unindexed:
|
||||
print(f" {line}")
|
||||
for line in retimed:
|
||||
print(f" ! {line}")
|
||||
for line in corrected[:6]:
|
||||
print(f" ~ {line}")
|
||||
if len(corrected) > 6:
|
||||
print(f" ~ ... and {len(corrected) - 6} more")
|
||||
for line in added[-8:]:
|
||||
print(f" + {line}")
|
||||
if len(added) > 8:
|
||||
print(f" + ... and {len(added) - 8} older ones")
|
||||
|
||||
if collisions:
|
||||
sys.exit(f"reindex-charts.py: two packages claim the same chart version "
|
||||
f"{collisions} - refusing to write an index that cannot say which "
|
||||
f"file a version is.")
|
||||
|
||||
if not args.write:
|
||||
print("\nReport only. Nothing was written. Re-run with --write to rebuild index.yaml.")
|
||||
return 0
|
||||
|
||||
document = {
|
||||
"apiVersion": "v1",
|
||||
"entries": {CHART_NAME: entries},
|
||||
"generated": now,
|
||||
}
|
||||
with open(index_path, "w", encoding="utf-8") as fh:
|
||||
yaml.safe_dump(document, fh, default_flow_style=False, width=10000,
|
||||
sort_keys=True, allow_unicode=True)
|
||||
print(f"\nWrote {index_path} - {len(entries)} entries.")
|
||||
print("Review it (git diff), then commit and push the gh-pages branch.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+128
-93
@@ -1,27 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update and publish the WeKan Helm chart in the separate wekan/charts repo,
|
||||
# which lives at ../w/charts (a sibling of the wekan repo).
|
||||
# checked out at .tools/charts (or the older ../w/charts).
|
||||
#
|
||||
# Usage:
|
||||
# ./releases/release-charts.sh 9.36
|
||||
#
|
||||
# Called automatically by ./releases/version.sh during a release, so both
|
||||
# ./releases/release.sh and ./releases/release-all.sh keep the chart in sync.
|
||||
# ./releases/release.sh and ./releases/release-all.sh keep the chart in sync,
|
||||
# and by the charts jobs of release-all.yml and release-all-missing.yml.
|
||||
#
|
||||
# THE TWO BRANCHES. The charts repo keeps the chart SOURCE on `main` and the
|
||||
# published packages plus index.yaml on `gh-pages`, and its own two scripts move
|
||||
# between them: main's release.sh commits the source, tars `wekan/` into
|
||||
# wekan-<version>.0.tgz, checks out gh-pages and drops the package there;
|
||||
# gh-pages' release2.sh commits and pushes that branch and returns to main. This
|
||||
# script drives both, and owns the index in between.
|
||||
#
|
||||
# What it does:
|
||||
# 1. On the charts repo main branch, bump ONLY the WeKan version number in:
|
||||
# ../w/charts/wekan/Chart.yaml - appVersion (e.g. "9.36")
|
||||
# - version (e.g. 9.36.0)
|
||||
# ../w/charts/wekan/values.yaml - WeKan image tag (e.g. tag: v9.36)
|
||||
# <charts>/wekan/Chart.yaml - appVersion (e.g. "9.36")
|
||||
# - version (e.g. 9.36.0)
|
||||
# <charts>/wekan/values.yaml - WeKan image tag (e.g. tag: v9.36)
|
||||
# Dependency versions (mongodb chart, mongodb image, etc.) are NOT changed.
|
||||
# 2. Run ../w/charts/release.sh <version>: helm dependency build, commit+push
|
||||
# main, package wekan-<version>.0.tgz, then `git checkout gh-pages` and move
|
||||
# the .tgz into the gh-pages working tree.
|
||||
# 3. Prepend a new chart entry to gh-pages index.yaml: current datetime, the
|
||||
# sha256sum of wekan-<version>.0.tgz, and the new version/urls (the rest is
|
||||
# copied from the previous newest entry).
|
||||
# 4. Run ../w/charts/release2.sh <version>: commit+push the gh-pages branch.
|
||||
# 2. Run <charts>/release.sh <version>: commit+push main, package
|
||||
# wekan-<version>.0.tgz, then `git checkout gh-pages` and move the .tgz into
|
||||
# the gh-pages working tree.
|
||||
# 3. Check the package's own Chart.yaml agrees with its filename, then REBUILD
|
||||
# index.yaml from every package on gh-pages (releases/reindex-charts.py) -
|
||||
# rather than prepending a hand-edited copy of the previous entry, which is
|
||||
# where all four of the index's defects came from.
|
||||
# 4. Run <charts>/release2.sh <version>: commit+push the gh-pages branch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -35,16 +44,24 @@ VERSION="$1" # e.g. 9.36 (WeKan version, no v prefix)
|
||||
CHART_VERSION="${VERSION}.0" # e.g. 9.36.0 (chart SemVer + index version)
|
||||
|
||||
# The remote (GitHub Actions) flow sets CHARTS_DIR to the charts repo it checked
|
||||
# out; otherwise resolve it as a sibling of the wekan repo: <repo>/../w/charts.
|
||||
# out; otherwise it is resolved below.
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
export REPO_DIR
|
||||
# .tools/charts first: companion repositories are cloned there (CLAUDE.md), which
|
||||
# is where the wekan/charts checkout lives. ../w/charts stays as the older
|
||||
# location so an existing checkout keeps working. The chart SOURCE is on the main
|
||||
# branch, so `wekan/` is what says a checkout is really there - on gh-pages it is
|
||||
# not, and a run from the wrong branch should say so rather than half-work.
|
||||
if [ -z "${CHARTS_DIR:-}" ]; then
|
||||
if [ ! -d "$REPO_DIR/../w/charts/wekan" ]; then
|
||||
echo "Charts repo not found at $REPO_DIR/../w/charts/wekan, skipping chart release."
|
||||
exit 0
|
||||
fi
|
||||
CHARTS_DIR="$(cd "$REPO_DIR/../w/charts" && pwd)"
|
||||
elif [ ! -d "$CHARTS_DIR/wekan" ]; then
|
||||
echo "Charts repo not found at $CHARTS_DIR/wekan, skipping chart release."
|
||||
for candidate in "$REPO_DIR/.tools/charts" "$REPO_DIR/../w/charts"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
CHARTS_DIR="$(cd "$candidate" && pwd)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${CHARTS_DIR:-}" ]; then
|
||||
echo "Charts repo not found at $REPO_DIR/.tools/charts or $REPO_DIR/../w/charts, skipping chart release."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -61,6 +78,50 @@ CHART_VALUES="$CHARTS_DIR/wekan/values.yaml"
|
||||
|
||||
echo "=== Helm chart release: WeKan v$VERSION (chart $CHART_VERSION) ==="
|
||||
|
||||
# ── 0. Is there an image for this version to point AT? ───────────────────────
|
||||
#
|
||||
# A chart is a pointer to a container image, so publishing one for a version
|
||||
# whose image was never pushed produces a chart that installs and then fails at
|
||||
# the pull - and Artifact Hub, which scans every entry in the index, reports it
|
||||
# as an error against the whole repository:
|
||||
#
|
||||
# error scanning image ghcr.io/wekan/wekan:v9.62: image not found
|
||||
#
|
||||
# Six versions are in that state already (v8.30, v9.12, v9.14, v9.38, v9.39,
|
||||
# v9.62 - releases whose own docker job failed), and they had to be taken back
|
||||
# out of the index by hand. One request stops the seventh. In a full release the
|
||||
# charts job runs after `docker` has pushed the image, so this passes; it is the
|
||||
# out-of-band paths - release-all-missing.yml, and running this by hand - where a
|
||||
# version can have no image at all.
|
||||
#
|
||||
# A registry that cannot be reached is NOT taken as "no image": the check has to
|
||||
# be sure before it stops a release.
|
||||
# PYTHONDONTWRITEBYTECODE: the check imports releases/reindex-charts.py, and
|
||||
# importing a module writes releases/__pycache__ beside it - untracked junk
|
||||
# appearing in the repository on every release.
|
||||
if ! PYTHONDONTWRITEBYTECODE=1 python3 - "$VERSION" <<'PYEOF'
|
||||
import importlib.util, os, sys
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"reindex", os.path.join(os.environ["REPO_DIR"], "releases", "reindex-charts.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
host, repo, tag = mod.split_image(None, "ghcr.io/wekan/wekan", "v" + sys.argv[1])
|
||||
verdict = mod.image_exists(host, repo, tag)
|
||||
if verdict is False:
|
||||
sys.exit(1)
|
||||
if verdict is None:
|
||||
print(f" Could not reach the registry to check ghcr.io/wekan/wekan:v{sys.argv[1]};"
|
||||
" continuing.")
|
||||
PYEOF
|
||||
then
|
||||
echo "Error: ghcr.io/wekan/wekan:v${VERSION} does not exist." >&2
|
||||
echo " A chart for it would install and then fail at the image pull, and" >&2
|
||||
echo " Artifact Hub would report it as a scan error against wekan/charts." >&2
|
||||
echo " Publish the image first (the docker job), then run this again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# ── 1. Bump only the WeKan version numbers on the main branch ────────────────
|
||||
( cd "$CHARTS_DIR" && git checkout main && git pull )
|
||||
|
||||
@@ -79,7 +140,18 @@ echo " Updated Chart.yaml (appVersion ${VERSION}, version ${CHART_VERSION}) and
|
||||
# ── 2. Build, commit+push main, package, and switch to gh-pages ──────────────
|
||||
( cd "$CHARTS_DIR" && ./release.sh "$VERSION" )
|
||||
|
||||
# ── 3. Add the new chart to the gh-pages index.yaml ──────────────────────────
|
||||
# ── 3. Rebuild the gh-pages index.yaml from the packages ─────────────────────
|
||||
#
|
||||
# The package is checked BEFORE it is indexed, because the one thing this step
|
||||
# cannot recover from is a tarball whose name and contents disagree. charts'
|
||||
# release.sh names the file from its argument (`tar -cvzf wekan-$1.0.tgz wekan`)
|
||||
# while the version INSIDE comes from the Chart.yaml this script sed-ed a moment
|
||||
# ago - so if those two ever drift, the repository gains a file called one
|
||||
# version that declares another. That is not hypothetical: wekan-1.2.7.tgz
|
||||
# (containing 1.2.6) and wekan-6.96.tgz (containing 6.9.6) are both in the
|
||||
# repository's history, each colliding with the correctly named file of that
|
||||
# version, and a Helm index keys on the version INSIDE, so neither could ever be
|
||||
# installed under the name it was given.
|
||||
TGZ="wekan-${CHART_VERSION}.tgz"
|
||||
(
|
||||
cd "$CHARTS_DIR"
|
||||
@@ -87,81 +159,44 @@ TGZ="wekan-${CHART_VERSION}.tgz"
|
||||
echo "Error: expected packaged chart not found: $CHARTS_DIR/$TGZ" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A Helm index digest is the bare sha256 hash. `sha256sum` prints
|
||||
# "<hash> <filename>", so keep only the first field — otherwise the filename
|
||||
# leaks into the digest value (digest: <hash> wekan-<ver>.tgz).
|
||||
DIGEST="$(sha256sum "$TGZ" | awk '{print $1}')"
|
||||
|
||||
CHART_VERSION="$CHART_VERSION" DIGEST="$DIGEST" python3 - <<'PYEOF'
|
||||
import os, re, sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
chart_version = os.environ["CHART_VERSION"]
|
||||
digest = os.environ["DIGEST"].strip()
|
||||
url = f"https://wekan.github.io/charts/wekan-{chart_version}.tgz"
|
||||
created = datetime.now(timezone.utc).astimezone().isoformat()
|
||||
|
||||
path = "index.yaml"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Clean up legacy entries whose digest still carries the "<hash> <filename>"
|
||||
# form (older releases stored the raw `sha256sum` output): keep only the hash.
|
||||
digest_re = re.compile(r'^(\s*digest:\s+)([0-9a-fA-F]+)\b.*$')
|
||||
lines = [digest_re.sub(r'\1\2', ln) if digest_re.match(ln) else ln for ln in lines]
|
||||
|
||||
# Locate the " wekan:" entries header; the newest entry is the first list item.
|
||||
hdr = next((i for i, ln in enumerate(lines) if ln.rstrip("\n") == " wekan:"), None)
|
||||
if hdr is None:
|
||||
sys.exit("Could not find ' wekan:' in index.yaml")
|
||||
|
||||
start = hdr + 1
|
||||
if start >= len(lines) or not lines[start].startswith(" - "):
|
||||
sys.exit("Unexpected index.yaml format: no chart entry after ' wekan:'")
|
||||
|
||||
# The entry runs until the next list item (" - ") or a dedent to a top-level key.
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
s = lines[j]
|
||||
if s.startswith(" - ") or (s.strip() and not s.startswith(" ")):
|
||||
end = j
|
||||
break
|
||||
block = lines[start:end]
|
||||
|
||||
def set_field(block, key, value):
|
||||
pat = re.compile(r'^( ' + re.escape(key) + r': ).*$')
|
||||
out, done = [], False
|
||||
for ln in block:
|
||||
if not done and pat.match(ln):
|
||||
out.append(f" {key}: {value}\n")
|
||||
done = True
|
||||
else:
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
block = set_field(block, "appVersion", f'"{chart_version}"')
|
||||
block = set_field(block, "created", f'"{created}"')
|
||||
block = set_field(block, "digest", digest)
|
||||
block = set_field(block, "version", chart_version)
|
||||
|
||||
# Update the chart download URL (distinct from the github.com source URL).
|
||||
out, done = [], False
|
||||
for ln in block:
|
||||
if not done and re.match(r'^ - https://wekan\.github\.io/charts/wekan-.*\.tgz\s*$', ln):
|
||||
out.append(f" - {url}\n")
|
||||
done = True
|
||||
else:
|
||||
out.append(ln)
|
||||
block = out
|
||||
|
||||
# Insert as the new newest (top) entry, right after the " wekan:" header.
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines[:start] + block + lines[start:])
|
||||
|
||||
print(f" Prepended chart {chart_version} to index.yaml (created {created}).")
|
||||
inside="$(TGZ="$TGZ" python3 - <<'PYEOF'
|
||||
import os, sys, tarfile, yaml
|
||||
with tarfile.open(os.environ["TGZ"]) as tar:
|
||||
members = [m for m in tar.getnames()
|
||||
if m.count("/") == 1 and m.endswith("/Chart.yaml")]
|
||||
if not members:
|
||||
sys.exit("no <chart>/Chart.yaml in the package")
|
||||
with tar.extractfile(members[0]) as fh:
|
||||
print(yaml.safe_load(fh.read()).get("version", ""))
|
||||
PYEOF
|
||||
)"
|
||||
if [ "$inside" != "$CHART_VERSION" ]; then
|
||||
echo "Error: $TGZ declares chart version '${inside}', not '${CHART_VERSION}'." >&2
|
||||
echo " The file name and its Chart.yaml disagree, so this package would be a" >&2
|
||||
echo " second copy of ${inside} under a misleading name - which is how" >&2
|
||||
echo " wekan-1.2.7.tgz and wekan-6.96.tgz came to exist. Not indexing it." >&2
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
|
||||
# The index is DERIVED from the packages, never edited in place.
|
||||
#
|
||||
# It used to be edited: the newest entry was copied, a few fields substituted,
|
||||
# and the result PREPENDED. Every one of the index's four defects came from that.
|
||||
# Re-running a release prepended a second entry for the same version instead of
|
||||
# replacing it (9.36.0 ended up with four, each with a different digest and the
|
||||
# same url, so a helm client could pick a digest matching no file). appVersion
|
||||
# was set to the CHART version rather than the WeKan version, on every entry. The
|
||||
# fields nobody substituted never changed, so every entry claimed the mongodb
|
||||
# subchart was 0.7.2 while the packages had moved to 0.7.6. And a release whose
|
||||
# charts job did not run left a hole nothing ever went back for - 146 packages
|
||||
# had no entry at all.
|
||||
#
|
||||
# Deriving it from the packages makes all four impossible rather than fixed:
|
||||
# one entry per package, its digest computed from the file, its fields read out
|
||||
# of the archive, and any package that was missed picked up on the next run.
|
||||
"$REPO_DIR/releases/reindex-charts.py" --write --charts-dir "$CHARTS_DIR"
|
||||
|
||||
# ── 4. Commit and push the gh-pages branch ───────────────────────────────────
|
||||
( cd "$CHARTS_DIR" && ./release2.sh "$VERSION" )
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 1080,
|
||||
appVersion = 1083,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "10.80~2026-08-10"),
|
||||
appMarketingVersion = (defaultText = "10.83~2026-08-11"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -222,6 +222,9 @@ import '/server/methods/lockoutSettings';
|
||||
import '/server/methods/migrateTextDatabase';
|
||||
import '/server/methods/repairBoardData';
|
||||
import '/server/methods/repairBrokenCards';
|
||||
// Removes the "Templates" container boards made for accounts that never used
|
||||
// them (pre-v10.00 signups). Dry run by default; see the method for why.
|
||||
import '/server/methods/cleanupTemplateContainers';
|
||||
import '/server/methods/systemStatus';
|
||||
import '/server/methods/positionHistory';
|
||||
import '/server/methods/sandstormMigration';
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Who is making this plain HTTP request?
|
||||
//
|
||||
// `Meteor.userId()` CANNOT ANSWER THAT. It reads the current DDP invocation's
|
||||
// environment, which exists inside a method or a publication and nowhere else. In
|
||||
// a WebApp/connect handler there is no such environment, so it does not return
|
||||
// "nobody" - it throws:
|
||||
//
|
||||
// Meteor.userId can only be invoked in method calls or publications.
|
||||
//
|
||||
// Two file routes called it anyway, and because both wrap their body in a
|
||||
// try/catch that turns any error into a 500, the failure looked like a broken
|
||||
// file rather than broken authentication:
|
||||
//
|
||||
// server/routes/avatarServer.js /cdn/storage/avatars/:fileName
|
||||
// server/routes/legacyAttachments.js
|
||||
//
|
||||
// That is why profile pictures came back as broken images after upgrading from an
|
||||
// old MongoDB snap: the stored `profile.avatarUrl` of a 6.x install is
|
||||
// `/cfs/files/avatars/<id>`, the legacy route redirects it to
|
||||
// `/cdn/storage/avatars/<id>`, and that route could never serve anybody. The
|
||||
// avatar files themselves migrated fine - the request for them was answered 500
|
||||
// before a byte was read.
|
||||
//
|
||||
// An HTTP request carries its identity in the request: a bearer token, an
|
||||
// X-Auth-Token header, an ?authToken= parameter, or the login cookie. This is
|
||||
// the same resolution server/routes/universalFileServer.js has always done -
|
||||
// lifted here so the routes that were guessing can share it rather than grow a
|
||||
// third copy.
|
||||
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
export function parseCookies(req) {
|
||||
const header = req && req.headers && req.headers.cookie;
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
try {
|
||||
out[decodeURIComponent(part.slice(0, idx).trim())] =
|
||||
decodeURIComponent(part.slice(idx + 1).trim());
|
||||
} catch (_) {
|
||||
// A cookie we cannot decode is not a reason to fail the request.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseQuery(req) {
|
||||
const out = {};
|
||||
const q = ((req && req.url) || '').split('?')[1] || '';
|
||||
if (!q) return out;
|
||||
for (const pair of q.split('&')) {
|
||||
if (!pair) continue;
|
||||
const [rawK, rawV] = pair.split('=');
|
||||
try {
|
||||
out[decodeURIComponent((rawK || '').trim())] =
|
||||
decodeURIComponent((rawV || '').trim());
|
||||
} catch (_) {
|
||||
// ditto
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The login token this request carries, or null. Priority order matches
|
||||
* universalFileServer's, so a request that works for an attachment works for an
|
||||
* avatar. */
|
||||
export function extractLoginToken(req) {
|
||||
const authz = req && req.headers &&
|
||||
(req.headers.authorization || req.headers.Authorization);
|
||||
if (authz && typeof authz === 'string') {
|
||||
const m = authz.match(/^Bearer\s+(.+)$/i);
|
||||
if (m && m[1]) return m[1].trim();
|
||||
}
|
||||
|
||||
const xAuth = req && req.headers &&
|
||||
(req.headers['x-auth-token'] || req.headers['X-Auth-Token']);
|
||||
if (xAuth && typeof xAuth === 'string') return xAuth.trim();
|
||||
|
||||
const q = parseQuery(req);
|
||||
if (q.authToken && typeof q.authToken === 'string') return q.authToken.trim();
|
||||
|
||||
const cookies = parseCookies(req);
|
||||
if (cookies.meteor_login_token) return cookies.meteor_login_token.trim();
|
||||
if (cookies.wekan_login_token) return cookies.wekan_login_token.trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** On Sandstorm the platform has already authorized the request and injects the
|
||||
* user id as a header; there is no Meteor login token in that model at all. */
|
||||
export function isSandstormRequest(req) {
|
||||
return !!(
|
||||
Meteor.settings &&
|
||||
Meteor.settings.public &&
|
||||
Meteor.settings.public.sandstorm &&
|
||||
req && req.headers && req.headers['x-sandstorm-user-id']
|
||||
);
|
||||
}
|
||||
|
||||
/** The user id behind this request, or null. Never throws: a caller deciding
|
||||
* whether to serve a file wants an answer, not an exception that its own
|
||||
* try/catch will turn into a 500. */
|
||||
export async function getUserIdFromRequest(req) {
|
||||
try {
|
||||
if (isSandstormRequest(req)) {
|
||||
return req.headers['x-sandstorm-user-id'];
|
||||
}
|
||||
const raw = extractLoginToken(req);
|
||||
if (!raw || typeof raw !== 'string' || raw.length < 10) return null;
|
||||
const hashed = Accounts._hashLoginToken(raw);
|
||||
const user = await Meteor.users.findOneAsync(
|
||||
{ 'services.resume.loginTokens.hashedToken': hashed },
|
||||
{ fields: { _id: 1 } },
|
||||
);
|
||||
return user ? user._id : null;
|
||||
} catch (error) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('requestUser: could not resolve the request user:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
parseCookies,
|
||||
parseQuery,
|
||||
extractLoginToken,
|
||||
isSandstormRequest,
|
||||
getUserIdFromRequest,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
// selectorGuard — refuse a client-supplied MongoDB selector that carries an
|
||||
// EXECUTION operator, and record who sent it.
|
||||
//
|
||||
// GHSA-phm4-4v26-j2vq (WhereBleed). A publication or method that takes a query
|
||||
// selector from the client and validates only its TYPE —
|
||||
//
|
||||
// check(query, Match.OneOf(Object, null));
|
||||
//
|
||||
// — has not validated it at all. A MongoDB selector is executable data: `$where`
|
||||
// makes the database run the caller's JavaScript once per document scanned, so
|
||||
// `{ $where: 'while(true){}' }` pins a database worker for as long as it likes,
|
||||
// repeatably, and `{ $where: 'sleep(2000) || true' }` is a document-inclusion
|
||||
// oracle whose truth value the caller controls. Reported against the Admin
|
||||
// Panel's people, org, team and translation publications and their companion
|
||||
// count/page methods — eight handlers, all taking the same shape of selector.
|
||||
//
|
||||
// The defence is NOT new here. classifySelector (models/lib/injectionDetect.js)
|
||||
// and hasWhere (models/lib/mongoSelectorSafety.js) were written for exactly this
|
||||
// and are wired into the card-window publication; the eight handlers simply never
|
||||
// called them. This module is that publication's own helper, lifted out
|
||||
// unchanged so there is ONE copy for every caller — the bug was eight places not
|
||||
// doing what one place did, and a second copy of the check would be the same
|
||||
// mistake waiting to happen again.
|
||||
//
|
||||
// Callers use the refusal cardsWindow.js already uses in production:
|
||||
//
|
||||
// const safe = selectorIsInjection(query, 'people') ? MATCH_NOTHING : query;
|
||||
//
|
||||
// MATCH_NOTHING is `{ _id: { $in: [] } }`: a selector that is valid, cheap and
|
||||
// matches no document, so a refused request returns an empty result instead of
|
||||
// throwing. Nothing an ordinary Admin Panel search, filter or page sends is
|
||||
// affected — none of it contains an execution operator.
|
||||
//
|
||||
// See docs/Security/Remediation/WeKan.md §12.6 for the canary, and
|
||||
// tests/selectorGuard.test.cjs for what counts as an injection.
|
||||
|
||||
import { tripCanary } from '/server/lib/canary';
|
||||
|
||||
const { hasWhere } = require('/models/lib/mongoSelectorSafety');
|
||||
const { classifySelector, injectionDetail } = require('/models/lib/injectionDetect');
|
||||
|
||||
// The refusal: a well-formed selector that cannot match anything. Frozen so a
|
||||
// caller cannot mutate the shared object into one that matches something.
|
||||
export const MATCH_NOTHING = Object.freeze({ _id: Object.freeze({ $in: Object.freeze([]) }) });
|
||||
|
||||
// True when `selector` is an attempt to make the database run something rather
|
||||
// than a query. `where` names the call site and only reaches the canary record,
|
||||
// so an operator can see WHICH surface was probed.
|
||||
export function selectorIsInjection(selector, where) {
|
||||
const verdict = classifySelector(selector);
|
||||
if (!verdict.injection && !hasWhere(selector)) return false;
|
||||
tripCanary('injection.nosql-selector', {
|
||||
detail: injectionDetail(
|
||||
verdict.injection ? verdict : { kind: 'execution', operators: ['$where'] },
|
||||
where,
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// The whole pattern in one call, since every one of the nine call sites wants
|
||||
// exactly this: hand back the selector, or a selector that matches nothing.
|
||||
export function safeSelector(selector, where) {
|
||||
return selectorIsInjection(selector, where) ? MATCH_NOTHING : selector;
|
||||
}
|
||||
|
||||
export default { MATCH_NOTHING, selectorIsInjection, safeSelector };
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { check, Match } from 'meteor/check';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import {
|
||||
templateContainersSelector,
|
||||
planTemplateContainerCleanup,
|
||||
} from '/models/lib/unusedTemplateContainers';
|
||||
|
||||
// Remove the "Templates" container boards that were created for accounts which
|
||||
// never used them.
|
||||
//
|
||||
// Before v10.00 every new account got one at signup whether or not the person
|
||||
// ever saved a template (#2339, #5850 made it lazy). Nothing ever removed the
|
||||
// ones already made, and on an instance that provisions accounts automatically
|
||||
// they come to dominate the boards collection: one reported instance has 14490
|
||||
// boards of which 13404 are template containers, for 9264 accounts of which 478
|
||||
// have ever logged in. Every query that touches boards carries that, and under
|
||||
// polling reactivity that is every poll.
|
||||
//
|
||||
// TWO THINGS THIS IS CAREFUL ABOUT, because it deletes boards.
|
||||
//
|
||||
// It only ever removes an EMPTY container: nothing saved into it, nothing put in
|
||||
// it directly, one member, not renamed, not starred, not archived by hand. If
|
||||
// somebody used it, it is theirs and it stays. The rule lives in
|
||||
// models/lib/unusedTemplateContainers.js, on its own, so it can be read and
|
||||
// tested without this file.
|
||||
//
|
||||
// And it does NOTHING unless asked twice. The default is a dry run that reports
|
||||
// what would go; `apply: true` is what deletes. An admin can therefore look at
|
||||
// the list first, which for thirteen thousand boards is the difference between a
|
||||
// cleanup and an accident.
|
||||
|
||||
const DEFAULT_TITLES = ['Templates', 'templates', 'Template Container'];
|
||||
|
||||
async function collectContainers(limit) {
|
||||
const boards = await Boards.find(templateContainersSelector(), {
|
||||
fields: { _id: 1, title: 1, type: 1, members: 1, archived: 1, starred: 1 },
|
||||
...(limit ? { limit } : {}),
|
||||
}).fetchAsync();
|
||||
|
||||
const entries = [];
|
||||
for (const board of boards) {
|
||||
// Counted rather than fetched: the question is only "is there anything in
|
||||
// it", and these collections are the large ones.
|
||||
const [templateCount, listCount, swimlaneCount, cardCount] = await Promise.all([
|
||||
Boards.find({
|
||||
type: { $in: ['template-board', 'template-list', 'template-card',
|
||||
'template-swimlane'] },
|
||||
'members.userId': { $exists: true },
|
||||
_id: { $ne: board._id },
|
||||
parentId: board._id,
|
||||
}).countAsync(),
|
||||
Lists.find({ boardId: board._id }).countAsync(),
|
||||
Swimlanes.find({ boardId: board._id }).countAsync(),
|
||||
Cards.find({ boardId: board._id }).countAsync(),
|
||||
]);
|
||||
entries.push({
|
||||
board,
|
||||
counts: { templateCount, listCount, swimlaneCount, cardCount },
|
||||
options: { defaultTitles: DEFAULT_TITLES },
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
// options: { apply: false, limit: 0 }
|
||||
async cleanupUnusedTemplateContainers(options = {}) {
|
||||
check(options, Match.Optional(Object));
|
||||
const apply = options.apply === true;
|
||||
const limit = Number.isInteger(options.limit) && options.limit > 0
|
||||
? options.limit
|
||||
: 0;
|
||||
|
||||
if (!this.userId || !(await ReactiveCache.getUser(this.userId))?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be an admin.');
|
||||
}
|
||||
|
||||
const entries = await collectContainers(limit);
|
||||
const plan = planTemplateContainerCleanup(entries);
|
||||
|
||||
if (!apply) {
|
||||
return {
|
||||
applied: false,
|
||||
scanned: entries.length,
|
||||
wouldRemove: plan.remove.length,
|
||||
kept: plan.keep.length,
|
||||
// A sample rather than thirteen thousand rows, and the reasons the kept
|
||||
// ones were kept - which is what tells an admin the rule is doing what
|
||||
// they think it is.
|
||||
removeSample: plan.remove.slice(0, 25),
|
||||
keepSample: plan.keep.slice(0, 25),
|
||||
};
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
const failed = [];
|
||||
for (const entry of plan.remove) {
|
||||
try {
|
||||
await Boards.removeAsync(entry.boardId);
|
||||
// The pointer on the user profile goes with it, or the next
|
||||
// ensureTemplatesBoard finds a board that is not there.
|
||||
await Meteor.users.updateAsync(
|
||||
{ 'profile.templatesBoardId': entry.boardId },
|
||||
{ $unset: { 'profile.templatesBoardId': '' } },
|
||||
{ multi: true },
|
||||
);
|
||||
removed += 1;
|
||||
} catch (error) {
|
||||
failed.push({ boardId: entry.boardId, error: String(error && error.message) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
scanned: entries.length,
|
||||
removed,
|
||||
kept: plan.keep.length,
|
||||
failed,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
import Org from '/models/org';
|
||||
import { ensureIndex } from '/server/lib/mongoStartup';
|
||||
import { Authentication } from '/server/authentication';
|
||||
@@ -290,7 +291,7 @@ Meteor.methods({
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
const cursor = await ReactiveCache.getOrgs(
|
||||
tenantAdmin.orgScopeSelector(user, query || {}), {}, true);
|
||||
tenantAdmin.orgScopeSelector(user, safeSelector(query || {}, 'getOrgsCollectionCount')), {}, true);
|
||||
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
import Team from '/models/team';
|
||||
import { ensureIndex } from '/server/lib/mongoStartup';
|
||||
import { Authentication } from '/server/authentication';
|
||||
@@ -254,7 +255,7 @@ Meteor.methods({
|
||||
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
const cursor = await ReactiveCache.getTeams(query || {}, {}, true);
|
||||
const cursor = await ReactiveCache.getTeams(safeSelector(query || {}, 'getTeamsCollectionCount'), {}, true);
|
||||
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import Translation from '/models/translation';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
import { ensureIndex } from '/server/lib/mongoStartup';
|
||||
|
||||
const getReactiveCache = () => require('/imports/reactiveCache').ReactiveCache;
|
||||
@@ -58,7 +59,7 @@ Meteor.methods({
|
||||
if (!(await getReactiveCache().getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
const cursor = await getReactiveCache().getTranslations(query || {}, {}, true);
|
||||
const cursor = await getReactiveCache().getTranslations(safeSelector(query || {}, 'getTranslationsCollectionCount'), {}, true);
|
||||
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
|
||||
},
|
||||
});
|
||||
|
||||
+35
-10
@@ -3,6 +3,7 @@ import { WebApp } from 'meteor/webapp';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Email } from 'meteor/email';
|
||||
import { check, Match } from 'meteor/check';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
import { EJSON } from 'meteor/ejson';
|
||||
import { Random } from 'meteor/random';
|
||||
import { CollectionHooks } from 'meteor/matb33:collection-hooks';
|
||||
@@ -77,7 +78,11 @@ import InviteToBoardRolesSettings from '/models/inviteToBoardRolesSettings';
|
||||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import Users, { allowedSortValues, allowedAllBoardsSortValues } from '/models/users';
|
||||
import { expiredNotificationActivityIds } from '/models/lib/notificationCleanup';
|
||||
import {
|
||||
expiredNotificationActivityIds,
|
||||
cappedNotifications,
|
||||
notificationCapFromEnv,
|
||||
} from '/models/lib/notificationCleanup';
|
||||
import { chooseInviteEmailLanguage } from '/models/lib/inviteEmailLanguage';
|
||||
import { paginateDomains } from '/models/lib/domainTablePage';
|
||||
import { orgsToAutoAddForEmail } from '/models/lib/orgAutoAddByDomain';
|
||||
@@ -1712,19 +1717,39 @@ const runNotificationCleanup = async function runNotificationCleanup() {
|
||||
const users = await ReactiveCache.getUsers({
|
||||
'profile.notifications': { $exists: true, $ne: [] },
|
||||
});
|
||||
// #6533: the cap is a BACKSTOP for the array the rule above cannot shrink.
|
||||
// Only READ notifications are ever pruned, so a user who does not clear their
|
||||
// tray grows `profile.notifications` without limit - and every new
|
||||
// notification is an `$addToSet` that rewrites that whole array inside the user
|
||||
// document. On FerretDB's SQLite backend, which has one writer, those rewrites
|
||||
// queue and start failing with SQLITE_BUSY while FerretDB burns CPU scanning
|
||||
// the array. See models/lib/notificationCleanup.js.
|
||||
const maxPerUser = notificationCapFromEnv(process.env);
|
||||
for (const user of users) {
|
||||
const notifications = user.profile && user.profile.notifications;
|
||||
const activityIds = expiredNotificationActivityIds(
|
||||
user.profile && user.profile.notifications,
|
||||
notifications,
|
||||
removeAge,
|
||||
now,
|
||||
);
|
||||
if (activityIds.length === 0) continue;
|
||||
// What the array would be after the $pull, so the cap is applied to the
|
||||
// result rather than to a length that is about to shrink anyway.
|
||||
const remaining = activityIds.length
|
||||
? (notifications || []).filter(n => !(n && activityIds.includes(n.activity)))
|
||||
: notifications;
|
||||
const kept = cappedNotifications(remaining, maxPerUser);
|
||||
if (activityIds.length === 0 && kept === null) continue;
|
||||
try {
|
||||
await Users.updateAsync(user._id, {
|
||||
$pull: {
|
||||
'profile.notifications': { activity: { $in: activityIds } },
|
||||
},
|
||||
});
|
||||
// ONE write per user either way. When the cap applies, `$set` replaces the
|
||||
// array outright - a `$pull` for the overflow would need the entries it is
|
||||
// removing listed, and the point is that there are too many of them.
|
||||
await Users.updateAsync(user._id, kept !== null
|
||||
? { $set: { 'profile.notifications': kept } }
|
||||
: {
|
||||
$pull: {
|
||||
'profile.notifications': { activity: { $in: activityIds } },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Notification cleanup: failed to prune notifications for user',
|
||||
@@ -2340,7 +2365,7 @@ Meteor.methods({
|
||||
}
|
||||
|
||||
const cursor = await ReactiveCache.getUsers(
|
||||
tenantAdmin.peopleScopeSelector(currentUser, query || {}), {}, true);
|
||||
tenantAdmin.peopleScopeSelector(currentUser, safeSelector(query || {}, 'getUsersCollectionCount')), {}, true);
|
||||
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
|
||||
},
|
||||
|
||||
@@ -2379,7 +2404,7 @@ Meteor.methods({
|
||||
// The same selector, the same sort and the same window as the publication -
|
||||
// anything else would name a different page than the one that was sent.
|
||||
const cursor = await ReactiveCache.getUsers(
|
||||
tenantAdmin.peopleScopeSelector(currentUser, query || {}),
|
||||
tenantAdmin.peopleScopeSelector(currentUser, safeSelector(query || {}, 'getPeoplePageIds')),
|
||||
{
|
||||
limit,
|
||||
skip: skip || 0,
|
||||
|
||||
@@ -2,27 +2,17 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { publishComposite } from 'meteor/reywood:publish-composite';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
const { hasWhere } = require('/models/lib/mongoSelectorSafety');
|
||||
const { classifySelector, injectionDetail } = require('/models/lib/injectionDetect');
|
||||
import { tripCanary } from '/server/lib/canary';
|
||||
|
||||
// A client-supplied selector is run against the database, so an execution
|
||||
// operator in one is not a query - it is an attempt to make the database run
|
||||
// something. The publication already refuses it; this names WHO tried, from
|
||||
// where, and what they sent (docs/Security/Remediation/WeKan.md §12.6).
|
||||
// Returns the same boolean the old hasWhere() call did, so both refusal sites
|
||||
// below are unchanged.
|
||||
function selectorIsInjection(selector, where) {
|
||||
const verdict = classifySelector(selector);
|
||||
if (!verdict.injection && !hasWhere(selector)) return false;
|
||||
tripCanary('injection.nosql-selector', {
|
||||
detail: injectionDetail(
|
||||
verdict.injection ? verdict : { kind: 'execution', operators: ['$where'] },
|
||||
where,
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// something. This publication refuses it and names WHO tried, from where, and
|
||||
// what they sent (docs/Security/Remediation/WeKan.md §12.6).
|
||||
//
|
||||
// The check used to live here as a local helper. GHSA-phm4-4v26-j2vq was eight
|
||||
// OTHER handlers taking the same shape of client-supplied selector and never
|
||||
// calling it, so it moved to /server/lib/selectorGuard - unchanged - and every
|
||||
// caller now shares the one copy. Leaving a second copy behind here would be the
|
||||
// same mistake set up to happen again.
|
||||
import { selectorIsInjection } from '/server/lib/selectorGuard';
|
||||
const {
|
||||
boardCardScope,
|
||||
assignedOnlyCardScope,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
|
||||
// Multitenancy option D (D.7): the site admin sees every Organization, a per-tenant
|
||||
// Global Admin sees the ones they administer. Same rule module as the people
|
||||
@@ -13,8 +14,13 @@ Meteor.publish('org', async function(query, limit, skip = 0) {
|
||||
let ret = [];
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
|
||||
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
|
||||
// nothing else, and a selector is executable data - `$where` makes the database
|
||||
// run the caller's JavaScript per document scanned. Refuse one that carries an
|
||||
// execution operator, with the same "match nothing" the card window uses.
|
||||
const safeQuery = safeSelector(query, 'org');
|
||||
if (tenantAdmin.canOpenAdminPanel(user)) {
|
||||
ret = await ReactiveCache.getOrgs(tenantAdmin.orgScopeSelector(user, query),
|
||||
ret = await ReactiveCache.getOrgs(tenantAdmin.orgScopeSelector(user, safeQuery),
|
||||
{
|
||||
limit,
|
||||
skip: skip || 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
|
||||
// Multitenancy option D (docs/Design/Multitenancy/Multitenancy.md, D.7): the site
|
||||
// admin still sees every user, and a PER-TENANT Global Admin sees the members of
|
||||
@@ -15,8 +16,13 @@ Meteor.publish('people', async function(query, limit, skip = 0) {
|
||||
let ret = [];
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
|
||||
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
|
||||
// nothing else, and a selector is executable data - `$where` makes the database
|
||||
// run the caller's JavaScript per document scanned. Refuse one that carries an
|
||||
// execution operator, with the same "match nothing" the card window uses.
|
||||
const safeQuery = safeSelector(query, 'people');
|
||||
if (tenantAdmin.canOpenAdminPanel(user)) {
|
||||
ret = await ReactiveCache.getUsers(tenantAdmin.peopleScopeSelector(user, query), {
|
||||
ret = await ReactiveCache.getUsers(tenantAdmin.peopleScopeSelector(user, safeQuery), {
|
||||
limit,
|
||||
skip: skip || 0,
|
||||
sort: { createdAt: -1 },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
|
||||
Meteor.publish('team', async function(query, limit, skip = 0) {
|
||||
check(query, Match.OneOf(Object, null));
|
||||
@@ -8,8 +9,13 @@ Meteor.publish('team', async function(query, limit, skip = 0) {
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
|
||||
let ret = [];
|
||||
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
|
||||
// nothing else, and a selector is executable data - `$where` makes the database
|
||||
// run the caller's JavaScript per document scanned. Refuse one that carries an
|
||||
// execution operator, with the same "match nothing" the card window uses.
|
||||
const safeQuery = safeSelector(query, 'team');
|
||||
if (user && user.isAdmin) {
|
||||
ret = await ReactiveCache.getTeams(query,
|
||||
ret = await ReactiveCache.getTeams(safeQuery,
|
||||
{
|
||||
limit,
|
||||
skip: skip || 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { safeSelector } from '/server/lib/selectorGuard';
|
||||
|
||||
// ONE page of custom translation strings (docs/Design/Page/Table.md): the limit and
|
||||
// the skip are applied server-side, so only the rows that are displayed ever reach
|
||||
@@ -13,8 +14,13 @@ Meteor.publish('translation', async function(query, limit, skip = 0) {
|
||||
let ret = [];
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
|
||||
// GHSA-phm4-4v26-j2vq: the check above validates the TYPE of the selector and
|
||||
// nothing else, and a selector is executable data - `$where` makes the database
|
||||
// run the caller's JavaScript per document scanned. Refuse one that carries an
|
||||
// execution operator, with the same "match nothing" the card window uses.
|
||||
const safeQuery = safeSelector(query, 'translation');
|
||||
if (user && user.isAdmin) {
|
||||
ret = await ReactiveCache.getTranslations(query,
|
||||
ret = await ReactiveCache.getTranslations(safeQuery,
|
||||
{
|
||||
limit,
|
||||
skip: skip || 0,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { getUserIdFromRequest, parseQuery } from '/server/lib/requestUser';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Avatars from '/models/avatars';
|
||||
import { fileStoreStrategyFactory } from '/models/avatars.server';
|
||||
@@ -44,6 +45,27 @@ async function serveLegacyAvatar(fileId, req, res) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// May a caller who is NOT signed in see this avatar?
|
||||
//
|
||||
// Only on a public board, and only for somebody who is on it. The client appends
|
||||
// ?boardId= to every avatar URL it renders for exactly this case - see the
|
||||
// avatarUrl helper in client/components/users/userAvatar.js, whose own comment
|
||||
// says "so public viewers can access avatars on public boards" - and this route
|
||||
// ignored the parameter, so a public board showed the missing-picture icon to
|
||||
// every visitor who was not logged in.
|
||||
//
|
||||
// The board has to be named AND public AND actually have the avatar's owner on
|
||||
// it. Without that last part, naming any public board would unlock any avatar on
|
||||
// the instance, which is not what a public board publishes.
|
||||
async function avatarIsOnAPublicBoard(req, avatar) {
|
||||
const boardId = parseQuery(req).boardId;
|
||||
if (!boardId || !avatar || !avatar.userId) {
|
||||
return false;
|
||||
}
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
return !!(board && board.isPublic() && board.hasMember(avatar.userId));
|
||||
}
|
||||
|
||||
// Handle avatar file downloads
|
||||
WebApp.handlers.use('/cdn/storage/avatars/:fileName', async (req, res, next) => {
|
||||
if (req.method !== 'GET') {
|
||||
@@ -81,9 +103,20 @@ WebApp.handlers.use('/cdn/storage/avatars/:fileName', async (req, res, next) =>
|
||||
}
|
||||
|
||||
// Check if user has permission to view this avatar
|
||||
// For avatars, we allow viewing by any logged-in user
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
// For avatars, we allow viewing by any logged-in user.
|
||||
//
|
||||
// Resolved FROM THE REQUEST. This was `Meteor.userId()`, which reads the
|
||||
// current DDP invocation's environment - it exists in a method or a
|
||||
// publication and not in a WebApp handler, where it THROWS
|
||||
// ("Meteor.userId can only be invoked in method calls or publications").
|
||||
// The catch at the bottom of this handler turned that into a 500, so no
|
||||
// avatar served through this route ever reached anybody, and it looked like
|
||||
// a broken image rather than broken authentication. That is what a 6.x
|
||||
// upgrade shows as lost profile pictures: `/cfs/files/avatars/<id>` is
|
||||
// redirected here, and here is where it died - the migrated file itself was
|
||||
// fine.
|
||||
const userId = await getUserIdFromRequest(req);
|
||||
if (!userId && !(await avatarIsOnAPublicBoard(req, avatar))) {
|
||||
res.writeHead(401);
|
||||
res.end('Authentication required');
|
||||
return;
|
||||
@@ -153,8 +186,13 @@ WebApp.handlers.use('/cfs/files/avatars/:fileName', async (req, res, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a legacy avatar — redirect to the new avatar URL format.
|
||||
const newUrl = `/cdn/storage/avatars/${fileName}`;
|
||||
// Not a legacy avatar — redirect to the new avatar URL format, KEEPING the
|
||||
// query string. A redirect that drops it drops ?boardId=, which is how a
|
||||
// visitor who is not signed in is allowed to see an avatar on a public board
|
||||
// at all - and this is the redirect every migrated 6.x avatar URL goes
|
||||
// through, so losing it there would 401 exactly the installs this fixes.
|
||||
const query = (req.url || '').split('?')[1];
|
||||
const newUrl = `/cdn/storage/avatars/${fileName}${query ? `?${query}` : ''}`;
|
||||
res.writeHead(301, { 'Location': newUrl });
|
||||
res.end();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { getUserIdFromRequest } from '/server/lib/requestUser';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
|
||||
const { sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
|
||||
@@ -88,7 +89,9 @@ if (Meteor.isServer) {
|
||||
}
|
||||
|
||||
// Check if user has permission to download
|
||||
const userId = Meteor.userId();
|
||||
// From the REQUEST, not Meteor.userId(): this is a WebApp handler, where
|
||||
// that throws rather than returning null (see server/lib/requestUser.js).
|
||||
const userId = await getUserIdFromRequest(req);
|
||||
if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
|
||||
res.writeHead(403);
|
||||
res.end('Access denied');
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# ferretdb-migration-stale — exit 0 if the completed MongoDB -> FerretDB migration
|
||||
# is OLDER than the MongoDB data it was made from, i.e. MongoDB has been written
|
||||
# to since. Exit 1 when it is current, when there is no completed migration, or
|
||||
# when it cannot be told.
|
||||
#
|
||||
# WHY THIS EXISTS (wekan/wekan#6583: "Missing Data after Upgrade from v6").
|
||||
#
|
||||
# The migration copies MongoDB into FerretDB's SQLite and writes
|
||||
# .migration-to-ferretdb-done. That copy is a SNAPSHOT, taken once. Nothing keeps
|
||||
# it in step afterwards - so if MongoDB goes on being used after a migration
|
||||
# completed, the SQLite beside it silently falls behind, by as long as that goes
|
||||
# on.
|
||||
#
|
||||
# Which is exactly what a `snap revert` produces. Revert to a revision that runs
|
||||
# mongod and WeKan carries on writing to MongoDB, for days or weeks, while the
|
||||
# finished FerretDB copy sits frozen at the date it was made. Refresh forward
|
||||
# again and mongodb-control saw a marker plus a non-empty SQLite, called that a
|
||||
# completed migration and switched the snap to it - serving the frozen snapshot.
|
||||
# Every board, card and comment written since the migration was still on disk in
|
||||
# MongoDB and simply not being shown, which reads exactly like the reported
|
||||
# "changes made in recent days were lost ... the state of the data from days ago".
|
||||
#
|
||||
# The database directory is $SNAP_COMMON, which is shared across revisions and NOT
|
||||
# rolled back by `snap revert` - so the data was never in danger. What was wrong
|
||||
# was WHICH of the two copies got served, and nothing compared their ages.
|
||||
#
|
||||
# HOW IT DECIDES. mongod rewrites its WiredTiger files whenever it commits, so the
|
||||
# newest mtime under the data directory is when MongoDB was last written to. If
|
||||
# that is later than when the migration finished, the migration is stale.
|
||||
#
|
||||
# The margin exists because the migration itself touches MongoDB on the way out:
|
||||
# it starts a temporary mongod to read from and stops it again, and that shutdown
|
||||
# writes to the data directory a moment after the marker appears. A few minutes
|
||||
# of slack tells that apart from the case this is for, which is days or weeks.
|
||||
#
|
||||
# Usage: ferretdb-migration-stale [data-dir] [sqlite-dir]
|
||||
# Env: FERRETDB_STALE_MARGIN_SECONDS default 600
|
||||
|
||||
DBPATH="${1:-${SNAP_COMMON:-/var/snap/${SNAP_INSTANCE_NAME:-${SNAP_NAME:-wekan}}/common}}"
|
||||
SQLITE_DIR="${2:-$DBPATH/files/db}"
|
||||
MARKER="$DBPATH/.migration-to-ferretdb-done"
|
||||
MARGIN="${FERRETDB_STALE_MARGIN_SECONDS:-600}"
|
||||
|
||||
# No completed migration means nothing to be stale about. Both halves are needed:
|
||||
# the marker is also written when the migration finishes with no data at all.
|
||||
[ -f "$MARKER" ] || exit 1
|
||||
have_sqlite=1
|
||||
for f in "$SQLITE_DIR"/*.sqlite; do
|
||||
if [ -f "$f" ] && [ -s "$f" ]; then have_sqlite=0; break; fi
|
||||
done
|
||||
[ "$have_sqlite" -eq 0 ] || exit 1
|
||||
|
||||
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo ""; }
|
||||
|
||||
marker_at="$(mtime "$MARKER")"
|
||||
case "$marker_at" in ''|*[!0-9]*) exit 1 ;; esac
|
||||
|
||||
# The MongoDB data files, and only those: a log or a pid file in the same
|
||||
# directory is touched by starting the snap and says nothing about whether the
|
||||
# DATABASE was written to.
|
||||
newest=0
|
||||
newest_file=""
|
||||
for f in "$DBPATH"/WiredTiger "$DBPATH"/WiredTiger.wt "$DBPATH"/WiredTiger.turtle \
|
||||
"$DBPATH"/_mdb_catalog.wt "$DBPATH"/sizeStorer.wt \
|
||||
"$DBPATH"/collection-*.wt "$DBPATH"/index-*.wt \
|
||||
"$DBPATH"/journal/WiredTigerLog.* ; do
|
||||
[ -f "$f" ] || continue
|
||||
m="$(mtime "$f")"
|
||||
case "$m" in ''|*[!0-9]*) continue ;; esac
|
||||
if [ "$m" -gt "$newest" ]; then newest="$m"; newest_file="$f"; fi
|
||||
done
|
||||
|
||||
# No MongoDB data at all: a FerretDB-only install, nothing to be behind.
|
||||
[ "$newest" -gt 0 ] || exit 1
|
||||
|
||||
if [ "$newest" -gt "$((marker_at + MARGIN))" ]; then
|
||||
behind=$(( (newest - marker_at) / 86400 ))
|
||||
echo "The FerretDB migration finished $(date -d "@$marker_at" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "at $marker_at')")," >&2
|
||||
echo "but MongoDB was written to as recently as $(date -d "@$newest" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$newest") ($(basename "$newest_file"))" >&2
|
||||
echo "- about ${behind} day(s) later. The migrated copy is BEHIND the MongoDB data." >&2
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
@@ -355,6 +355,11 @@ mongod_says_data_too_old() {
|
||||
stop_data_too_old() {
|
||||
local can; can="$(data_too_old_can_read)"
|
||||
printf '%s\n' "$can" > "$SNAP_COMMON/.mongodb-data-too-old"
|
||||
# #6471: record WHICH revision concluded this. The conclusion is about the
|
||||
# readers this snap has, and a later one may have more - the 4.2 reader was
|
||||
# added for exactly these databases. A newer revision therefore ignores this
|
||||
# marker and tries again (snap-src/bin/stale-marker).
|
||||
bash "$SNAP/bin/stale-marker" --stamp "$SNAP_COMMON/.mongodb-data-too-old"
|
||||
log "The database files were made by an older MongoDB${can:+ (MongoDB $can or earlier can still read them)}."
|
||||
log "This snap can read a MongoDB 6/7 database (mongod 7), a MongoDB 4.0/4.2 one (mongod 4.2) and a MongoDB 3.2 one (the migratemongo tools); these files are none of those, and no retry can change that."
|
||||
log "NOTHING has been changed: your data, attachments and avatars are untouched."
|
||||
|
||||
@@ -202,6 +202,18 @@ trap cleanup EXIT
|
||||
# be restarted by snapd, re-trigger the migration, and loop - so do not start it at
|
||||
# all. wekan-control serves the explanatory page; the data is untouched.
|
||||
MONGODB_DATA_TOO_OLD="${SNAP_COMMON}/.mongodb-data-too-old"
|
||||
# #6471: a marker written by an OLDER revision is not an answer about this one.
|
||||
# It records that no reader in THAT snap could open the data - and v10.81 added a
|
||||
# third reader (mongod 4.2) for exactly these databases. An instance that had
|
||||
# already failed on v10.79/v10.80 carried the marker, so the version with the fix
|
||||
# refused to start mongod, never ran the migration, and printed none of the new
|
||||
# messages: upgrading changed nothing, which is what the reporter saw. Same
|
||||
# revision, same refusal; new revision, new chance.
|
||||
if [ -f "$MONGODB_DATA_TOO_OLD" ] && bash "$SNAP/bin/stale-marker" "$MONGODB_DATA_TOO_OLD"; then
|
||||
echo "The 'database too old' marker was written by a different snap revision; this one may have readers it did not. Trying again."
|
||||
rm -f "$MONGODB_DATA_TOO_OLD" "${MONGODB_DATA_TOO_OLD}.revision"
|
||||
rm -f "${SNAP_COMMON}/.mongod-start-failures"
|
||||
fi
|
||||
if [ -f "$MONGODB_DATA_TOO_OLD" ]; then
|
||||
_can="$(cat "$MONGODB_DATA_TOO_OLD" 2>/dev/null)"
|
||||
echo "MongoDB will NOT be started: the database files were made by an older MongoDB${_can:+ (MongoDB $_can or earlier can still read them)},"
|
||||
@@ -220,6 +232,20 @@ MONGOD_FAIL_COUNT_FILE="${SNAP_COMMON}/.mongod-start-failures"
|
||||
# NOT switched to.) Returns 1 when there is no completed migration.
|
||||
switch_to_completed_ferretdb() {
|
||||
local svc="${SNAP_INSTANCE_NAME:-${SNAP_NAME:-wekan}}"
|
||||
# #6583: "completed" is not the same as "up to date". The migration is a
|
||||
# SNAPSHOT and nothing keeps it in step, so if MongoDB went on being written
|
||||
# to afterwards - which is what a `snap revert` to a mongod revision produces,
|
||||
# for as long as the revert lasts - the SQLite beside it is behind by that
|
||||
# much. Switching to it then serves data from the day the migration ran and
|
||||
# hides everything since, which is not a lost database (it is all still in
|
||||
# $SNAP_COMMON, which `snap revert` does not roll back) but is indistinguishable
|
||||
# from one to the person looking at it.
|
||||
if bash "$SNAP/bin/ferretdb-migration-stale" "$SNAP_COMMON" 2>&1; then
|
||||
echo "NOT switching to FerretDB: the migrated copy is OLDER than the MongoDB data."
|
||||
echo "MongoDB keeps serving, so the newest data is the data you see. To move to"
|
||||
echo "FerretDB, migrate again so the copy is current: snap run wekan.migrate"
|
||||
return 1
|
||||
fi
|
||||
if [ -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
|
||||
bash "$SNAP/bin/ferretdb-has-data" "$SNAP_COMMON/files/db" 2>/dev/null; then
|
||||
echo "A completed FerretDB migration exists; switching to database=ferretdb."
|
||||
@@ -246,18 +272,54 @@ handle_mongod_start_failure() {
|
||||
|
||||
switch_to_completed_ferretdb || true
|
||||
|
||||
# #6471: the count belongs to a REVISION, not to the installation. Three
|
||||
# failures is loop protection - it stops migration<->mongod restarting each
|
||||
# other forever - but it was never reset, so an instance that had failed more
|
||||
# than three times on an older snap would not even attempt the migration on a
|
||||
# newer one that could have completed it. A different revision starts at zero.
|
||||
local fails=0
|
||||
[ -f "$MONGOD_FAIL_COUNT_FILE" ] && fails="$(cat "$MONGOD_FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
|
||||
if [ -f "$MONGOD_FAIL_COUNT_FILE" ] && \
|
||||
! bash "$SNAP/bin/stale-marker" "$MONGOD_FAIL_COUNT_FILE"; then
|
||||
fails="$(cat "$MONGOD_FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
|
||||
fi
|
||||
case "$fails" in (*[!0-9]*|'') fails=0;; esac
|
||||
fails=$((fails + 1))
|
||||
echo "$fails" > "$MONGOD_FAIL_COUNT_FILE"
|
||||
bash "$SNAP/bin/stale-marker" --stamp "$MONGOD_FAIL_COUNT_FILE"
|
||||
|
||||
# MongoDB data that this mongod cannot serve: the only way forward is the
|
||||
# MongoDB → FerretDB migration. Re-enable and run it now (it RESUMES from its
|
||||
# checkpoint if one exists), unless it keeps failing too (counter guards
|
||||
# against a migration↔mongod restart loop).
|
||||
if [ "$fails" -le 3 ] && [ ! -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
|
||||
# ...or a migration that COMPLETED but has since fallen behind the MongoDB data
|
||||
# (#6583). mongod cannot start, so keeping MongoDB is not on offer here and the
|
||||
# only two outcomes are a FRESH copy or a stale one. Migrate again: the
|
||||
# migration reads the data with its own temporary mongod and the 4.2/3.2
|
||||
# readers, none of which is the mongod that just failed, so it can still reach
|
||||
# everything written since. Serving the stale copy instead would show data from
|
||||
# the day it ran and say nothing about it.
|
||||
local stale_migration=1
|
||||
if [ -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
|
||||
bash "$SNAP/bin/ferretdb-migration-stale" "$SNAP_COMMON" 2>/dev/null; then
|
||||
stale_migration=0
|
||||
fi
|
||||
if [ "$fails" -le 3 ] && \
|
||||
{ [ ! -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] || [ "$stale_migration" -eq 0 ]; } && \
|
||||
{ [ -f "$SNAP_COMMON/WiredTiger" ] || ls "$SNAP_COMMON"/collection-*.wt >/dev/null 2>&1; }; then
|
||||
if [ "$stale_migration" -eq 0 ]; then
|
||||
# A stale migration has to be RESTARTED, not resumed. migration-control
|
||||
# exits immediately while the marker is there ("already handled"), and
|
||||
# the checkpoint plus the old SQLite describe a finished copy of
|
||||
# last month's data - resuming onto that would keep every row the
|
||||
# revert period added out of it. Clear the three things that say
|
||||
# "already done" so the migration reads MongoDB again from the start.
|
||||
# The MongoDB data itself is never touched, here or by the migration.
|
||||
echo "The completed FerretDB migration is older than the MongoDB data; migrating again from scratch so nothing written since is left out."
|
||||
rm -f "$SNAP_COMMON/.migration-to-ferretdb-done"
|
||||
rm -f "$SNAP_COMMON/migration-progress.json"
|
||||
rm -f "$SNAP_COMMON/.attachments-files-v2-done"
|
||||
rm -rf "${SNAP_COMMON:?}/files/db/"* 2>/dev/null || true
|
||||
fi
|
||||
echo "mongod cannot serve this data; running the MongoDB -> FerretDB migration instead (attempt ${fails}/3)."
|
||||
snapctl set migrate=on 2>/dev/null || true
|
||||
rm -f "${SNAP_COMMON}/mongodb.pid"
|
||||
@@ -353,7 +415,7 @@ until "$DB_EVAL" ping "$_RS_URL" >/dev/null 2>&1; do
|
||||
done
|
||||
# mongod is answering: clear the start-failure counter (see
|
||||
# handle_mongod_start_failure) so past failures don't count against future ones.
|
||||
[ $_wait -lt 90 ] && rm -f "$MONGOD_FAIL_COUNT_FILE"
|
||||
[ $_wait -lt 90 ] && rm -f "$MONGOD_FAIL_COUNT_FILE" "${MONGOD_FAIL_COUNT_FILE}.revision"
|
||||
|
||||
# Check replica set status and current member host (prints OK:<host> or ERROR:<msg>)
|
||||
_rs_check=$("$DB_EVAL" rs-conf-host "$_RS_URL" 2>/dev/null)
|
||||
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# stale-marker — is a "give up" marker one that a DIFFERENT snap revision wrote?
|
||||
#
|
||||
# Exit 0 when the marker exists and was written by a revision other than the one
|
||||
# running now (so it is stale and should be ignored), 1 otherwise.
|
||||
#
|
||||
# WHY (wekan/wekan#6471). Two markers in $SNAP_COMMON stop the snap doing work:
|
||||
#
|
||||
# .mongodb-data-too-old written when no reader in the snap could open the
|
||||
# MongoDB data. mongodb-control then refuses to start
|
||||
# mongod at all and wekan-control serves an
|
||||
# explanatory page instead of WeKan.
|
||||
# .mongod-start-failures a counter; after three failures mongodb-control
|
||||
# stops re-running the MongoDB -> FerretDB migration,
|
||||
# so a migration<->mongod restart loop cannot form.
|
||||
#
|
||||
# Both are right, and both were PERMANENT. That is the bug: each records a
|
||||
# conclusion about what THIS SNAP can do, and a later revision can do more. The
|
||||
# 4.2 reader was added in v10.81 for exactly the databases the marker is written
|
||||
# for - but an instance that had already failed on v10.79 or v10.80 carried a
|
||||
# marker saying "no reader can open this", and the counter had long passed three,
|
||||
# so v10.81 never started mongod, never ran the migration, and printed none of
|
||||
# the new messages. The reporter upgraded to the version with the fix and saw no
|
||||
# change at all, which is exactly what a permanent latch looks like from outside:
|
||||
#
|
||||
# Database selection: setting='mongodb' ferretdb_has_data=false mongodb_has_data=true
|
||||
# Waiting for MongoDB replica set primary...
|
||||
# MongoDB not ready yet, retrying in 5 seconds...
|
||||
#
|
||||
# A NEW REVISION IS A NEW CHANCE. Every marker records the revision that wrote
|
||||
# it; when the running revision differs, the marker is stale and the snap tries
|
||||
# again with whatever it can do now. Within one revision nothing changes: the
|
||||
# loop protection and the "do not start mongod on data it cannot read" refusal
|
||||
# both still hold, because the revision matches.
|
||||
#
|
||||
# The revision is kept in a COMPANION file (<marker>.revision) rather than inside
|
||||
# the marker, because .mongodb-data-too-old's contents are read and displayed by
|
||||
# wekan-control and wekan-maintenance-page.mjs, and this must not change what
|
||||
# they show.
|
||||
#
|
||||
# Usage:
|
||||
# stale-marker <marker-path> # exit 0 if stale (written elsewhere)
|
||||
# stale-marker --stamp <marker-path> # record the current revision for it
|
||||
#
|
||||
# With no SNAP_REVISION - running outside a snap - nothing is ever called stale:
|
||||
# not knowing the revision is not evidence that it changed.
|
||||
|
||||
MARKER="${2:-$1}"
|
||||
[ -n "$MARKER" ] || exit 1
|
||||
REV_FILE="${MARKER}.revision"
|
||||
NOW="${SNAP_REVISION:-}"
|
||||
|
||||
if [ "$1" = "--stamp" ]; then
|
||||
[ -n "$NOW" ] || exit 0
|
||||
printf '%s\n' "$NOW" > "$REV_FILE" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -f "$MARKER" ] || exit 1
|
||||
[ -n "$NOW" ] || exit 1
|
||||
|
||||
# No companion file: the marker predates this mechanism, so it was written by an
|
||||
# older revision by definition - which is the case this exists for.
|
||||
[ -f "$REV_FILE" ] || exit 0
|
||||
|
||||
WROTE="$(cat "$REV_FILE" 2>/dev/null)"
|
||||
[ "$WROTE" = "$NOW" ] && exit 1
|
||||
exit 0
|
||||
@@ -30,6 +30,15 @@ CPU_EXEC="$SNAP/bin/cpu-exec"
|
||||
# reason only in `snap logs`; serve a page that says what happened, that nothing was
|
||||
# changed, and what the two ways forward are. Cleared by removing the marker (or by
|
||||
# reverting the snap, which is one of the two).
|
||||
# #6471: a marker from a DIFFERENT revision is not an answer about this one, so
|
||||
# it must not keep the explanatory page up either. mongodb-control removes it and
|
||||
# retries; this service can start first, so it makes the same judgement rather
|
||||
# than serving "your data is too old for this snap" on a snap that has not tried.
|
||||
if [ -f "$SNAP_COMMON/.mongodb-data-too-old" ] && \
|
||||
bash "$SNAP/bin/stale-marker" "$SNAP_COMMON/.mongodb-data-too-old"; then
|
||||
echo "The 'database too old' marker was written by a different snap revision; ignoring it so this one can try."
|
||||
rm -f "$SNAP_COMMON/.mongodb-data-too-old" "$SNAP_COMMON/.mongodb-data-too-old.revision"
|
||||
fi
|
||||
if [ -f "$SNAP_COMMON/.mongodb-data-too-old" ]; then
|
||||
echo "DATABASE TOO OLD FOR THIS SNAP (.mongodb-data-too-old present): serving the explanatory page instead of WeKan."
|
||||
export PORT="${PORT:-80}"
|
||||
@@ -76,6 +85,34 @@ if [ "ferretdb" = "$DATABASE" ] && [ "$ferretdb_has_data" != "true" ] && [ "$mon
|
||||
snapctl set database=mongodb 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# SAFETY, the second half (#6583): never serve a FerretDB copy that is OLDER than the
|
||||
# MongoDB data sitting beside it. The guard above catches an EMPTY FerretDB; this
|
||||
# catches a FULL but out-of-date one, which looks worse - WeKan comes up, boards and
|
||||
# cards are all there, and only the last weeks of work are missing, so it reads as
|
||||
# data loss rather than as a misconfiguration.
|
||||
#
|
||||
# It gets that way because the migration is a snapshot and nothing keeps it in step.
|
||||
# `snap revert` to a revision that runs mongod, carry on using WeKan for a month, and
|
||||
# the finished SQLite is a month behind; refresh forward and it would be served.
|
||||
# Nothing is lost either way - $SNAP_COMMON is shared across revisions and is not
|
||||
# rolled back by a revert, so the MongoDB data is still there - so the right move is
|
||||
# simply to serve the newer of the two and say why.
|
||||
if [ "ferretdb" = "$DATABASE" ] && [ "$mongodb_has_data" = "true" ] && \
|
||||
bash "$SNAP/bin/ferretdb-migration-stale" "$SNAP_COMMON" 2>/dev/null; then
|
||||
echo "========================================================================"
|
||||
echo "WeKan: the FerretDB database in $FERRETDB_SQLITE_DIR was migrated from MongoDB,"
|
||||
echo "but the MongoDB data has been written to SINCE that migration - so the migrated"
|
||||
echo "copy is missing everything added after it, and serving it would look like those"
|
||||
echo "changes were lost. They are not: they are in the MongoDB data in $SNAP_COMMON,"
|
||||
echo "which 'snap revert' does not roll back."
|
||||
echo ""
|
||||
echo "Staying on database=mongodb, which has the newest data. To move to FerretDB,"
|
||||
echo "migrate again so the copy is current: snap run ${WEKAN_SNAP_SVC:-wekan}.migrate"
|
||||
echo "========================================================================"
|
||||
export DATABASE="mongodb"
|
||||
snapctl set database=mongodb 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Bring up the chosen database service and stop the other, so exactly one runs on the
|
||||
# shared port and WeKan does not wait forever for a stopped service. snapctl start --enable
|
||||
# = snap enable + snap start; targets the snap INSTANCE name so parallel snaps hit their own.
|
||||
|
||||
+65
-9
@@ -19,7 +19,7 @@
|
||||
# base: core24 (not core26) · no build-base: devel · grade: stable
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: wekan
|
||||
version: '10.80'
|
||||
version: '10.83'
|
||||
base: core24
|
||||
confinement: strict
|
||||
grade: stable
|
||||
@@ -380,6 +380,33 @@ parts:
|
||||
override-build: |
|
||||
set -eu
|
||||
V=4.2.25
|
||||
|
||||
# The directory is created FIRST, before anything can decide to skip.
|
||||
# `stage:` and `prime:` below name `mongo42` explicitly, and a filter
|
||||
# that names a path the part did not produce is not ignored - snapcraft
|
||||
# stops the whole build on it:
|
||||
#
|
||||
# Staging mongo42
|
||||
# Failed to copy '/build/.../parts/mongo42/install/mongo42':
|
||||
# no such file or directory.
|
||||
# Build failed
|
||||
#
|
||||
# That is what failed EVERY Launchpad snap in v10.80 - armhf, s390x,
|
||||
# ppc64el and riscv64, three attempts each - because this part is
|
||||
# amd64/arm64 only and every other architecture took the `exit 0`
|
||||
# below, leaving nothing to stage. The part was meant to be optional
|
||||
# ("OPTIONAL BY DESIGN", above) and on those arches it silently was
|
||||
# not: it took the entire snap down with it, on the four
|
||||
# architectures that have no native runner and can only be built
|
||||
# here.
|
||||
#
|
||||
# An empty directory stages fine and primes to a few bytes, and
|
||||
# migration-control looks for the BINARY inside it, which is still
|
||||
# not there - so "no MongoDB 4.2 to migrate from" stays exactly as
|
||||
# true as it was, without being fatal.
|
||||
dest="$SNAPCRAFT_PART_INSTALL/mongo42"
|
||||
mkdir -p "$dest/bin" "$dest/lib"
|
||||
|
||||
case "${CRAFT_ARCH_BUILD_FOR}" in
|
||||
amd64) MARCH=x86_64 ; DEBARCH=amd64 ;;
|
||||
arm64) MARCH=aarch64 ; DEBARCH=arm64 ;;
|
||||
@@ -389,8 +416,6 @@ parts:
|
||||
;;
|
||||
esac
|
||||
|
||||
dest="$SNAPCRAFT_PART_INSTALL/mongo42"
|
||||
mkdir -p "$dest/bin" "$dest/lib"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
@@ -446,7 +471,13 @@ parts:
|
||||
done
|
||||
if [ "$got_ssl" != yes ]; then
|
||||
echo "mongo42: could not obtain OpenSSL 1.1 for ${DEBARCH}; mongod 4.2 cannot run without it, so it is not staged."
|
||||
rm -rf "$dest"
|
||||
# The CONTENTS go, the DIRECTORY stays - `stage: mongo42` below
|
||||
# names it, and snapcraft ends the whole build on a filter whose
|
||||
# path is missing (see the comment at the top of override-build).
|
||||
# An empty directory stages to nothing and migration-control
|
||||
# guards every use on `-x $M42/bin/mongod`, so "no 4.2 reader" is
|
||||
# still exactly what this means.
|
||||
rm -rf "${dest:?}"/* 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -455,7 +486,8 @@ parts:
|
||||
# already down, so the build asks it here.
|
||||
if ! LD_LIBRARY_PATH="$dest/lib" "$dest/bin/mongod" --version >/dev/null 2>&1; then
|
||||
echo "mongo42: the staged mongod 4.2 does not run in this environment; removing it rather than shipping something that cannot start."
|
||||
rm -rf "$dest"
|
||||
# Contents only, directory kept - same reason as above.
|
||||
rm -rf "${dest:?}"/* 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
echo "mongo42: staged $("$dest/bin/mongod" --version 2>/dev/null | head -1 || echo "mongod $V") for ${CRAFT_ARCH_BUILD_FOR}."
|
||||
@@ -496,18 +528,18 @@ parts:
|
||||
armhf) WEKAN_ARCH="armhf" ;;
|
||||
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
|
||||
esac
|
||||
WEKAN_ZIP="wekan-10.80-${WEKAN_ARCH}.zip"
|
||||
WEKAN_ZIP="wekan-10.83-${WEKAN_ARCH}.zip"
|
||||
echo "Cleaning environment first"
|
||||
rm -rf .build
|
||||
mkdir .build
|
||||
cd .build
|
||||
#wget https://github.com/wekan/wekan/releases/download/v10.80/wekan-10.80-amd64.zip
|
||||
#wget https://github.com/wekan/wekan/releases/download/v10.83/wekan-10.83-amd64.zip
|
||||
# Retry: a release asset can 404 for a short while right after it is
|
||||
# uploaded (CDN/propagation lag), and a plain wget treated that as
|
||||
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
|
||||
# missing bundle still fails after the tries are spent.
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
|
||||
"https://github.com/wekan/wekan/releases/download/v10.80/${WEKAN_ZIP}"
|
||||
"https://github.com/wekan/wekan/releases/download/v10.83/${WEKAN_ZIP}"
|
||||
unzip "${WEKAN_ZIP}"
|
||||
rm "${WEKAN_ZIP}"
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
@@ -526,7 +558,31 @@ parts:
|
||||
fi
|
||||
cd bundle/programs/server
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
# Retried, because on Launchpad this is the step that loses the race
|
||||
# with the network. Every request goes through the build farm's proxy,
|
||||
# and one tarball out of a couple of thousand can simply be cut:
|
||||
#
|
||||
# npm ERR! code ECONNRESET
|
||||
# npm ERR! network request to https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz
|
||||
# failed, reason: socket hang up
|
||||
# 'override-build' in part 'wekan' failed with code 1.
|
||||
#
|
||||
# which is what ended the riscv64 snap in v10.80 after it had already
|
||||
# built for half an hour. Nothing is wrong with the build when that
|
||||
# happens and asking again fixes it; npm's own --fetch-retries only
|
||||
# covers the request, not a socket dropped mid-stream. Three tries,
|
||||
# then it is a real failure and the log says so.
|
||||
for attempt in 1 2 3; do
|
||||
if npm install; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" = 3 ]; then
|
||||
echo "npm install failed three times in a row - this is not the network." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "npm install failed (attempt ${attempt}/3); retrying in 15s."
|
||||
sleep 15
|
||||
done
|
||||
cd ../../../..
|
||||
# Copy to Snap. Use the bundle's OWN arch-correct Node.js: the
|
||||
# wekan-<arch>.zip already ships the right node for this platform
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
'use strict';
|
||||
|
||||
// #6488, reported again against 10.77 after being closed three times:
|
||||
//
|
||||
// "at smartphone, at All Boards page, it is not possible to scroll down to
|
||||
// see remaining of boards. check that scrolling down works at all left menu
|
||||
// options right pages, and at mobile and desktop mode. also, some board icons
|
||||
// are too high, they are maybe 4x normal board icon height or more."
|
||||
// "board icons are too close to each other. there should be small amount of
|
||||
// space between board icons."
|
||||
//
|
||||
// Four separate things, and each earlier attempt fixed one shape of one of them:
|
||||
//
|
||||
// 1. THE SCROLL. Every viewport guess below `body` had been removed from
|
||||
// boardsList.css - and the last one was in layouts.css, where nobody looked:
|
||||
// `body.mobile-mode #content { height: calc(100dvh - 48px) }`. 48px is a
|
||||
// guess at the header, the header is not 48px and is not any one number
|
||||
// (Utils publishes --wekan-header-height from a ResizeObserver for exactly
|
||||
// this reason), so on a phone whose bar wraps, #content ended below the
|
||||
// screen and everything under it inherited that. Covered by
|
||||
// tests/mobileViewportHeight.test.cjs.
|
||||
//
|
||||
// 2. THE TABLE VIEW. The right column has two branches - the board icons and
|
||||
// `+tablePage` - and only the icons were given a scroller, so the other view
|
||||
// of every section could not be scrolled at all.
|
||||
//
|
||||
// 3. THE 4x-TALL TILES. A grid defaults to `align-content: stretch`, so a list
|
||||
// shorter than its column has the leftover height divided among its rows and
|
||||
// each tile grown into it; a board tile paints its colour over the whole
|
||||
// cell. This was fixed on `.board-list.mobile-view`, the class the template
|
||||
// sets from Utils.isMiniScreen() - but the phone MEDIA QUERY makes a grid
|
||||
// too, and a narrow window that is not a mini screen took that path and
|
||||
// never got the fix.
|
||||
//
|
||||
// 4. THE SPACING. An 8px grid gap PLUS a `margin-bottom: 0.5rem` per tile: 16px
|
||||
// between rows and 8px between columns, which reads as crowded sideways.
|
||||
// One value, both directions.
|
||||
//
|
||||
// Run: node tests/allBoardsPhoneScrollAndTiles.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const css = read('client/components/boards/boardsList.css');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// A rule's declarations, comments stripped - a rule that EXPLAINS what it no
|
||||
// longer says must not read as still saying it.
|
||||
function rule(selector, from = css) {
|
||||
const at = from.indexOf(`\n${selector} {`) !== -1
|
||||
? from.indexOf(`\n${selector} {`)
|
||||
: from.indexOf(`${selector} {`);
|
||||
assert.notStrictEqual(at, -1, `no rule for ${selector}`);
|
||||
const open = from.indexOf('{', at);
|
||||
return from.slice(open + 1, from.indexOf('}', open))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
}
|
||||
|
||||
function decl(body, prop) {
|
||||
const m = body.match(new RegExp(`(?:^|;|\\n)\\s*${prop}\\s*:\\s*([^;\\n]+)`));
|
||||
return m ? m[1].trim().replace(/\s*!important$/, '') : null;
|
||||
}
|
||||
|
||||
// The phone media query that turns the board list into a grid.
|
||||
const phoneGrid = (() => {
|
||||
const at = css.indexOf('grid-template-columns: repeat(2, minmax(0, 1fr));');
|
||||
assert.notStrictEqual(at, -1, 'the 2-per-row phone grid must exist');
|
||||
const open = css.lastIndexOf('{', at);
|
||||
return css.slice(open + 1, css.indexOf('}', at)).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
})();
|
||||
|
||||
// ── 3. the 4x-tall tiles ────────────────────────────────────────────────────
|
||||
|
||||
test('the phone board grid does not stretch its rows or its tiles', () => {
|
||||
assert.strictEqual(decl(phoneGrid, 'align-content'), 'start',
|
||||
'without align-content: start a grid divides the column\'s leftover height ' +
|
||||
'among its rows, so one row of boards becomes one band several times a ' +
|
||||
'tile\'s height - the "4x normal board icon height" in the report');
|
||||
assert.strictEqual(decl(phoneGrid, 'align-items'), 'start',
|
||||
'and without align-items: start each tile is stretched to its row');
|
||||
});
|
||||
|
||||
test('the mini-screen grid does not stretch either, so both paths agree', () => {
|
||||
const mobileView = rule('.board-list.mobile-view');
|
||||
assert.strictEqual(decl(mobileView, 'display'), 'grid');
|
||||
// The stretch fix for this path lives in its own late rule (source order
|
||||
// decides at equal specificity), so it is looked for across the file.
|
||||
const at = css.indexOf('.board-list.mobile-view {\n align-content: start;');
|
||||
assert.notStrictEqual(at, -1,
|
||||
'.board-list.mobile-view must also state align-content/align-items: start');
|
||||
});
|
||||
|
||||
test('a tile is its own height, not the height of the cell it sits in', () => {
|
||||
const li = (() => {
|
||||
const at = phoneGrid.length && css.indexOf(' .board-list li {');
|
||||
assert.notStrictEqual(at, -1, 'the phone rule for .board-list li must exist');
|
||||
return css.slice(css.indexOf('{', at) + 1, css.indexOf('}', at))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
})();
|
||||
assert.strictEqual(decl(li, 'align-self'), 'start',
|
||||
'a grid item stretches to its row unless it says otherwise');
|
||||
assert.strictEqual(decl(li, 'height'), 'auto', 'and takes its own content height');
|
||||
});
|
||||
|
||||
// ── 4. the spacing ──────────────────────────────────────────────────────────
|
||||
|
||||
test('one spacing value separates the tiles, in both directions', () => {
|
||||
const gap = decl(phoneGrid, 'gap');
|
||||
assert.ok(gap, 'the phone grid must set a gap');
|
||||
const px = parseInt(gap, 10);
|
||||
assert.ok(px >= 8, `the gap must be visible between tiles, got ${gap}`);
|
||||
assert.ok(/^\d+px$/.test(gap),
|
||||
`one length, applied to both rows and columns - got ${gap}`);
|
||||
});
|
||||
|
||||
test('no per-tile margin adds a second, one-directional gap', () => {
|
||||
for (const [selector, body] of [
|
||||
['.board-list li (phone)', css.slice(css.indexOf(' .board-list li {'),
|
||||
css.indexOf('}', css.indexOf(' .board-list li {'))).replace(/\/\*[\s\S]*?\*\//g, '')],
|
||||
['.board-list.mobile-view li', rule('.board-list.mobile-view li')],
|
||||
]) {
|
||||
const mb = decl(body, 'margin-bottom');
|
||||
assert.strictEqual(mb, '0',
|
||||
`${selector}: the grid gap spaces the tiles. A margin here spaced them a ` +
|
||||
`second time and only downwards, so the rows were twice as far apart as ` +
|
||||
`the columns - which is what "too close to each other" describes sideways`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2. every left-menu option's right page scrolls ──────────────────────────
|
||||
|
||||
test('the right column is a flex column with a scrolling part in BOTH views', () => {
|
||||
const jade = read('client/components/boards/boardsList.jade');
|
||||
// The two branches the template really has, so this test fails if a third
|
||||
// appears without a scroller.
|
||||
assert.ok(/if isAllBoardsView 'table'\s*\n\s*\+tablePage/.test(jade),
|
||||
'the Table branch of the right column must still be +tablePage');
|
||||
assert.ok(/ul\.board-list/.test(jade), 'the icons branch must still be .board-list');
|
||||
|
||||
// The PHONE rule (two-space indent, inside the media query) - not the
|
||||
// page-level `.boards-layout > .boards-right-grid` padding rule near the top
|
||||
// of the file, which a loose substring search finds first.
|
||||
const rgAt = css.indexOf('\n .boards-right-grid {');
|
||||
assert.notStrictEqual(rgAt, -1, 'the phone rule for .boards-right-grid must exist');
|
||||
const rightGrid = css.slice(css.indexOf('{', rgAt) + 1, css.indexOf('}', rgAt))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
assert.strictEqual(decl(rightGrid, 'display'), 'flex');
|
||||
assert.strictEqual(decl(rightGrid, 'flex-direction'), 'column');
|
||||
|
||||
const table = rule('.boards-right-grid > .table-page');
|
||||
assert.strictEqual(decl(table, 'flex'), '1 1 auto',
|
||||
'the table view must take what the column has left');
|
||||
assert.strictEqual(decl(table, 'min-height'), '0',
|
||||
'and be allowed to be shorter than the table, or it can never scroll');
|
||||
assert.ok(/auto/.test(decl(table, 'overflow-y')),
|
||||
'a table taller than the phone must scroll inside the column - #content is ' +
|
||||
'overflow: hidden, so anything past the fold is otherwise clipped');
|
||||
assert.ok(/auto|scroll/.test(decl(table, 'overflow-x')),
|
||||
'a table is wider than a phone too');
|
||||
});
|
||||
|
||||
test('the pane heading does not get squeezed instead of the scroller scrolling', () => {
|
||||
// Again the PHONE rule: there is a page-level
|
||||
// `.boards-right-grid > .admin-pane-title` earlier in the file that sets the
|
||||
// heading's margins, and a loose search finds that one.
|
||||
const tAt = css.indexOf('\n .boards-right-grid > .admin-pane-title {');
|
||||
assert.notStrictEqual(tAt, -1, 'the phone rule for the pane title must exist');
|
||||
const title = css.slice(css.indexOf('{', tAt) + 1, css.indexOf('}', tAt))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
assert.strictEqual(decl(title, 'flex'), '0 0 auto',
|
||||
'a flex item shrinks by default; the title must keep its height and let the ' +
|
||||
'list below it be the flexible one');
|
||||
});
|
||||
|
||||
test('the left menu is its own scroller, so its options are reachable too', () => {
|
||||
// "check that scrolling down works at all left menu options" - the menu
|
||||
// itself has to scroll when there are more workspaces than fit.
|
||||
const menu = css.slice(css.indexOf(' .boards-left-menu {\n overflow-y: auto;'));
|
||||
assert.ok(menu.length, 'the phone rule making the left menu a scroller must exist');
|
||||
const body = menu.slice(menu.indexOf('{') + 1, menu.indexOf('}')).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
assert.strictEqual(decl(body, 'overflow-y'), 'auto');
|
||||
assert.strictEqual(decl(body, 'min-height'), '0');
|
||||
assert.strictEqual(decl(body, 'height'), '100%');
|
||||
});
|
||||
|
||||
// ── 1. the chain the scroll hangs from, end to end ──────────────────────────
|
||||
|
||||
test('nothing between the body and the board list computes a height from the viewport', () => {
|
||||
const layouts = read('client/components/main/layouts.css');
|
||||
const strip = s => s.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// #content: the flex item, no arithmetic (the fix in layouts.css).
|
||||
const contentAt = layouts.indexOf('body.mobile-mode #content {');
|
||||
const content = strip(layouts.slice(contentAt, layouts.indexOf('}', contentAt)));
|
||||
assert.ok(!/height:\s*calc\(/.test(content),
|
||||
'body.mobile-mode #content must be sized by flex: 1, not by 100dvh minus a ' +
|
||||
'guessed header height - that is what put the bottom of All Boards off screen');
|
||||
|
||||
// ...and each step below it is a definite height that can also shrink.
|
||||
for (const [selector, expect] of [
|
||||
['#content .wrapper', { height: '100%', 'min-height': '0' }],
|
||||
['#content .wrapper > .boards-layout', { flex: '1 1 auto', 'min-height': '0' }],
|
||||
]) {
|
||||
const at = css.indexOf(` ${selector} {`);
|
||||
assert.notStrictEqual(at, -1, `the phone rule for ${selector} must exist`);
|
||||
const body = strip(css.slice(css.indexOf('{', at) + 1, css.indexOf('}', at)));
|
||||
for (const [prop, value] of Object.entries(expect)) {
|
||||
assert.strictEqual(decl(body, prop), value,
|
||||
`${selector} must state ${prop}: ${value} - a step that cannot shrink ` +
|
||||
`below its content stops every scroller under it from scrolling`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,198 @@
|
||||
'use strict';
|
||||
|
||||
// Guard for releases/backfill-charts.sh - the script that answers "which WeKan
|
||||
// releases should the Helm index list?" and repairs what release-charts.sh left.
|
||||
// Run: node tests/backfillCharts.test.cjs
|
||||
//
|
||||
// Two facts about the live wekan/charts index this is written against:
|
||||
//
|
||||
// * It is INCOMPLETE. An entry is written once, by release-charts.sh, during
|
||||
// the release it belongs to - so a release whose charts job did not run
|
||||
// leaves a hole nothing ever fills. v10.80 is one of those (its charts job
|
||||
// was skipped when a cancelled build-mac-x64 cancelled the run) and it is far
|
||||
// from alone: 216 of 690 releases have an entry.
|
||||
//
|
||||
// * It has DUPLICATES, and they are the dangerous kind. 9.36.0 appears four
|
||||
// times and 10.30.0 twice, every copy with a DIFFERENT digest and the SAME
|
||||
// url - because release-charts.sh PREPENDS an entry each time it runs, so
|
||||
// re-running a release duplicated its version instead of replacing it. At
|
||||
// most one of those digests can belong to the wekan-9.36.0.tgz that is
|
||||
// actually served; a helm client that picks another fails the integrity check
|
||||
// on a file that is perfectly good.
|
||||
//
|
||||
// The dedup rule is checked here by running the script's own embedded python
|
||||
// against a synthesised index, because the rule is the part with a decision in
|
||||
// it: the entry whose digest MATCHES the package on disk wins - not the newest,
|
||||
// which is only the fallback for when no package is there to compare against.
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const scriptPath = path.join(repoRoot, 'releases/backfill-charts.sh');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
test('the script exists and is executable', () => {
|
||||
assert.ok(fs.existsSync(scriptPath), 'releases/backfill-charts.sh is missing');
|
||||
assert.ok(fs.statSync(scriptPath).mode & 0o111, 'backfill-charts.sh must be executable');
|
||||
});
|
||||
|
||||
test('it writes nothing without --apply, and pushes nothing without --push', () => {
|
||||
assert.ok(/if \[ "\$APPLY" != true \]; then/.test(script),
|
||||
'the plan must return before anything is written');
|
||||
assert.ok(/if \[ "\$PUSH" != true \]; then/.test(script),
|
||||
'even --apply must not push a rewritten public index without --push');
|
||||
const applyGate = script.indexOf('if [ "$APPLY" != true ]; then');
|
||||
const helmPackage = script.indexOf('helm package');
|
||||
const gitPush = script.indexOf('git push origin gh-pages');
|
||||
assert.ok(applyGate !== -1 && helmPackage > applyGate,
|
||||
'nothing may be packaged before the --apply gate');
|
||||
assert.ok(gitPush > script.indexOf('if [ "$PUSH" != true ]; then'),
|
||||
'the push must come after the --push gate');
|
||||
});
|
||||
|
||||
test('a release with no container image is omitted, not listed', () => {
|
||||
assert.ok(/omit\s*=\s*\[v for v, ok in zip\(candidates, have_image\) if ok is False\]/.test(script),
|
||||
'a release whose image does not exist must land in "omit"');
|
||||
assert.ok(/return None/.test(script) && /if ok is None/.test(script),
|
||||
'a network failure must be "unknown", not "no image" - otherwise a hiccup ' +
|
||||
'omits a release that has one');
|
||||
});
|
||||
|
||||
test('an already-published chart is never repackaged', () => {
|
||||
assert.ok(/if f"\{ver\}\.0" in published:\s*\n\s*keep\.append\(ver\)/.test(script),
|
||||
'a version already in the index goes to "keep"; repackaging it would change ' +
|
||||
'a digest that helm clients have already seen');
|
||||
});
|
||||
|
||||
test('the published-chart scan reads entry versions, not dependency versions', () => {
|
||||
assert.ok(/\^ \{4\}version:/.test(script),
|
||||
"the index's own entries are at four spaces; the mongodb subchart's " +
|
||||
"version: is at six, and a loose \\s* counts 0.7.2 as a published WeKan chart");
|
||||
});
|
||||
|
||||
// The dedup rule, run for real.
|
||||
function runDedup(dir) {
|
||||
const body = script.split("python3 - <<'PYEOF'\nimport hashlib")[1].split('\nPYEOF')[0];
|
||||
const py = path.join(dir, 'dedup.py');
|
||||
fs.writeFileSync(py, 'import hashlib' + body);
|
||||
return execFileSync('python3', [py], { cwd: dir, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
// A minimal index in the real shape: entry keys at four spaces, a dependency
|
||||
// block at six, and the " wekan:" header the script looks for.
|
||||
function makeIndex(entries) {
|
||||
const head = 'apiVersion: v1\nentries:\n wekan:\n';
|
||||
const body = entries.map(e =>
|
||||
` - apiVersion: v2\n` +
|
||||
` appVersion: "${e.version}"\n` +
|
||||
` created: "${e.created}"\n` +
|
||||
` dependencies:\n` +
|
||||
` - name: mongodb\n` +
|
||||
` version: 0.7.2\n` +
|
||||
` digest: ${e.digest}\n` +
|
||||
` name: wekan\n` +
|
||||
` urls:\n` +
|
||||
` - https://wekan.github.io/charts/wekan-${e.version}.tgz\n` +
|
||||
` version: ${e.version}\n`).join('');
|
||||
return head + body + 'generated: "2026-08-11T00:00:00.000000+00:00"\n';
|
||||
}
|
||||
|
||||
function readVersions(file) {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter(l => /^ {4}version: /.test(l))
|
||||
.map(l => l.trim().replace('version: ', ''));
|
||||
}
|
||||
|
||||
test('a duplicated version is reduced to the entry whose digest matches the package', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-charts-'));
|
||||
try {
|
||||
const pkg = Buffer.from('pretend chart package');
|
||||
const realDigest = crypto.createHash('sha256').update(pkg).digest('hex');
|
||||
fs.writeFileSync(path.join(dir, 'wekan-9.36.0.tgz'), pkg);
|
||||
|
||||
// The matching digest is on the OLDEST copy on purpose: if "newest wins" were
|
||||
// the rule rather than the fallback, this is the case that would expose it.
|
||||
fs.writeFileSync(path.join(dir, 'index.yaml'), makeIndex([
|
||||
{ version: '10.79.0', created: '2026-08-10T02:46:52+00:00', digest: 'a'.repeat(64) },
|
||||
{ version: '9.36.0', created: '2026-06-11T03:41:02+00:00', digest: 'b'.repeat(64) },
|
||||
{ version: '9.36.0', created: '2026-06-11T02:26:50+00:00', digest: 'c'.repeat(64) },
|
||||
{ version: '9.36.0', created: '2026-06-10T21:20:06+00:00', digest: realDigest },
|
||||
]));
|
||||
|
||||
const out = runDedup(dir);
|
||||
const versions = readVersions(path.join(dir, 'index.yaml'));
|
||||
assert.deepStrictEqual(versions, ['10.79.0', '9.36.0'],
|
||||
'one entry per version must remain, and the untouched version must survive');
|
||||
const kept = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
|
||||
assert.ok(kept.includes(realDigest),
|
||||
'the entry kept for 9.36.0 must be the one whose digest is the digest of ' +
|
||||
'wekan-9.36.0.tgz - a helm client checks the package against it');
|
||||
assert.ok(kept.includes('2026-06-10T21:20:06'),
|
||||
'which here is the OLDEST copy: the digest decides, the timestamp does not');
|
||||
assert.ok(/2 duplicate\(s\) removed/.test(out), `expected a report of 2 removals, got: ${out}`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('with no package to compare against, the newest duplicate is the fallback', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-charts-'));
|
||||
try {
|
||||
// No wekan-10.30.0.tgz in the directory, so nothing can be verified.
|
||||
fs.writeFileSync(path.join(dir, 'index.yaml'), makeIndex([
|
||||
{ version: '10.30.0', created: '2026-07-23T12:50:37+00:00', digest: 'd'.repeat(64) },
|
||||
{ version: '10.30.0', created: '2026-07-23T11:58:18+00:00', digest: 'e'.repeat(64) },
|
||||
]));
|
||||
runDedup(dir);
|
||||
const kept = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
|
||||
assert.deepStrictEqual(readVersions(path.join(dir, 'index.yaml')), ['10.30.0']);
|
||||
assert.ok(kept.includes('2026-07-23T12:50:37'), 'the newest is the fallback');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an index with no duplicates is left exactly as it was', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-charts-'));
|
||||
try {
|
||||
const before = makeIndex([
|
||||
{ version: '10.79.0', created: '2026-08-10T02:46:52+00:00', digest: 'a'.repeat(64) },
|
||||
{ version: '10.78.0', created: '2026-08-09T02:46:52+00:00', digest: 'b'.repeat(64) },
|
||||
]);
|
||||
fs.writeFileSync(path.join(dir, 'index.yaml'), before);
|
||||
const out = runDedup(dir);
|
||||
assert.strictEqual(fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8'), before,
|
||||
'a clean index must not be rewritten at all - no reformatting, no reordering');
|
||||
assert.ok(/0 duplicate\(s\) removed/.test(out), out);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the charts job reports the gap on every release', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
|
||||
assert.ok(/backfill-charts\.sh \| tee -a "\$GITHUB_STEP_SUMMARY"/.test(workflow),
|
||||
'the charts job should print the plan into the run summary, so a hole in the ' +
|
||||
'index is visible the release it appears in');
|
||||
// Comments may name `--apply --push` as the command to run by hand; what must
|
||||
// not exist is a line that actually RUNS it.
|
||||
const invocations = workflow.split('\n')
|
||||
.filter(l => l.includes('backfill-charts.sh'))
|
||||
.filter(l => !/^\s*#/.test(l.trim()) && !l.trim().startsWith('#'));
|
||||
const publishing = invocations.filter(l => /--push|--apply/.test(l));
|
||||
assert.deepStrictEqual(publishing, [],
|
||||
'the workflow must never package or push a rewritten index by itself:\n ' +
|
||||
publishing.join('\n '));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -53,7 +53,10 @@ const WIRED = [
|
||||
['export.path-outside-storage', 'models/exporter.js', 'PathBleed'],
|
||||
['database.canary', 'server/lib/databaseProblems.js', '-'],
|
||||
// Injection - the attacker sends a query instead of a value.
|
||||
['injection.nosql-selector', 'server/publications/cardsWindow.js', '-'],
|
||||
// GHSA-phm4-4v26-j2vq: this trip moved OUT of cardsWindow.js into the shared
|
||||
// guard, because eight other handlers needed the same check and a second copy
|
||||
// of it would be the same bug set up to happen again.
|
||||
['injection.nosql-selector', 'server/lib/selectorGuard.js', '-'],
|
||||
['injection.nosql-operator', 'models/lib/injectionDetect.js', '-'],
|
||||
['injection.sql-statement', 'server/lib/databaseProblems.js', 'EscapeBleed'],
|
||||
// Sanitization - something DANGEROUS had to be removed, not merely tidied.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
|
||||
// #6465, the two newest comments:
|
||||
//
|
||||
// csonkaoszimt: "is there a way to implement the whole closing an open card by
|
||||
// clicking on it again thing?"
|
||||
// Heart1010: "agree, closing by clicking again would be nice."
|
||||
// "And I found this small glitch at the problems page (v10.81.0)"
|
||||
//
|
||||
// Run: node tests/cardClickToggle.test.cjs
|
||||
//
|
||||
// TWO THINGS, and the first is not a missing feature - it is an unreachable one.
|
||||
//
|
||||
// clickOnMiniCard already ended with:
|
||||
//
|
||||
// } else if (Session.equals('currentCard', card._id)) {
|
||||
// Utils.goBoardId(Session.get('currentBoard')); // close it
|
||||
//
|
||||
// but the TITLE branch above it returns before that is ever reached, and the
|
||||
// title covers most of a minicard. So the second click almost always landed in
|
||||
// the title branch and re-opened the card that was already open, which is why
|
||||
// this reads to users as a feature that was never built. The title branch now
|
||||
// makes the same decision, and the phone's popup path does too.
|
||||
//
|
||||
// The second is the Problems page glitch in the screenshot: a checkbox that is
|
||||
// both checked and focused drew as a blue DIAMOND. The checked rule turns the
|
||||
// square into a tick by rotating the element 40deg, and a browser draws its focus
|
||||
// ring around the element AS IT IS - rotated. The ring moves to the row instead,
|
||||
// which is not rotated. Keyboard focus stays visible; it just is not a diamond.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
const listBody = read('client/components/lists/listBody.js');
|
||||
const settingCss = read('client/components/settings/settingBody.css');
|
||||
const problemsJade = read('client/components/settings/problemsSummary.jade');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// The click handler, so the assertions are about it and not about the file.
|
||||
const handler = (() => {
|
||||
const at = listBody.indexOf('this.clickOnMiniCard = (evt) => {');
|
||||
assert.notStrictEqual(at, -1, 'listBody.js must still define clickOnMiniCard');
|
||||
return listBody.slice(at, listBody.indexOf('\n this.toggleMultiSelection', at));
|
||||
})();
|
||||
|
||||
// ── clicking an open card again ─────────────────────────────────────────────
|
||||
|
||||
test('the title branch closes the card when it is already the open one', () => {
|
||||
const titleAt = handler.indexOf('if (clickedTitle && !clickedLinkedReference)');
|
||||
assert.notStrictEqual(titleAt, -1, 'the title branch must still exist');
|
||||
const branch = handler.slice(titleAt, handler.indexOf('if (Utils.isMiniScreen())', titleAt));
|
||||
assert.ok(/Session\.equals\('currentCard', card\._id\)/.test(branch),
|
||||
'the title is most of a minicard, so this is where the second click lands - ' +
|
||||
'it has to ask whether the card is already open');
|
||||
assert.ok(/Utils\.goBoardId\(Session\.get\('currentBoard'\)\)/.test(branch),
|
||||
'and close it the same way the existing branch below does');
|
||||
const check = branch.indexOf("Session.equals('currentCard'");
|
||||
const open = branch.indexOf('openCardWindow(card._id)');
|
||||
assert.ok(check !== -1 && open !== -1 && check < open,
|
||||
'the close decision must come BEFORE re-opening, or it never happens');
|
||||
});
|
||||
|
||||
test('opening a DIFFERENT card from a title click still works', () => {
|
||||
const titleAt = handler.indexOf('if (clickedTitle && !clickedLinkedReference)');
|
||||
const branch = handler.slice(titleAt, handler.indexOf('if (Utils.isMiniScreen())', titleAt));
|
||||
assert.ok(/Session\.set\('currentCard', card\._id\)/.test(branch) &&
|
||||
/openCardWindow\(card\._id\)/.test(branch),
|
||||
'a click on a card that is not open must still open it');
|
||||
assert.ok(/Session\.delete\('popupCardId'\)/.test(branch),
|
||||
'and still clear the phone popup ids, which is what that branch did before');
|
||||
});
|
||||
|
||||
test('the original toggle further down is untouched', () => {
|
||||
// Clicking a non-title part of an open card already closed it; this fix must
|
||||
// not change that path.
|
||||
assert.ok(/\} else if \(Session\.equals\('currentCard', card\._id\)\) \{[\s\S]{0,200}Utils\.goBoardId/.test(handler),
|
||||
'the else-if that already toggled must stay, so both click targets agree');
|
||||
});
|
||||
|
||||
test('on a phone, the second click closes the card popup', () => {
|
||||
const at = handler.indexOf('if (Utils.isMiniScreen())');
|
||||
const branch = handler.slice(at, handler.indexOf('} else if', at));
|
||||
assert.ok(/Session\.equals\('popupCardId', card\._id\) && Popup\.isOpen\(\)/.test(branch),
|
||||
'on a mini screen the card is a popup, so "already open" is a different ' +
|
||||
'question - and it has to be asked before opening another one');
|
||||
assert.ok(/Popup\.back\(\)/.test(branch), 'and closed as a popup, not by routing');
|
||||
});
|
||||
|
||||
test('multi-selection still wins over everything', () => {
|
||||
const multi = handler.indexOf('MultiSelection.isActive() || evt.shiftKey');
|
||||
const title = handler.indexOf('if (clickedTitle && !clickedLinkedReference)');
|
||||
assert.ok(multi !== -1 && multi < title,
|
||||
'selecting cards must still take precedence, or a click during ' +
|
||||
'multi-selection would open or close a card instead of ticking it');
|
||||
});
|
||||
|
||||
// ── the Problems page checkbox ──────────────────────────────────────────────
|
||||
|
||||
test('the checked checkbox is still the rotated tick', () => {
|
||||
const at = settingCss.indexOf('.setting-content input[type="checkbox"]:checked {');
|
||||
assert.notStrictEqual(at, -1, 'the checked style must still exist');
|
||||
const rule = settingCss.slice(at, settingCss.indexOf('}', at));
|
||||
assert.ok(/transform: rotate\(40deg\)/.test(rule),
|
||||
'the tick IS the rotation - this fix is about the focus ring, not the tick');
|
||||
});
|
||||
|
||||
test('the focus ring is not drawn on the rotated element', () => {
|
||||
assert.ok(/\.setting-content input\[type="checkbox"\]:focus[\s\S]{0,120}outline: none/.test(settingCss),
|
||||
'a ring around a 40deg-rotated box renders as the blue diamond in the report');
|
||||
});
|
||||
|
||||
test('...but focus is still visible, on the row that is not rotated', () => {
|
||||
assert.ok(/label:has\(> input\[type="checkbox"\]:focus-visible\)[\s\S]{0,160}outline: 2px solid/.test(settingCss),
|
||||
'removing focus indication outright would be worse than the glitch; it moves ' +
|
||||
'to the containing label');
|
||||
assert.ok(/:focus-visible/.test(settingCss),
|
||||
'and :focus-visible, so a mouse click does not leave a ring behind');
|
||||
});
|
||||
|
||||
test('the markup the ring relies on is really there', () => {
|
||||
assert.ok(/label\.admin-problem-item\s*\n\s*input\.js-problem-check\(type="checkbox"/.test(problemsJade),
|
||||
'the fix puts the outline on the label because the checkbox is inside one; ' +
|
||||
'if that nesting changes, the ring has nowhere to go');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
// wekan/wekan#6584 "It is not possible to move a card downwards":
|
||||
//
|
||||
// "With v6 i was able to move a Card downwards by drag & Drop. Upwards is no
|
||||
// problem, the Line scrolls automaticly up, but this does not work downwards.
|
||||
// The whole Page/Site scrolls down and not ne Line"
|
||||
//
|
||||
// Run: node tests/cardDragVerticalScroll.test.cjs
|
||||
//
|
||||
// The card sortable's `sort` handler auto-scrolls at the edges. Horizontally it
|
||||
// picks the LANE under the pointer (#443). Vertically it did not pick anything:
|
||||
// it always scrolled `.board-canvas`, which holds the swimlanes, rather than the
|
||||
// `.list-body` under the pointer, which is `overflow-y: scroll` and holds the
|
||||
// cards. Scrolling the canvas moves the whole board - "the whole Page/Site
|
||||
// scrolls down and not ne Line".
|
||||
//
|
||||
// THE ASYMMETRY IS THE TELL, and it is what makes this reproducible rather than
|
||||
// vague. Dragging UP, the canvas is usually already at scrollTop 0, so
|
||||
// computeEdgeScroll returned null, this handler did nothing, and jQuery UI's own
|
||||
// `scroll` option - which acts on the placeholder's scrollParent, i.e. the list
|
||||
// body - scrolled the list, which is why up "is no problem". Dragging DOWN, the
|
||||
// canvas nearly always has room left, so the handler fired first and scrolled the
|
||||
// board instead.
|
||||
//
|
||||
// The list under the pointer now scrolls first, and the canvas only once the list
|
||||
// cannot go further that way - so a drag down a long list scrolls the list, and a
|
||||
// drag past the end of it moves on to the board.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const listJs = fs.readFileSync(
|
||||
path.join(repoRoot, 'client/components/lists/list.js'), 'utf8');
|
||||
const listCss = fs.readFileSync(
|
||||
path.join(repoRoot, 'client/components/lists/list.css'), 'utf8');
|
||||
const { computeEdgeScroll, findLaneUnderPointer } =
|
||||
require(path.join(repoRoot, 'imports/lib/boardAutoScroll.js'));
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// The `sort` handler, which is where the auto-scroll lives.
|
||||
const sortHandler = (() => {
|
||||
const at = listJs.indexOf(' sort(event, ui) {');
|
||||
assert.notStrictEqual(at, -1, 'list.js must still have the sortable sort handler');
|
||||
return listJs.slice(at, listJs.indexOf("\n $cards.sortable('refreshPositions');", at));
|
||||
})();
|
||||
|
||||
test('the list body is still the element that scrolls cards', () => {
|
||||
const at = listCss.indexOf('.list-body {');
|
||||
assert.notStrictEqual(at, -1, 'list.css must still style .list-body');
|
||||
const rule = listCss.slice(at, listCss.indexOf('}', at));
|
||||
assert.ok(/overflow-y:\s*scroll/.test(rule),
|
||||
'if the cards ever stop scrolling inside .list-body, the drag auto-scroll ' +
|
||||
'has to follow them to whatever does');
|
||||
});
|
||||
|
||||
test('the vertical auto-scroll picks the list under the pointer', () => {
|
||||
assert.ok(/document\.querySelectorAll\('\.list-body'\)/.test(sortHandler),
|
||||
'the handler must look at the list bodies, not only at .board-canvas - ' +
|
||||
'scrolling the canvas moves the whole board, which is the reported symptom');
|
||||
const listAt = sortHandler.indexOf(".querySelectorAll('.list-body')");
|
||||
const canvasAt = sortHandler.indexOf(".querySelector('.board-canvas')");
|
||||
assert.ok(listAt !== -1 && canvasAt !== -1 && listAt < canvasAt,
|
||||
'and it must try the list BEFORE the canvas');
|
||||
});
|
||||
|
||||
test('the canvas is only scrolled when the list could not be', () => {
|
||||
assert.ok(/scrolledList \? null : document\.querySelector\('\.board-canvas'\)/.test(sortHandler),
|
||||
'a drag past the end of a list should still move on to the board, but a drag ' +
|
||||
'down a list that can still scroll must not move the board underneath it');
|
||||
});
|
||||
|
||||
test('the horizontal lane scroll is untouched', () => {
|
||||
// #443 is the reason the lane hit-test exists; this fix must not disturb it.
|
||||
assert.ok(/document\.querySelectorAll\('\.js-lists'\)/.test(sortHandler),
|
||||
'the lane lookup must stay');
|
||||
assert.ok(/scrollLeft = nextLeft/.test(sortHandler), 'and still set scrollLeft');
|
||||
});
|
||||
|
||||
test('positions are still re-cached after any auto-scroll', () => {
|
||||
// #6477: jQuery UI caches geometry at drag start; a manual scroll invalidates
|
||||
// it and the drop lands in the wrong swimlane without this.
|
||||
assert.ok(/if \(scrolled\) \{/.test(listJs) &&
|
||||
/refreshPositions/.test(listJs),
|
||||
'scrolling the list is a manual scroll like any other and must refresh too');
|
||||
assert.ok(/scrolled = true;/.test(sortHandler.slice(sortHandler.indexOf('.list-body'))),
|
||||
'the list branch must set `scrolled`, or the refresh is skipped for it');
|
||||
});
|
||||
|
||||
// ── the decision logic itself, exercised with real numbers ──────────────────
|
||||
|
||||
test('a pointer near the bottom of a scrollable list scrolls it DOWN', () => {
|
||||
// A list body 600px tall on screen, 2000px of cards, currently at the top.
|
||||
// The pointer is 10px above its bottom edge: inside the 40px edge zone.
|
||||
const next = computeEdgeScroll({
|
||||
pointer: 690, lowEdge: 100, highEdge: 700,
|
||||
scrollPos: 0, scrollSize: 2000, clientSize: 600,
|
||||
});
|
||||
assert.strictEqual(next, 15, 'it should step down by SCROLL_STEP');
|
||||
});
|
||||
|
||||
test('...and stops at the end instead of handing the drag to the board', () => {
|
||||
const next = computeEdgeScroll({
|
||||
pointer: 690, lowEdge: 100, highEdge: 700,
|
||||
scrollPos: 1400, scrollSize: 2000, clientSize: 600,
|
||||
});
|
||||
assert.strictEqual(next, null,
|
||||
'at maxScroll it returns null - which is exactly what lets the canvas take over');
|
||||
});
|
||||
|
||||
test('a list that does not overflow never claims the scroll', () => {
|
||||
const next = computeEdgeScroll({
|
||||
pointer: 690, lowEdge: 100, highEdge: 700,
|
||||
scrollPos: 0, scrollSize: 600, clientSize: 600,
|
||||
});
|
||||
assert.strictEqual(next, null, 'a short list must let the board scroll instead');
|
||||
});
|
||||
|
||||
test('the list under the pointer is the one chosen, not the first on the board', () => {
|
||||
// Three lists side by side; the pointer is over the middle one.
|
||||
const rects = [
|
||||
{ left: 0, right: 200, top: 100, bottom: 700 },
|
||||
{ left: 210, right: 410, top: 100, bottom: 700 },
|
||||
{ left: 420, right: 620, top: 100, bottom: 700 },
|
||||
];
|
||||
assert.strictEqual(findLaneUnderPointer(rects, 300, 690), 1);
|
||||
assert.strictEqual(findLaneUnderPointer(rects, 300, 900), -1,
|
||||
'below every list, nothing is chosen and the canvas handles it');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,228 @@
|
||||
'use strict';
|
||||
|
||||
// #1591 "Collapsable checklist on card" (open since 2018):
|
||||
//
|
||||
// "Long checklists can make a card pretty cluttered, so being able to collapse
|
||||
// them and expand only when needed would keep the board much cleaner."
|
||||
//
|
||||
// Run: node tests/checklistCollapse.test.cjs
|
||||
//
|
||||
// WeKan already had something adjacent and it is NOT this: a checklist's own
|
||||
// `hideAllChecklistItems` field, reachable through a toggle switch inside the
|
||||
// checklist actions popup. That is a field ON THE CHECKLIST, so flipping it
|
||||
// changes what EVERYONE on the board sees, and it is a data edit rather than a
|
||||
// view preference - the wrong thing for "get this out of my way while I read the
|
||||
// card", and buried where nobody folding a list would look.
|
||||
//
|
||||
// Collapsing is per-user, and WeKan already says so twice in its own models:
|
||||
//
|
||||
// models/lists.js: "collapsed state is per-user only, stored in user
|
||||
// profile.collapsedLists"
|
||||
// models/swimlanes.js: "collapsed state is per-user only ... Use
|
||||
// user.setCollapsedSwimlane(boardId, swimlaneId, ...)"
|
||||
//
|
||||
// So cards follow that: profile.collapsedCardSections[cardId][sectionKey], with
|
||||
// a caret on the title rather than a third entry in a menu. ONE map covers every
|
||||
// foldable thing on a card - a whole feature group uses its own section name, an
|
||||
// individual checklist uses 'checklist-<id>' - so the opened card and the
|
||||
// minicard agree about what was folded. This suite pins the shape against the
|
||||
// two siblings, because the value of following an existing pattern is entirely
|
||||
// in actually following it.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
const usersModel = read('models/users.js');
|
||||
const jade = read('client/components/cards/checklists.jade');
|
||||
const js = read('client/components/cards/checklists.js');
|
||||
const css = read('client/components/cards/checklists.css');
|
||||
const minicardJade = read('client/components/cards/minicard.jade');
|
||||
const minicardJs = read('client/components/cards/minicard.js');
|
||||
const detailsJs = read('client/components/cards/cardDetails.js');
|
||||
const detailsCss = read('client/components/cards/cardDetails.css');
|
||||
const en = JSON.parse(read('imports/i18n/data/en.i18n.json'));
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// ── the state, and where it lives ───────────────────────────────────────────
|
||||
|
||||
test('the collapsed state is per user, beside lists and swimlanes', () => {
|
||||
assert.ok(/'profile\.collapsedCardSections': \{/.test(usersModel),
|
||||
'it belongs in the user profile, like collapsedLists and collapsedSwimlanes');
|
||||
for (const sibling of ['profile.collapsedLists', 'profile.collapsedSwimlanes']) {
|
||||
assert.ok(usersModel.includes(`'${sibling}': {`),
|
||||
`${sibling} must still be there - this is meant to sit alongside them`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ONE map covers every foldable thing on a card', () => {
|
||||
assert.ok(/getCollapsedCardSection\(cardId, sectionKey\)/.test(usersModel));
|
||||
assert.ok(/setCollapsedCardSection\(cardId, sectionKey, collapsed\)/.test(usersModel));
|
||||
assert.ok(/current\[cardId\]\[sectionKey\] = !!collapsed;/.test(usersModel),
|
||||
'two checklists on the same card fold independently, so the section key ' +
|
||||
'cannot be dropped');
|
||||
assert.ok(/checklistSectionKey\(checklistId\) \{[\s\S]{0,120}checklist-\$\{checklistId\}/.test(usersModel),
|
||||
'a checklist needs ONE key, because the opened card and the minicard have ' +
|
||||
'to agree about which thing was folded');
|
||||
});
|
||||
|
||||
test('"never set" is distinguishable from "expanded"', () => {
|
||||
const at = usersModel.indexOf('getCollapsedCardSection(cardId, sectionKey)');
|
||||
const body = usersModel.slice(at, usersModel.indexOf('\n },', at));
|
||||
assert.ok(/typeof collapsedCardSections\[cardId\]\[sectionKey\] === 'boolean'/.test(body),
|
||||
'a stored false and an absent entry must not be the same thing, or a ' +
|
||||
'deliberate expand cannot be told from a default');
|
||||
assert.ok(/return null;/.test(body), 'absent returns null, as the siblings do');
|
||||
});
|
||||
|
||||
test('it does NOT touch the checklist document', () => {
|
||||
// The distinction this whole feature turns on.
|
||||
const setter = usersModel.slice(usersModel.indexOf('async setCollapsedCardSection'));
|
||||
const body = setter.slice(0, setter.indexOf('\n },'));
|
||||
assert.ok(/Users\.updateAsync/.test(body), 'it writes the USER');
|
||||
assert.ok(!/Checklists\./.test(body),
|
||||
'writing the checklist would change what everyone on the board sees, which ' +
|
||||
'is what hideAllChecklistItems already does and is not what was asked for');
|
||||
});
|
||||
|
||||
test('the existing shared hideAllChecklistItems is left exactly as it was', () => {
|
||||
const checklists = read('models/checklists.js');
|
||||
assert.ok(/toggleHideAllChecklistItems/.test(checklists),
|
||||
'it has its own uses (it also drives showChecklist / isFinished) and this ' +
|
||||
'feature must not have quietly replaced it');
|
||||
assert.ok(/hideAllChecklistItems/.test(jade),
|
||||
'and its effect on items is still rendered');
|
||||
});
|
||||
|
||||
// ── the control ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the caret is on the title, not another entry in the actions menu', () => {
|
||||
assert.ok(/a\.fa\.checklist-collapse\.js-collapse-checklist\(/.test(jade),
|
||||
'the point is to fold at a glance while reading the card');
|
||||
const titleAt = jade.indexOf('.checklist-title');
|
||||
const caretAt = jade.indexOf('js-collapse-checklist');
|
||||
const menuAt = jade.indexOf('js-open-checklist-details-menu');
|
||||
assert.ok(titleAt < caretAt && caretAt < menuAt,
|
||||
'inside the title row, before the actions menu');
|
||||
});
|
||||
|
||||
test('the caret says which way it goes, and to a screen reader too', () => {
|
||||
const block = jade.slice(jade.indexOf('js-collapse-checklist'), jade.indexOf('js-open-checklist-details-menu'));
|
||||
assert.ok(/fa-caret-right/.test(block) && /fa-caret-down/.test(block),
|
||||
'right when folded, down when open - or it is a button with no state');
|
||||
assert.ok(/aria-expanded=/.test(block), 'and the state is exposed, not just drawn');
|
||||
assert.ok(/role="button"/.test(block) && /tabindex="0"/.test(block),
|
||||
'it is an <a> with no href, so it needs both to be reachable');
|
||||
});
|
||||
|
||||
test('folding hides the items and keeps the progress bar', () => {
|
||||
assert.ok(/unless checklistCollapsed\s*\n\s*\+checklistItems/.test(jade),
|
||||
'the items are what gets folded away');
|
||||
const progressAt = jade.indexOf('.checklist-progress-bar-container');
|
||||
const unlessAt = jade.indexOf('unless checklistCollapsed');
|
||||
assert.ok(progressAt !== -1 && progressAt < unlessAt,
|
||||
'the progress bar stays: it is the summary of what was folded, so hiding it ' +
|
||||
'too would leave a title that says nothing');
|
||||
});
|
||||
|
||||
test('the click does not also open the rename form underneath it', () => {
|
||||
const at = js.indexOf("'click .js-collapse-checklist'");
|
||||
const body = js.slice(at, js.indexOf('\n },', at));
|
||||
assert.ok(/event\.preventDefault\(\)/.test(body) && /event\.stopPropagation\(\)/.test(body),
|
||||
'the caret sits inside the title, which opens the inline rename form');
|
||||
assert.ok(/setCollapsedCardSection\(cardId, key, !collapsed\)/.test(body),
|
||||
'and it toggles this user\'s state');
|
||||
});
|
||||
|
||||
test('it works from the keyboard', () => {
|
||||
assert.ok(/'keydown \.js-collapse-checklist'/.test(js),
|
||||
'a link with no href is not reachable by keyboard without this');
|
||||
const at = js.indexOf("'keydown .js-collapse-checklist'");
|
||||
const body = js.slice(at, js.indexOf('\n },', at));
|
||||
assert.ok(/event\.key !== 'Enter' && event\.key !== ' '/.test(body),
|
||||
'Enter and Space are the keys a button answers');
|
||||
});
|
||||
|
||||
test('a logged-out reader is not broken by it', () => {
|
||||
const at = js.indexOf('checklistCollapsed() {');
|
||||
const body = js.slice(at, js.indexOf('\n },', at));
|
||||
assert.ok(/if \(!user\) return false;/.test(body),
|
||||
'public boards have no current user; the checklist must render expanded ' +
|
||||
'rather than throwing');
|
||||
});
|
||||
|
||||
test('the caret is styled and has a hit area', () => {
|
||||
assert.ok(/\.checklist-title \.checklist-collapse \{/.test(css));
|
||||
assert.ok(/padding:/.test(css.slice(css.indexOf('.checklist-title .checklist-collapse {'))),
|
||||
'a single glyph you have to aim at is worse than no glyph');
|
||||
assert.ok(/:focus-visible/.test(css), 'and keyboard focus has to be visible on it');
|
||||
});
|
||||
|
||||
test('both labels exist in English', () => {
|
||||
assert.strictEqual(en['collapse-checklist'], 'Collapse checklist');
|
||||
assert.strictEqual(en['expand-checklist'], 'Expand checklist');
|
||||
const block = jade.slice(jade.indexOf('js-collapse-checklist'), jade.indexOf('js-open-checklist-details-menu'));
|
||||
assert.ok(/_ 'expand-checklist'/.test(block) && /_ 'collapse-checklist'/.test(block),
|
||||
'the title has to say what the click will DO, which is the opposite of the ' +
|
||||
'current state');
|
||||
});
|
||||
|
||||
|
||||
// ── the minicard, and the other feature groups ──────────────────────────────
|
||||
|
||||
test('the minicard has the same caret, under the same key', () => {
|
||||
assert.ok(/js-collapse-checklist/.test(minicardJade),
|
||||
'a checklist shown on a minicard must fold there too');
|
||||
assert.ok(/unless checklistCollapsed\s*\n\s*\.checklist-items/.test(minicardJade),
|
||||
'and folding must hide its items');
|
||||
const at = minicardJs.indexOf("'click .js-collapse-checklist'");
|
||||
assert.notStrictEqual(at, -1, 'the minicard needs its own handler');
|
||||
const body = minicardJs.slice(at, minicardJs.indexOf('\n },', at));
|
||||
assert.ok(/user\.checklistSectionKey\(checklist\._id\)/.test(body),
|
||||
'the SAME key as the opened card, or folding in one place would not fold in ' +
|
||||
'the other');
|
||||
assert.ok(/event\.preventDefault\(\)/.test(body) && /event\.stopPropagation\(\)/.test(body),
|
||||
'a minicard is a link to the card; folding must not open it');
|
||||
});
|
||||
|
||||
test('every feature group on the opened card folds, by its title', () => {
|
||||
assert.ok(/'click \.card-details-item > \.card-details-item-title'/.test(detailsJs),
|
||||
'one delegated handler rather than an edit to each of the sixteen sections');
|
||||
assert.ok(/setCollapsedCardSection\(card\._id, key, !collapsed\)/.test(detailsJs),
|
||||
'stored under the same per-user map as the checklists');
|
||||
assert.ok(/card-details-item-\$\{|replace\('card-details-item-', ''\)/.test(detailsJs),
|
||||
"the section's own class is its key; there is no separate id to invent");
|
||||
});
|
||||
|
||||
test('a folded section hides what follows the title, whatever that is', () => {
|
||||
assert.ok(/\.card-details-item\.is-collapsed > \*:not\(\.card-details-item-title\) \{\s*\n\s*display: none;/.test(detailsCss),
|
||||
'the sections are NOT uniform inside - all sixteen open with a title, only ' +
|
||||
'three wrap the rest in a content element - so hiding the siblings is what ' +
|
||||
'works for all of them');
|
||||
assert.ok(/\.card-details-item\.is-collapsed > \.card-details-item-title::after/.test(detailsCss),
|
||||
'and the caret has to flip, or a folded section looks like an empty one');
|
||||
});
|
||||
|
||||
test('a fold survives reopening the card', () => {
|
||||
assert.ok(/collapsedCardSections/.test(detailsJs) && /is-collapsed/.test(detailsJs),
|
||||
'the click toggles a class, but a fresh render only has the stored state - ' +
|
||||
'without re-applying it, folding would last until the card was closed');
|
||||
const at = detailsJs.indexOf('Template.cardDetails.onRendered');
|
||||
const body = detailsJs.slice(at, at + 1800);
|
||||
assert.ok(/classList\.toggle\('is-collapsed'/.test(body),
|
||||
'applied on render, from the profile');
|
||||
});
|
||||
|
||||
test('the caret CSS is direction-agnostic', () => {
|
||||
// tests/rtl.test.js enforces this across component CSS; named here because a
|
||||
// caret is exactly the kind of thing that gets a physical float.
|
||||
assert.ok(!/float:\s*(left|right)/.test(css),
|
||||
'a caret floated left sits on the wrong side of an RTL card');
|
||||
assert.ok(/float:\s*inline-start/.test(css));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
// The database-conformance run must not depend on a port nothing in it uses.
|
||||
// Run: node tests/conformanceDebugPort.test.cjs
|
||||
//
|
||||
// From the 2026-08-11_16-55-04 run: EVERY backend failed before a single query
|
||||
// was compared.
|
||||
//
|
||||
// db-conformance-sqlite.log:
|
||||
// Failed to create debug handler ... listen tcp 127.0.0.1:8088:
|
||||
// bind: address already in use
|
||||
// db-conformance-summary.txt:
|
||||
// ERROR sqlite FerretDB did not start on this backend
|
||||
// ERROR postgresql FerretDB did not start on this backend
|
||||
//
|
||||
// FerretDB opens a debug handler for metrics and profiling at 127.0.0.1:8088 by
|
||||
// default, and EXITS when that address is taken. An unrelated FerretDB happened
|
||||
// to be running on the machine, so the whole conformance stage reported a
|
||||
// database problem that was nothing of the sort.
|
||||
//
|
||||
// The script already takes this seriously for the two ports it knows about - it
|
||||
// picks a free wire port, and says in its own comment that it is "chosen so this
|
||||
// can run WHILE something else is running". The debug port was simply never
|
||||
// passed, so it always took the default. Nothing in the run queries it, so it is
|
||||
// not opened at all: `--debug-addr=-` is how FerretDB's main.go spells that
|
||||
// (`if addr := cli.DebugAddr; addr != "" && addr != "-"`).
|
||||
//
|
||||
// Verified by hand against the built binary, with 8088 deliberately held: without
|
||||
// the flag FerretDB does not come up, with it it does.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const script = fs.readFileSync(
|
||||
path.join(repoRoot, 'releases/db-conformance.sh'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// Every line that starts the FerretDB being tested.
|
||||
const launches = script.split('\n').filter(l => /"\$FERRET_BIN" --handler=/.test(l));
|
||||
|
||||
test('every backend launch is found', () => {
|
||||
assert.strictEqual(launches.length, 4,
|
||||
'sqlite, postgresql, mysql and hana - if a backend is added it needs the ' +
|
||||
`same treatment; found ${launches.length}`);
|
||||
});
|
||||
|
||||
test('no launch opens the debug handler', () => {
|
||||
// Checked on the whole command, which spans continuation lines.
|
||||
const commands = script.split('"$FERRET_BIN" --handler=').slice(1)
|
||||
.map(chunk => chunk.split(';;')[0]);
|
||||
assert.strictEqual(commands.length, 4);
|
||||
for (const command of commands) {
|
||||
const handler = (command.match(/^(\w+)/) || [])[1];
|
||||
assert.ok(/--debug-addr=-/.test(command),
|
||||
`the ${handler} launch must pass --debug-addr=- : nothing in the run asks ` +
|
||||
`the debug handler anything, and leaving it on the default 8088 makes the ` +
|
||||
`whole stage fail when anything else on the machine is using it`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the wire port is still chosen to be free, as it was', () => {
|
||||
// The fix must not have replaced the care that was already there.
|
||||
assert.ok(/FERRET_PORT="\$\(free_port /.test(script),
|
||||
'the port FerretDB serves on is still moved off a busy one');
|
||||
assert.ok(/WEKAN_CONFORMANCE_PORT/.test(script) && /WEKAN_CONFORMANCE_DB_PORT/.test(script),
|
||||
'and both remain overridable');
|
||||
});
|
||||
|
||||
test('the reason is written down where the ports are explained', () => {
|
||||
assert.ok(/debug handler/.test(script) && /8088/.test(script),
|
||||
'the next person choosing ports here needs to know there is a third one');
|
||||
assert.ok(/address already in use/.test(script),
|
||||
'quoting the failure is what makes it recognisable when it recurs');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
// Email feedback (../log/wekan17/email-feedback/card-date):
|
||||
//
|
||||
// "If i write the expiration date with the keyboard it turns red, if i choose
|
||||
// it with the date picker it is yellow. Can you please tell me the difference?"
|
||||
//
|
||||
// Run: node tests/datePickerYear.test.cjs
|
||||
//
|
||||
// It is a bug, and the colour was never the bug. `<input type="date">` reports
|
||||
// its value as YYYY-MM-DD, but the browser lets the YEAR sub-field be typed as
|
||||
// two digits and reports exactly what was typed: entering 31-12-26 gives
|
||||
// "0026-12-31" - the year 26 AD. `new Date('0026-12-31T17:00:00')` is a perfectly
|
||||
// valid Date, so nothing refused it, and the card was saved with a due date two
|
||||
// thousand years in the past. Red is what an overdue date is supposed to look
|
||||
// like; picking the same day from the calendar always fills four digits, gets
|
||||
// 2026, and is yellow because it is merely due soon.
|
||||
//
|
||||
// The screenshot the reporter attached shows it directly: the yellow badges read
|
||||
// "31-12-2026 17:00" and the red ones "31-12-26 17:00" - two different years, not
|
||||
// two different colours of the same date.
|
||||
//
|
||||
// So the submit refuses a year outside a plausible window and says which digits
|
||||
// are missing. It REFUSES rather than correcting: 0026 was probably meant as
|
||||
// 2026, but silently rewriting a date somebody typed - one that other people's
|
||||
// due-date reminders hang off - is worse than telling them.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const src = fs.readFileSync(path.join(repoRoot, 'client/lib/datepicker.js'), 'utf8');
|
||||
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, 'imports/i18n/data/en.i18n.json'), 'utf8'));
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// The check as the submit handler does it.
|
||||
const MIN = Number((src.match(/const MIN_PLAUSIBLE_YEAR = (\d+);/) || [])[1]);
|
||||
const MAX = Number((src.match(/const MAX_PLAUSIBLE_YEAR = (\d+);/) || [])[1]);
|
||||
const accepted = year => year >= MIN && year <= MAX;
|
||||
|
||||
test('the plausible-year window is defined', () => {
|
||||
assert.ok(Number.isInteger(MIN) && Number.isInteger(MAX),
|
||||
'datepicker.js must define MIN_PLAUSIBLE_YEAR and MAX_PLAUSIBLE_YEAR');
|
||||
assert.ok(MIN < MAX);
|
||||
});
|
||||
|
||||
test('the reported case is refused', () => {
|
||||
// What the browser really hands over when 31-12-26 is typed.
|
||||
const typed = new Date('0026-12-31T17:00:00');
|
||||
assert.strictEqual(typed.getFullYear(), 26,
|
||||
'this is the value the date input reports - a real Date, in the year 26');
|
||||
assert.strictEqual(accepted(typed.getFullYear()), false,
|
||||
'and it must not be stored as a due date');
|
||||
});
|
||||
|
||||
test('the date the reporter meant is accepted', () => {
|
||||
const picked = new Date('2026-12-31T17:00:00');
|
||||
assert.strictEqual(picked.getFullYear(), 2026);
|
||||
assert.strictEqual(accepted(picked.getFullYear()), true,
|
||||
'the same day chosen from the calendar must go through unchanged');
|
||||
});
|
||||
|
||||
test('the window is wide enough for the dates WeKan really holds', () => {
|
||||
// Received dates can be genuinely old and deadlines genuinely far out; this is
|
||||
// only meant to catch a year that cannot have been intended.
|
||||
for (const year of [1970, 1999, 2000, 2026, 2100, 3000]) {
|
||||
assert.strictEqual(accepted(year), true, `${year} is a date somebody may mean`);
|
||||
}
|
||||
for (const year of [1, 26, 99, 999, 10000]) {
|
||||
assert.strictEqual(accepted(year), false, `${year} cannot have been intended`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the refusal happens on submit, before the date is stored', () => {
|
||||
const check = src.indexOf('MIN_PLAUSIBLE_YEAR ||');
|
||||
const store = src.indexOf('storeDate.call(tpl, newCompleteDate)');
|
||||
assert.ok(check !== -1 && store !== -1 && check < store,
|
||||
'a year that cannot be right must never reach storeDate');
|
||||
assert.ok(/tpl\.datePicker\.error\.set\('invalid-year'\)/.test(src),
|
||||
'and the field must say so, the same way invalid-date and invalid-time do');
|
||||
assert.ok(/evt\.target\.date\.focus\(\)/.test(src.slice(check, store)),
|
||||
'with the cursor put back in the date field, which is where the fix is');
|
||||
});
|
||||
|
||||
test('the message tells the user what to type', () => {
|
||||
assert.ok(en['invalid-year'], 'en.i18n.json must carry the invalid-year string');
|
||||
assert.ok(/four digits/i.test(en['invalid-year']),
|
||||
'the whole confusion was a two-digit year, so the message has to name that - ' +
|
||||
`got: ${en['invalid-year']}`);
|
||||
// The neighbouring errors are still there; this is an addition, not a rename.
|
||||
assert.ok(en['invalid-date'] && en['invalid-time']);
|
||||
});
|
||||
|
||||
test('the date is not silently corrected to the year the user probably meant', () => {
|
||||
assert.ok(!/\+ 2000|year < 100 \?|20\$\{/.test(src),
|
||||
'rewriting 0026 to 2026 would be a guess about a date other people\'s ' +
|
||||
'reminders hang off; it is refused and named instead');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
// GHSA-6p5m-f9p2-wqm5 — cross-board IDOR in the single-card Excel export.
|
||||
// Run: node tests/exportExcelCardContainment.test.cjs
|
||||
//
|
||||
// GET /api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel
|
||||
//
|
||||
// authorised against :boardId and then read the card named by :cardId, with
|
||||
// nothing tying the two together. canExport() asks "may this user see the board
|
||||
// in :boardId?" - it never sees :cardId - and _buildAndWrite() then resolved the
|
||||
// card with a bare primary-key lookup. So the two identifiers came apart: one
|
||||
// decided the authorisation, the other decided the data.
|
||||
//
|
||||
// The precondition is self-service: POST /api/boards takes `permission` straight
|
||||
// from the request body, so any authenticated user can mint their own PUBLIC
|
||||
// board, name it as :boardId, and pass the id of a card in somebody else's
|
||||
// private board as :cardId. The workbook that comes back carries that card's
|
||||
// title, description, members and assignees, every comment with its author,
|
||||
// checklists, subtasks, attachment metadata - and, because image attachments are
|
||||
// read through getReadStream() and embedded with workbook.addImage, the
|
||||
// attachment BYTES. :listId was never used in any query, so it could be
|
||||
// anything. One board, reused, made it a scriptable bulk read.
|
||||
//
|
||||
// The same route shape for PDF has always been right - ExporterCardPDF resolves
|
||||
// getCard({ _id, boardId, listId }) - which is what makes this an omission rather
|
||||
// than a decision, and is why the fix is the PDF exporter's query, not a new
|
||||
// mechanism.
|
||||
//
|
||||
// This suite is a source guard: there is no server here to issue requests to. It
|
||||
// pins the constrained lookup in both places it now lives, that the route checks
|
||||
// BEFORE either branch builds (including the public branch, which skips
|
||||
// authentication entirely), and that the PDF control it copies is still there.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
const exporter = read('models/server/ExporterExcelCard.js');
|
||||
const route = read('models/exportExcelCard.js');
|
||||
const pdf = read('models/server/ExporterCardPDF.js');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// Declarations only: the file explains the old shape at length, and prose about
|
||||
// a bare lookup must not read as one.
|
||||
const code = src => src.split('\n')
|
||||
.filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
||||
.join('\n');
|
||||
|
||||
test('the exporter resolves the card INSIDE the authorised board and list', () => {
|
||||
const body = code(exporter);
|
||||
assert.ok(/getCard\(\{\s*_id: this\._cardId,\s*boardId: this\._boardId,\s*listId: this\._listId,?\s*\}\)/.test(body),
|
||||
'the lookup must carry boardId and listId, so a card outside the authorised ' +
|
||||
'board does not resolve at all');
|
||||
assert.ok(!/getCard\(this\._cardId\)/.test(body),
|
||||
'the bare primary-key lookup is the vulnerability itself');
|
||||
});
|
||||
|
||||
test('nothing downstream can be reached when the card does not resolve', () => {
|
||||
// The fan-out (checklists, checklist items, subtasks, comments, attachments)
|
||||
// is all keyed on the same card id and carries no board constraint of its own,
|
||||
// so constraining the CARD is what makes those safe by construction.
|
||||
const body = code(exporter);
|
||||
const lookup = body.indexOf('getCard({');
|
||||
const guard = body.indexOf("res.end('Card not found')");
|
||||
const fanout = body.indexOf('getChecklists({ cardId: this._cardId })');
|
||||
assert.ok(lookup !== -1 && guard > lookup,
|
||||
'the 404 must still follow the lookup');
|
||||
assert.ok(fanout === -1 || fanout > guard,
|
||||
'and every query keyed on the card id must come after it');
|
||||
});
|
||||
|
||||
test('the route binds the two identifiers before EITHER branch builds', () => {
|
||||
const body = code(route);
|
||||
assert.ok(/getCard\(\{\s*_id: paramCardId,\s*boardId,\s*listId: paramListId,?\s*\}\)/.test(body),
|
||||
'the route has both identifiers in hand, so it states their relationship');
|
||||
const check = body.indexOf('_id: paramCardId');
|
||||
const publicBranch = body.indexOf('board.isPublic()');
|
||||
const firstBuild = body.indexOf('exporter.build(res)');
|
||||
assert.ok(check !== -1 && check < publicBranch,
|
||||
'the PUBLIC branch skips authentication entirely, so the containment check ' +
|
||||
'has to come before it, not inside the authenticated path');
|
||||
assert.ok(check < firstBuild, 'and before anything is exported');
|
||||
});
|
||||
|
||||
test('a card that is not in the board is a 404, not a 403', () => {
|
||||
const body = code(route);
|
||||
const at = body.indexOf('_id: paramCardId');
|
||||
const after = body.slice(at, at + 400);
|
||||
assert.ok(/404/.test(after),
|
||||
'whether a card id exists at all is not something an unauthorised caller ' +
|
||||
'should learn from the difference between 403 and 404');
|
||||
assert.ok(/Card not found/.test(after));
|
||||
});
|
||||
|
||||
test('the PDF exporter this copies is unchanged', () => {
|
||||
assert.ok(/getCard\(\{\s*\n?\s*_id: this\._cardId,\s*\n?\s*boardId: this\._boardId,\s*\n?\s*listId: this\._listId,?\s*\n?\s*\}\)/.test(code(pdf)),
|
||||
'it is the control that showed the Excel exporter was an omission; if it ' +
|
||||
'ever loses the constraint, the same hole opens on the PDF route');
|
||||
});
|
||||
|
||||
test('the two single-card export routes are the only ones of this shape', () => {
|
||||
// The reporter recommended auditing every /api/boards/:boardId/.../:childId
|
||||
// route for the same authorize-on-parent / read-on-child mismatch.
|
||||
const files = ['models/exportExcelCard.js', 'models/exportPDF.js'];
|
||||
for (const file of files) {
|
||||
assert.ok(/cards\/:cardId\/export/.test(read(file)),
|
||||
`${file} should still be a single-card export route`);
|
||||
}
|
||||
// The attachment API takes the card id and then checks it against the named
|
||||
// board - the correct direction, and already in place.
|
||||
assert.ok(/card\.boardId !== boardId/.test(read('server/attachmentApi.js')),
|
||||
'the attachment handler already enforces containment; if that check goes, ' +
|
||||
'the same class of bug reopens there');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
// Guard for wekan/wekan#6582: a compose file that runs FerretDB as a replica set
|
||||
// must connect to it with directConnection=true.
|
||||
// Run: node tests/ferretdbDirectConnection.test.cjs
|
||||
//
|
||||
// The reported failure, on nothing but `docker compose up -d` with the default
|
||||
// docker-compose.yml and no change beyond a port and ROOT_URL:
|
||||
//
|
||||
// MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
|
||||
// reason: TopologyDescription { type: 'ReplicaSetNoPrimary',
|
||||
// servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
|
||||
// setName: 'rs0', ... }
|
||||
//
|
||||
// Note the address: 0.0.0.0, which is in NO compose file. It is FerretDB's
|
||||
// LISTEN address, and the driver got it from the server.
|
||||
//
|
||||
// The ferretdb service runs with `--repl-set-name=rs0` (added for #6480/#6481 so
|
||||
// Meteor can tail an OpLog instead of poll-and-diff). FerretDB then answers the
|
||||
// hello handshake as a one-member replica set and fills `hosts`, `me` and
|
||||
// `primary` with its own --listen-addr, which is the wildcard 0.0.0.0:27017.
|
||||
// A MongoDB driver that is not in directConnection mode does replica-set
|
||||
// DISCOVERY on that reply: it adopts the advertised member list and DROPS the
|
||||
// seed it was given (the "me mismatch" rule - the server says it is called
|
||||
// something other than what the client dialled). So `mongodb://ferretdb:27017`
|
||||
// becomes `0.0.0.0:27017`, which inside the wekan container is the wekan
|
||||
// container itself, where nothing listens. Verified against FerretDB v1.49.0 and
|
||||
// the mongodb driver the bundle ships:
|
||||
//
|
||||
// mongodb://localhost:27017/wekan -> servers: 0.0.0.0:27017 (seed discarded)
|
||||
// mongodb://localhost:27017/wekan?directConnection=true -> servers: localhost:27017
|
||||
//
|
||||
// directConnection=true keeps the driver on exactly the host it was given, and
|
||||
// costs nothing else: the handshake still carries setName: 'rs0', which is the
|
||||
// only thing Meteor's oplog tailing checks (packages/mongo/oplog_replica_set_error.js
|
||||
// accepts the URL when `ismaster.setName` is set), so #6480/#6481 still work.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const composeFiles = fs.readdirSync(repoRoot)
|
||||
.filter(f => /^docker-compose.*\.ya?ml$/.test(f))
|
||||
.sort();
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
test('the compose files are found', () => {
|
||||
assert.ok(composeFiles.length >= 5,
|
||||
`expected the docker-compose*.yml set, found ${composeFiles.length}`);
|
||||
assert.ok(composeFiles.includes('docker-compose.yml'), 'the default compose file must be there');
|
||||
});
|
||||
|
||||
// A compose file "runs FerretDB as a replica set" when its ferretdb service is
|
||||
// started with --repl-set-name. That flag is what makes the handshake advertise
|
||||
// a replica set at all, and so it is what makes discovery kick in.
|
||||
function declaresReplSet(text) {
|
||||
return /--repl-set-name[= ]/.test(text);
|
||||
}
|
||||
|
||||
// Only URLs that point at THIS compose file's own ferretdb service matter. A
|
||||
// MongoDB service (docker-compose-mongodb-v7.yml, the multitenancy example) has
|
||||
// a real replica set with a real, resolvable member list, and must keep using it.
|
||||
function ferretdbUrls(text) {
|
||||
const urls = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*#?\s*-\s*(MONGO_URL|MONGO_OPLOG_URL)=(\S+)/);
|
||||
if (!m) continue;
|
||||
const [, name, url] = m;
|
||||
if (!/@?(wekan-)?ferretdb:/.test(url)) continue;
|
||||
urls.push({ name, url, commented: /^\s*#/.test(line) });
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
for (const file of composeFiles) {
|
||||
const text = fs.readFileSync(path.join(repoRoot, file), 'utf8');
|
||||
if (!declaresReplSet(text)) continue;
|
||||
|
||||
const urls = ferretdbUrls(text);
|
||||
|
||||
test(`${file}: its ferretdb URLs are found`, () => {
|
||||
assert.ok(urls.length > 0,
|
||||
`${file} starts FerretDB with --repl-set-name but has no MONGO_URL pointing at it`);
|
||||
assert.ok(urls.some(u => u.name === 'MONGO_URL' && !u.commented),
|
||||
`${file} has no ACTIVE MONGO_URL for its ferretdb service`);
|
||||
});
|
||||
|
||||
test(`${file}: every ferretdb URL carries directConnection=true`, () => {
|
||||
const missing = urls
|
||||
.filter(u => !/[?&]directConnection=true(&|$)/.test(u.url))
|
||||
.map(u => `${u.name}=${u.url}${u.commented ? ' (commented example)' : ''}`);
|
||||
assert.deepStrictEqual(missing, [],
|
||||
`${file} runs FerretDB with --repl-set-name, so a driver without ` +
|
||||
`directConnection=true throws away this host and dials the advertised ` +
|
||||
`0.0.0.0:27017 instead (#6582). Missing on:\n ` + missing.join('\n '));
|
||||
});
|
||||
}
|
||||
|
||||
test('a compose file that does NOT run a replica-set FerretDB is left alone', () => {
|
||||
// FerretDB v2 is not started with --repl-set-name, so its handshake advertises
|
||||
// no replica set, no discovery happens, and directConnection is not needed.
|
||||
// Pinned so that adding --repl-set-name there is a decision, not an accident.
|
||||
const v2 = path.join(repoRoot, 'docker-compose-ferretdb-v2-postgresql.yml');
|
||||
if (!fs.existsSync(v2)) return;
|
||||
const text = fs.readFileSync(v2, 'utf8');
|
||||
if (declaresReplSet(text)) {
|
||||
const urls = ferretdbUrls(text).filter(u => !/directConnection=true/.test(u.url));
|
||||
assert.deepStrictEqual(urls.map(u => u.url), [],
|
||||
'FerretDB v2 gained --repl-set-name, so its URLs need directConnection=true too');
|
||||
}
|
||||
});
|
||||
|
||||
// The MongoDB compose files must NOT be "fixed" the same way: those are real
|
||||
// replica sets whose members are configured with resolvable names, and
|
||||
// directConnection would pin Meteor to one node of them.
|
||||
test('the MongoDB compose files keep replicaSet= and gain no directConnection', () => {
|
||||
for (const file of ['docker-compose-mongodb-v7.yml', 'docker-compose-multitenancy.yml']) {
|
||||
const p = path.join(repoRoot, file);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const text = fs.readFileSync(p, 'utf8');
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-\s*(MONGO_URL|MONGO_OPLOG_URL)=(\S+)/);
|
||||
if (!m) continue;
|
||||
if (/(wekan-)?ferretdb:/.test(m[2])) continue;
|
||||
assert.ok(!/directConnection=true/.test(m[2]),
|
||||
`${file}: ${m[1]} points at a real MongoDB replica set, whose members ARE ` +
|
||||
`reachable by the names it advertises; directConnection would pin Meteor to ` +
|
||||
`a single node of it. Line: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,201 @@
|
||||
'use strict';
|
||||
|
||||
// wekan/wekan#6583 "Missing Data after Upgrade from v6 to latest Release":
|
||||
// after upgrading, WeKan showed "the state of the data from days ago" and the
|
||||
// reporter suspected a `snap revert` done four weeks earlier.
|
||||
// Run: node tests/ferretdbMigrationStale.test.cjs
|
||||
//
|
||||
// They were right about the cause and it is not data loss. The MongoDB ->
|
||||
// FerretDB migration copies MongoDB into SQLite ONCE and writes
|
||||
// .migration-to-ferretdb-done; nothing keeps that copy in step afterwards. Revert
|
||||
// the snap to a revision that runs mongod and WeKan carries on writing to
|
||||
// MongoDB - for four weeks, in this report - while the finished SQLite sits
|
||||
// frozen at the date it was made. Refresh forward again and mongodb-control saw
|
||||
// a marker plus a non-empty SQLite, called that a completed migration, and
|
||||
// switched the snap onto it: every board and card written during the revert was
|
||||
// still on disk and simply not being served.
|
||||
//
|
||||
// Nothing was ever in danger. The database lives in $SNAP_COMMON, which is shared
|
||||
// across revisions and is NOT rolled back by `snap revert` (unlike $SNAP_DATA,
|
||||
// which is per-revision). What was wrong was WHICH of the two copies got served,
|
||||
// and nothing compared their ages.
|
||||
//
|
||||
// So: never serve a migrated copy that is older than the MongoDB beside it. If
|
||||
// mongod can run, keep MongoDB, which has the newest data. If mongod CANNOT run
|
||||
// (the AVX case that forces the migration in the first place), migrate again from
|
||||
// scratch rather than serve the stale copy.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const stale = path.join(repoRoot, 'snap-src/bin/ferretdb-migration-stale');
|
||||
const mongodbControl = fs.readFileSync(path.join(repoRoot, 'snap-src/bin/mongodb-control'), 'utf8');
|
||||
const wekanControl = fs.readFileSync(path.join(repoRoot, 'snap-src/bin/wekan-control'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
test('the detector exists, is executable, and parses', () => {
|
||||
assert.ok(fs.existsSync(stale), 'snap-src/bin/ferretdb-migration-stale is missing');
|
||||
assert.ok(fs.statSync(stale).mode & 0o111, 'it must be executable');
|
||||
const check = spawnSync('bash', ['-n', stale]);
|
||||
assert.strictEqual(check.status, 0, `bash -n failed: ${check.stderr}`);
|
||||
});
|
||||
|
||||
// A $SNAP_COMMON as the snap really lays it out: WiredTiger files at the top,
|
||||
// the FerretDB SQLite under files/db, the marker beside them.
|
||||
function makeCommon({ mongoAt, migratedAt, sqlite = true, mongo = true, extra = {} }) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-common-'));
|
||||
fs.mkdirSync(path.join(dir, 'files/db'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'journal'), { recursive: true });
|
||||
if (mongo) {
|
||||
for (const f of ['WiredTiger', '_mdb_catalog.wt', 'collection-0.wt']) {
|
||||
fs.writeFileSync(path.join(dir, f), 'x');
|
||||
fs.utimesSync(path.join(dir, f), new Date(mongoAt), new Date(mongoAt));
|
||||
}
|
||||
}
|
||||
if (sqlite) fs.writeFileSync(path.join(dir, 'files/db/wekan.sqlite'), 'data');
|
||||
if (migratedAt) {
|
||||
const marker = path.join(dir, '.migration-to-ferretdb-done');
|
||||
fs.writeFileSync(marker, '');
|
||||
fs.utimesSync(marker, new Date(migratedAt), new Date(migratedAt));
|
||||
}
|
||||
for (const [name, when] of Object.entries(extra)) {
|
||||
fs.writeFileSync(path.join(dir, name), 'x');
|
||||
fs.utimesSync(path.join(dir, name), new Date(when), new Date(when));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Exit 0 = stale. Anything else = current / cannot tell.
|
||||
function isStale(dir) {
|
||||
return spawnSync('bash', [stale, dir], { encoding: 'utf8' }).status === 0;
|
||||
}
|
||||
|
||||
function withCommon(opts, fn) {
|
||||
const dir = makeCommon(opts);
|
||||
try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
const JULY = '2026-07-14T12:00:00Z';
|
||||
const AUGUST = '2026-08-11T09:00:00Z';
|
||||
|
||||
test('the reported case: MongoDB written to for weeks after the migration', () => {
|
||||
withCommon({ migratedAt: JULY, mongoAt: AUGUST }, dir => {
|
||||
assert.strictEqual(isStale(dir), true,
|
||||
'a migration from four weeks before the newest MongoDB write is behind it, ' +
|
||||
'and serving it is what showed "the state of the data from days ago"');
|
||||
const out = spawnSync('bash', [stale, dir], { encoding: 'utf8' }).stderr;
|
||||
assert.ok(/BEHIND the MongoDB data/.test(out), `it must say so: ${out}`);
|
||||
assert.ok(/day\(s\) later/.test(out), 'and by how much');
|
||||
});
|
||||
});
|
||||
|
||||
test('a migration newer than the data is current', () => {
|
||||
withCommon({ migratedAt: AUGUST, mongoAt: JULY }, dir => {
|
||||
assert.strictEqual(isStale(dir), false,
|
||||
'the normal case - migrate, then stop using MongoDB - must not be called stale');
|
||||
});
|
||||
});
|
||||
|
||||
test('the migration stopping its own source mongod does not count as staleness', () => {
|
||||
// The migration starts a temporary mongod to read from and stops it again, and
|
||||
// that shutdown writes to the data directory moments AFTER the marker appears.
|
||||
// Without a margin every fresh migration would look stale and the snap would
|
||||
// migrate in a loop.
|
||||
withCommon({ migratedAt: '2026-08-11T09:56:00Z', mongoAt: '2026-08-11T10:00:00Z' }, dir => {
|
||||
assert.strictEqual(isStale(dir), false, 'four minutes later is the migration itself');
|
||||
});
|
||||
withCommon({ migratedAt: '2026-08-11T09:30:00Z', mongoAt: '2026-08-11T10:00:00Z' }, dir => {
|
||||
assert.strictEqual(isStale(dir), true, 'half an hour later is somebody using MongoDB');
|
||||
});
|
||||
});
|
||||
|
||||
test('only DATABASE files count, not a log or a pid file', () => {
|
||||
// $SNAP_COMMON also holds mongodb.log and mongodb.pid, which starting the snap
|
||||
// touches. Counting those would call every migration stale on the next boot.
|
||||
withCommon({
|
||||
migratedAt: '2026-08-11T09:30:00Z',
|
||||
mongoAt: '2026-08-11T09:00:00Z',
|
||||
extra: { 'mongodb.log': '2026-08-11T10:00:00Z', 'mongodb.pid': '2026-08-11T10:00:00Z' },
|
||||
}, dir => {
|
||||
assert.strictEqual(isStale(dir), false,
|
||||
'a newer log file says the snap was started, not that the database was written to');
|
||||
});
|
||||
});
|
||||
|
||||
test('nothing to compare is never reported as stale', () => {
|
||||
// Each of these must answer "not stale": being unsure has to leave the existing
|
||||
// behaviour alone, because the caller acts on a yes.
|
||||
withCommon({ mongoAt: AUGUST, migratedAt: null }, dir =>
|
||||
assert.strictEqual(isStale(dir), false, 'no marker: no migration to be behind'));
|
||||
withCommon({ migratedAt: JULY, mongoAt: AUGUST, sqlite: false }, dir =>
|
||||
assert.strictEqual(isStale(dir), false,
|
||||
'the marker is written even when the migration produced no data; an empty ' +
|
||||
'FerretDB is the OTHER guard\'s business'));
|
||||
withCommon({ migratedAt: JULY, mongoAt: AUGUST, mongo: false }, dir =>
|
||||
assert.strictEqual(isStale(dir), false,
|
||||
'a FerretDB-only install has no MongoDB to have fallen behind'));
|
||||
assert.strictEqual(isStale(path.join(os.tmpdir(), 'wekan-nonexistent-dir')), false,
|
||||
'a directory that is not there must not be called stale either');
|
||||
});
|
||||
|
||||
// ── the two places that act on it ───────────────────────────────────────────
|
||||
|
||||
test('mongodb-control will not switch to a stale migration', () => {
|
||||
const at = mongodbControl.indexOf('switch_to_completed_ferretdb() {');
|
||||
assert.notStrictEqual(at, -1, 'mongodb-control must still have that function');
|
||||
const body = mongodbControl.slice(at, mongodbControl.indexOf('\n}', at));
|
||||
assert.ok(/ferretdb-migration-stale/.test(body),
|
||||
'the switch must consult the check - "a completed migration exists" was the ' +
|
||||
'whole of its test, and completed is not the same as up to date');
|
||||
const guard = body.indexOf('ferretdb-migration-stale');
|
||||
const doSwitch = body.indexOf('snapctl set database=ferretdb');
|
||||
assert.ok(guard !== -1 && guard < doSwitch,
|
||||
'and consult it BEFORE switching, not after');
|
||||
assert.ok(/return 1/.test(body.slice(guard, doSwitch)),
|
||||
'a stale migration must leave MongoDB running, which has the newest data');
|
||||
});
|
||||
|
||||
test('when mongod cannot run at all, a stale migration is redone rather than served', () => {
|
||||
const at = mongodbControl.indexOf('handle_mongod_start_failure() {');
|
||||
const body = mongodbControl.slice(at, mongodbControl.indexOf('\n# #6458', at));
|
||||
assert.ok(/stale_migration/.test(body),
|
||||
'keeping MongoDB is not on offer when mongod will not start, so the choice is ' +
|
||||
'between a fresh copy and a stale one');
|
||||
for (const cleared of ['.migration-to-ferretdb-done', 'migration-progress.json', 'files/db/']) {
|
||||
assert.ok(body.includes(cleared),
|
||||
`a stale migration must be RESTARTED, so ${cleared} has to go - migration-control ` +
|
||||
'exits immediately while the marker is there, and the checkpoint describes a ' +
|
||||
'finished copy of the old data');
|
||||
}
|
||||
assert.ok(!/rm -rf "\$SNAP_COMMON"\/collection|rm .*WiredTiger/.test(body),
|
||||
'and the MongoDB data itself must never be touched');
|
||||
});
|
||||
|
||||
test('wekan-control keeps MongoDB when the migrated copy is behind it', () => {
|
||||
assert.ok(/ferretdb-migration-stale/.test(wekanControl),
|
||||
'wekan-control guards against an EMPTY FerretDB already; a full but out-of-date ' +
|
||||
'one looks worse, because WeKan comes up with only the last weeks missing');
|
||||
const at = wekanControl.indexOf('ferretdb-migration-stale');
|
||||
const after = wekanControl.slice(at, at + 1600);
|
||||
assert.ok(/export DATABASE="mongodb"/.test(after) &&
|
||||
/snapctl set database=mongodb/.test(after),
|
||||
'it must fall back to MongoDB, which has the newest data');
|
||||
assert.ok(/snap revert. does not roll back|not rolled back by a revert/.test(after),
|
||||
'and say that nothing is lost - $SNAP_COMMON is shared across revisions - or the ' +
|
||||
'message reads as a report of data loss');
|
||||
});
|
||||
|
||||
test('the detector ships inside the snap', () => {
|
||||
const snapcraft = fs.readFileSync(path.join(repoRoot, 'snapcraft.yaml'), 'utf8');
|
||||
assert.ok(/helpers:\s*\n\s*source: snap-src\s*\n\s*plugin: dump/.test(snapcraft),
|
||||
'snap-src is dumped wholesale, which is what puts bin/ferretdb-migration-stale ' +
|
||||
'in $SNAP/bin - if that part ever stops being a dump, the callers break');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -141,9 +141,18 @@ test('the detail names the operators and nothing else', () => {
|
||||
// ---------------------------------------------------------------- the wiring
|
||||
|
||||
test('the windowed card publication trips a canary on an injected selector', () => {
|
||||
// GHSA-phm4-4v26-j2vq moved selectorIsInjection out of this publication and into
|
||||
// /server/lib/selectorGuard, because eight OTHER handlers took the same
|
||||
// client-supplied selector and never called it. The publication's behaviour is
|
||||
// unchanged - it imports the same function - so what is pinned here is that the
|
||||
// definition exists in one place and that both refusal sites still use it.
|
||||
const src = read('server/publications/cardsWindow.js');
|
||||
assert.ok(/function selectorIsInjection\(selector, where\)/.test(src));
|
||||
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(src));
|
||||
const guard = read('server/lib/selectorGuard.js');
|
||||
assert.ok(/function selectorIsInjection\(selector, where\)/.test(guard),
|
||||
'the one definition lives in the shared guard now');
|
||||
assert.ok(/import \{ selectorIsInjection \} from '\/server\/lib\/selectorGuard'/.test(src),
|
||||
'and the publication imports it rather than keeping a copy');
|
||||
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(guard));
|
||||
// Both refusal sites go through it.
|
||||
const uses = src.match(/selectorIsInjection\(cardSelector, '/g) || [];
|
||||
assert.strictEqual(uses.length, 2, 'the window AND the count');
|
||||
@@ -155,13 +164,17 @@ test('SILENT: the publication still answers exactly as before', () => {
|
||||
// caller cannot tell the canary is there.
|
||||
assert.ok(/\? \{ _id: \{ \$in: \[\] \} \}/.test(src));
|
||||
assert.ok(/if \(!board \|\| selectorIsInjection\(cardSelector, 'boardCardsCount'\)\) \{\s*\n\s*return this\.ready\(\);/.test(src));
|
||||
assert.ok(/return true;/.test(src.match(/function selectorIsInjection[\s\S]*?\n\}/)[0]),
|
||||
const guard = read('server/lib/selectorGuard.js');
|
||||
assert.ok(/return true;/.test(guard.match(/function selectorIsInjection[\s\S]*?\n\}/)[0]),
|
||||
'and it returns the same boolean hasWhere() did');
|
||||
});
|
||||
|
||||
test('the old $where-only check is still honoured, not replaced', () => {
|
||||
const src = read('server/publications/cardsWindow.js');
|
||||
assert.ok(/hasWhere\(selector\)/.test(src),
|
||||
// Looked for in the shared guard now (GHSA-phm4-4v26-j2vq moved the function
|
||||
// there); the point is unchanged - hasWhere is the narrower, older check and the
|
||||
// widened classifier must not quietly replace it.
|
||||
const guard = read('server/lib/selectorGuard.js');
|
||||
assert.ok(/hasWhere\(selector\)/.test(guard),
|
||||
'the narrower existing guard stays: a widened detector must not lose a case');
|
||||
});
|
||||
|
||||
|
||||
@@ -106,13 +106,54 @@ test('a vertical swipe in those scrollers is a scroll, and stays in them', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile mode sizes the body and the content area the same way', () => {
|
||||
test('the mobile body is exactly one viewport, stated in dvh as well as vh', () => {
|
||||
// `position: fixed; height: 100vh` on the body has the same problem, and it is
|
||||
// what the whole mobile layout hangs from.
|
||||
const body = layouts.slice(layouts.indexOf('body.mobile-mode {'));
|
||||
assert.ok(/height: 100vh;[\s\S]{0,400}height: 100dvh;/.test(body));
|
||||
const content = layouts.slice(layouts.indexOf('body.mobile-mode #content {'));
|
||||
assert.ok(/height: calc\(100vh - 48px\);\s*\n\s*height: calc\(100dvh - 48px\);/.test(content));
|
||||
});
|
||||
|
||||
test('#content is sized by the flex column, not by a guess at the header height', () => {
|
||||
// CHANGED DELIBERATELY, and the change is the fix for #6488 rather than a
|
||||
// relaxation of this guard. This used to REQUIRE
|
||||
// height: calc(100vh - 48px); height: calc(100dvh - 48px);
|
||||
// and getting the dvh fallback right there was the whole point - but the rule
|
||||
// was wrong in a way no dvh can repair: `- 48px` is a guess at how tall the
|
||||
// header is, and the header is not 48px and is not any one number. Utils
|
||||
// publishes --wekan-header-height from a ResizeObserver precisely because the
|
||||
// quick-access bar wraps to a second and third row depending on language and
|
||||
// width, and its comment says every fixed number for it has been wrong.
|
||||
//
|
||||
// On a phone with a wrapped header, #content was therefore TALLER than the room
|
||||
// under it, so its bottom sat below the screen - and body.mobile-mode is
|
||||
// `position: fixed` and `overflow: hidden`, so that strip is unreachable. The
|
||||
// `height: 100%` chain below it inherited the error and the last boards ended
|
||||
// up where no gesture could reach: "at smartphone, at All Boards page, it is
|
||||
// not possible to scroll down to see remaining of boards."
|
||||
//
|
||||
// body is a flex column of exactly one viewport and #content is its `flex: 1`
|
||||
// item, so the space under the header ALREADY is this box, at whatever height
|
||||
// the header really is. So what is pinned now is the absence of the arithmetic.
|
||||
const contentAt = layouts.indexOf('body.mobile-mode #content {');
|
||||
assert.notStrictEqual(contentAt, -1, 'layouts.css has no body.mobile-mode #content rule');
|
||||
// Comments stripped first: the rule explains at length what it used to say,
|
||||
// and quoting the old declaration must not read as still declaring it.
|
||||
const content = layouts.slice(contentAt, layouts.indexOf('}', contentAt))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
assert.ok(!/height:\s*calc\(/.test(content),
|
||||
'body.mobile-mode #content must not compute a height from the viewport: a ' +
|
||||
'fixed number for the header is what put the bottom of the page off screen');
|
||||
assert.ok(/min-height:\s*0;/.test(content),
|
||||
'it must be allowed to be shorter than its content, or the inner scroller ' +
|
||||
'below it can never scroll');
|
||||
// ...and the flex item it depends on is really there.
|
||||
const base = layouts.slice(layouts.indexOf('\n#content {'));
|
||||
assert.ok(/flex:\s*1;/.test(base.slice(0, base.indexOf('}'))),
|
||||
'#content must stay the flex: 1 item of the body column - that is what now ' +
|
||||
'gives it the height the calc() was guessing at');
|
||||
const bodyRule = layouts.slice(layouts.indexOf('\nbody {'));
|
||||
assert.ok(/flex-direction:\s*column;/.test(bodyRule.slice(0, bodyRule.indexOf('}'))),
|
||||
'and body must stay a flex column, or #content has no share to take');
|
||||
});
|
||||
|
||||
test('the board tiles still drag from their handle only, on a touch screen', () => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use strict';
|
||||
|
||||
// #6533 — FerretDB at 737% CPU and constant SQLITE_BUSY on a snap instance.
|
||||
// Run: node tests/notificationCap.test.cjs
|
||||
//
|
||||
// The reporter's logs name the write every time:
|
||||
//
|
||||
// [db-retry] the database was busy: 0 write(s) retried, 0 succeeded on a retry,
|
||||
// 3 given up on.
|
||||
// ValidationError: ... [collection.go:191 sqlite.(*collection).UpdateAll]
|
||||
// database is locked (5) (SQLITE_BUSY)
|
||||
// at _helpersConstructor.addNotification (models/users.js:2248)
|
||||
//
|
||||
// addNotification is `$addToSet` on `profile.notifications` — an array INSIDE the
|
||||
// user document. Adding one means reading the whole document, scanning the array
|
||||
// for a duplicate and writing the whole document back, so the cost grows with the
|
||||
// array. FerretDB on SQLite has a single writer, so those rewrites queue behind
|
||||
// each other, start failing with SQLITE_BUSY, and login — which also writes to the
|
||||
// user document — queues behind them. That is the slow login and the pinned CPU.
|
||||
//
|
||||
// WHY THE ARRAY GROWS FOREVER: the existing cleanup (#5685) only removes
|
||||
// notifications that have been READ and are past their removal age. A user who
|
||||
// never clears their tray accumulates entries with no upper bound at all, and
|
||||
// nothing in the product ever shrinks it.
|
||||
//
|
||||
// So the cleanup now also CAPS the array. This suite pins the cap's decisions,
|
||||
// which is where the judgement is: what to keep, what never to touch, and that a
|
||||
// user needing no change is not written to at all.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const {
|
||||
cappedNotifications,
|
||||
notificationCapFromEnv,
|
||||
expiredNotificationActivityIds,
|
||||
} = require(path.join(repoRoot, 'models/lib/notificationCleanup.js'));
|
||||
const serverSrc = fs.readFileSync(
|
||||
path.join(repoRoot, 'server/models/users.js'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
const notif = (n, read = null) => ({ activity: `a${n}`, read });
|
||||
|
||||
test('an array under the cap is left completely alone', () => {
|
||||
const list = [notif(1), notif(2), notif(3)];
|
||||
assert.strictEqual(cappedNotifications(list, 10), null,
|
||||
'null means "nothing to do" - the caller must then skip the write entirely, ' +
|
||||
'because rewriting the array with itself is exactly the write this is about');
|
||||
assert.strictEqual(cappedNotifications(list, 3), null, 'exactly at the cap is not over it');
|
||||
});
|
||||
|
||||
test('an oversized array keeps the NEWEST entries', () => {
|
||||
const list = [notif(1), notif(2), notif(3), notif(4), notif(5)];
|
||||
const kept = cappedNotifications(list, 2);
|
||||
assert.deepStrictEqual(kept.map(n => n.activity), ['a4', 'a5'],
|
||||
'$addToSet appends, so the tail is the newest - and an unread notification ' +
|
||||
'from long ago is not something anyone is going to act on');
|
||||
assert.strictEqual(kept.length, 2);
|
||||
});
|
||||
|
||||
test('unread entries are capped too - that is the whole point', () => {
|
||||
// The existing rule cannot touch these: it only removes READ ones.
|
||||
const unread = Array.from({ length: 50 }, (_, i) => notif(i));
|
||||
assert.deepStrictEqual(expiredNotificationActivityIds(unread, 2, new Date()), [],
|
||||
'confirming the existing rule leaves every unread entry in place');
|
||||
const kept = cappedNotifications(unread, 10);
|
||||
assert.strictEqual(kept.length, 10, 'so the cap has to be what bounds them');
|
||||
});
|
||||
|
||||
test('junk input never produces a write', () => {
|
||||
for (const bad of [null, undefined, 'nope', 42, {}]) {
|
||||
assert.strictEqual(cappedNotifications(bad, 10), null,
|
||||
`${JSON.stringify(bad)} must be left alone rather than turned into an array`);
|
||||
}
|
||||
assert.strictEqual(cappedNotifications([notif(1), notif(2)], 0), null,
|
||||
'a cap of 0 disables capping instead of deleting everything');
|
||||
assert.strictEqual(cappedNotifications([notif(1), notif(2)], -5), null,
|
||||
'and so does a negative one');
|
||||
});
|
||||
|
||||
test('the default cap is generous, and configurable', () => {
|
||||
assert.strictEqual(notificationCapFromEnv({}), 1000,
|
||||
'this is a backstop against unbounded growth, not a retention policy');
|
||||
assert.strictEqual(notificationCapFromEnv({ NOTIFICATION_TRAY_MAX_PER_USER: '50' }), 50);
|
||||
for (const bad of ['', 'abc', '0', '-1', undefined]) {
|
||||
assert.strictEqual(notificationCapFromEnv({ NOTIFICATION_TRAY_MAX_PER_USER: bad }), 1000,
|
||||
`a nonsense setting (${JSON.stringify(bad)}) must fall back to the default, ` +
|
||||
'not disable the backstop');
|
||||
}
|
||||
});
|
||||
|
||||
// ── how the cleanup uses it ─────────────────────────────────────────────────
|
||||
|
||||
test('the cap is applied to what is left AFTER the expiry pull', () => {
|
||||
assert.ok(/const remaining = activityIds\.length/.test(serverSrc),
|
||||
'capping the array before the $pull would trim entries that were about to be ' +
|
||||
'removed anyway, and keep fewer than the cap');
|
||||
assert.ok(/cappedNotifications\(remaining, maxPerUser\)/.test(serverSrc));
|
||||
});
|
||||
|
||||
test('a user who needs neither is not written to', () => {
|
||||
assert.ok(/if \(activityIds\.length === 0 && kept === null\) continue;/.test(serverSrc),
|
||||
'the whole problem is writes to the user document; the cleanup must not add ' +
|
||||
'one per user per day for users with nothing to change');
|
||||
});
|
||||
|
||||
test('it stays ONE write per user', () => {
|
||||
const at = serverSrc.indexOf('const maxPerUser = notificationCapFromEnv');
|
||||
const body = serverSrc.slice(at, serverSrc.indexOf('const startNotificationCleanup', at));
|
||||
const updates = (body.match(/await Users\.updateAsync\(/g) || []).length;
|
||||
assert.strictEqual(updates, 1,
|
||||
'#5685 collapsed K writes per user into one; the cap must not undo that by ' +
|
||||
'adding a second update alongside the $pull');
|
||||
assert.ok(/\$set: \{ 'profile\.notifications': kept \}/.test(body),
|
||||
'the capped case replaces the array outright - a $pull would have to list the ' +
|
||||
'entries it removes, and there being too many of them is the problem');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,317 @@
|
||||
'use strict';
|
||||
|
||||
// Guard for releases/reindex-charts.py - the tool that rebuilds the wekan/charts
|
||||
// Helm index from the packages that repository actually serves.
|
||||
// Run: node tests/reindexCharts.test.cjs
|
||||
//
|
||||
// The index had drifted from its own packages in four ways at once, and every
|
||||
// one of them came from the same habit: release-charts.sh writes an entry by
|
||||
// COPYING the previous entry and substituting a few fields, so the index was
|
||||
// being treated as the record rather than as a description of the .tgz files
|
||||
// beside it.
|
||||
//
|
||||
// 146 packages had no entry at all - downloadable by URL, invisible to
|
||||
// `helm search`, `helm install --version` and Artifact Hub.
|
||||
// 9.36.0 had four entries and 10.30.0 two, each with a different digest and
|
||||
// the same url, so a helm client could pick a digest that matches no file
|
||||
// and fail integrity on a good package.
|
||||
// appVersion was the CHART version ("10.79.0") on every entry, where the
|
||||
// package says the WeKan version ("10.79").
|
||||
// Every entry claimed mongodb 0.7.2 because nobody substituted that field,
|
||||
// while the packages had moved to 0.7.6.
|
||||
//
|
||||
// So the rebuild reads each package's own Chart.yaml out of the archive. The
|
||||
// rules that matter are checked here by running the tool against a synthesised
|
||||
// charts directory: it must never invent a `created`, never drop a version, and
|
||||
// never write an index in which one version means two files.
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const script = path.join(repoRoot, 'releases/reindex-charts.py');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// python3 with PyYAML is what the tool needs; without it the behaviour tests
|
||||
// cannot run, and saying so is better than reporting a pass that did not happen.
|
||||
const hasPyYaml = spawnSync('python3', ['-c', 'import yaml'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
test('the tool exists and is executable', () => {
|
||||
assert.ok(fs.existsSync(script), 'releases/reindex-charts.py is missing');
|
||||
assert.ok(fs.statSync(script).mode & 0o111, 'reindex-charts.py must be executable');
|
||||
});
|
||||
|
||||
test('it writes nothing unless asked', () => {
|
||||
const src = fs.readFileSync(script, 'utf8');
|
||||
assert.ok(/if not args\.write:/.test(src),
|
||||
'the report must return before index.yaml is written');
|
||||
const gate = src.indexOf('if not args.write:');
|
||||
const write = src.indexOf('yaml.safe_dump(document, fh');
|
||||
assert.ok(gate !== -1 && write > gate, 'nothing may be written before the --write gate');
|
||||
assert.ok(!/git (push|commit)/.test(src),
|
||||
'rebuilding an index is not the same as publishing it - pushing stays a ' +
|
||||
'separate, deliberate step');
|
||||
});
|
||||
|
||||
test('it looks for the charts checkout where the repo keeps companion repos', () => {
|
||||
const src = fs.readFileSync(script, 'utf8');
|
||||
assert.ok(/\.tools", "charts"/.test(src),
|
||||
'.tools/charts is where companion repositories are cloned (CLAUDE.md)');
|
||||
assert.ok(/"w", "charts"/.test(src), '../w/charts must keep working as the older location');
|
||||
const sh = fs.readFileSync(path.join(repoRoot, 'releases/backfill-charts.sh'), 'utf8');
|
||||
assert.ok(/\$REPO_DIR\/\.tools\/charts/.test(sh),
|
||||
'backfill-charts.sh must look in the same places');
|
||||
});
|
||||
|
||||
// ── the behaviour, run for real against a synthesised charts directory ──────
|
||||
|
||||
// A minimal but REAL chart package: a gzipped tar with <name>/Chart.yaml in it,
|
||||
// which is what the tool reads. Written by hand so the test needs no helm.
|
||||
function makePackage(dir, filename, chart) {
|
||||
const body = Buffer.from(
|
||||
`apiVersion: v2\nname: wekan\nversion: ${chart.version}\n` +
|
||||
`appVersion: "${chart.appVersion}"\ndescription: Open Source kanban\n` +
|
||||
(chart.dependencies
|
||||
? `dependencies:\n- name: mongodb\n version: ${chart.dependencies}\n` : ''),
|
||||
'utf8');
|
||||
|
||||
// One ustar header + the file, padded to 512-byte records.
|
||||
const header = Buffer.alloc(512);
|
||||
header.write('wekan/Chart.yaml', 0, 'utf8');
|
||||
header.write('0000644\0', 100);
|
||||
header.write('0000000\0', 108);
|
||||
header.write('0000000\0', 116);
|
||||
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
header.write('00000000000\0', 136);
|
||||
header.write(' ', 148); // checksum field, spaces while summing
|
||||
header.write('0', 156);
|
||||
header.write('ustar\0', 257);
|
||||
header.write('00', 263);
|
||||
let sum = 0;
|
||||
for (const byte of header) sum += byte;
|
||||
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
|
||||
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
|
||||
const tar = Buffer.concat([header, body, pad, Buffer.alloc(1024)]);
|
||||
fs.writeFileSync(path.join(dir, filename), zlib.gzipSync(tar));
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, filename))).digest('hex');
|
||||
}
|
||||
|
||||
function writeIndex(dir, entries) {
|
||||
const body = entries.map(e =>
|
||||
` - apiVersion: v2\n appVersion: "${e.appVersion}"\n` +
|
||||
` created: "${e.created}"\n digest: ${e.digest}\n name: wekan\n` +
|
||||
` urls:\n - https://wekan.github.io/charts/wekan-${e.version}.tgz\n` +
|
||||
` version: ${e.version}\n`).join('');
|
||||
fs.writeFileSync(path.join(dir, 'index.yaml'),
|
||||
`apiVersion: v1\nentries:\n wekan:\n${body}generated: "2026-01-01T00:00:00+00:00"\n`);
|
||||
}
|
||||
|
||||
function run(dir, extra = []) {
|
||||
return execFileSync('python3', [script, '--charts-dir', dir, ...extra],
|
||||
{ encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function readIndex(dir) {
|
||||
const text = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
|
||||
const out = [];
|
||||
let current = null;
|
||||
for (const line of text.split('\n')) {
|
||||
if (/^ {2}- /.test(line)) { current = {}; out.push(current); }
|
||||
if (!current) continue;
|
||||
const m = line.match(/^\s+(appVersion|created|digest|version):\s*'?"?([^'"\n]*)'?"?\s*$/);
|
||||
if (m) current[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function withCharts(fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-reindex-'));
|
||||
try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (!hasPyYaml) {
|
||||
console.log(' -- python3 PyYAML not available; behaviour tests skipped');
|
||||
} else {
|
||||
test('a package with no entry is added, and one already correct keeps its created', () => {
|
||||
withCharts(dir => {
|
||||
const dA = makePackage(dir, 'wekan-10.79.0.tgz', { version: '10.79.0', appVersion: '10.79' });
|
||||
makePackage(dir, 'wekan-9.63.0.tgz', { version: '9.63.0', appVersion: '9.63' });
|
||||
writeIndex(dir, [
|
||||
{ version: '10.79.0', appVersion: '10.79.0', created: '2026-08-10T02:46:52.308983+00:00', digest: dA },
|
||||
]);
|
||||
|
||||
const out = run(dir, ['--write']);
|
||||
const entries = readIndex(dir);
|
||||
assert.deepStrictEqual(entries.map(e => e.version), ['10.79.0', '9.63.0'],
|
||||
'the unindexed package must be added, newest first');
|
||||
assert.strictEqual(entries[0].created, '2026-08-10T02:46:52.308983+00:00',
|
||||
'an entry whose digest matches its package must keep the created it has - ' +
|
||||
'a rebuild must not restamp the whole repository');
|
||||
assert.ok(entries[1].created, 'the added entry must still have a created');
|
||||
assert.ok(/added\s+:\s*1/.test(out), out);
|
||||
});
|
||||
});
|
||||
|
||||
test('appVersion is taken from the package, not from the chart version', () => {
|
||||
withCharts(dir => {
|
||||
const d = makePackage(dir, 'wekan-10.79.0.tgz', { version: '10.79.0', appVersion: '10.79' });
|
||||
writeIndex(dir, [
|
||||
{ version: '10.79.0', appVersion: '10.79.0', created: '2026-08-10T02:46:52+00:00', digest: d },
|
||||
]);
|
||||
run(dir, ['--write']);
|
||||
assert.strictEqual(readIndex(dir)[0].appVersion, '10.79',
|
||||
'appVersion is the WeKan version the package declares; the index had the ' +
|
||||
'CHART version there on every entry');
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate entries collapse to the one whose digest matches the package', () => {
|
||||
withCharts(dir => {
|
||||
const real = makePackage(dir, 'wekan-9.36.0.tgz', { version: '9.36.0', appVersion: '9.36' });
|
||||
// The matching digest on the OLDEST copy: if "newest wins" were the rule
|
||||
// rather than a fallback, this is what would expose it.
|
||||
writeIndex(dir, [
|
||||
{ version: '9.36.0', appVersion: '9.36.0', created: '2026-06-11T03:41:02+00:00', digest: 'b'.repeat(64) },
|
||||
{ version: '9.36.0', appVersion: '9.36.0', created: '2026-06-11T02:26:50+00:00', digest: 'c'.repeat(64) },
|
||||
{ version: '9.36.0', appVersion: '9.36.0', created: '2026-06-10T21:20:06+00:00', digest: real },
|
||||
]);
|
||||
run(dir, ['--write']);
|
||||
const entries = readIndex(dir);
|
||||
assert.strictEqual(entries.length, 1, 'one entry per version');
|
||||
assert.strictEqual(entries[0].digest, real,
|
||||
'the surviving entry must be the one a helm client can verify the file against');
|
||||
assert.strictEqual(entries[0].created, '2026-06-10T21:20:06+00:00',
|
||||
'and its created comes with it - the digest decides, not the timestamp');
|
||||
});
|
||||
});
|
||||
|
||||
test('two packages claiming one version: the filename that matches wins, and it is reported', () => {
|
||||
withCharts(dir => {
|
||||
makePackage(dir, 'wekan-6.9.6.tgz', { version: '6.9.6', appVersion: '6.96' });
|
||||
makePackage(dir, 'wekan-6.96.tgz', { version: '6.9.6', appVersion: '6.96' });
|
||||
writeIndex(dir, []);
|
||||
const out = run(dir, ['--write']);
|
||||
const entries = readIndex(dir);
|
||||
assert.strictEqual(entries.length, 1,
|
||||
'an index where one version means two files cannot say which to install');
|
||||
const text = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
|
||||
assert.ok(text.includes('wekan-6.9.6.tgz'), 'the file whose name matches its version is kept');
|
||||
assert.ok(!text.includes('wekan-6.96.tgz'), 'the mis-named one is not offered as a version');
|
||||
assert.ok(/not offered/.test(out) && /wekan-6\.96\.tgz/.test(out),
|
||||
`dropping a package must be reported, not silent. Got:\n${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('no version that was listed before is ever dropped', () => {
|
||||
withCharts(dir => {
|
||||
const d = makePackage(dir, 'wekan-8.00.0.tgz', { version: '8.00.0', appVersion: '8.00' });
|
||||
makePackage(dir, 'wekan-7.99.0.tgz', { version: '7.99.0', appVersion: '7.99' });
|
||||
writeIndex(dir, [
|
||||
{ version: '8.00.0', appVersion: '8.00.0', created: '2026-01-02T00:00:00+00:00', digest: d },
|
||||
]);
|
||||
run(dir, ['--write']);
|
||||
const versions = readIndex(dir).map(e => e.version);
|
||||
assert.ok(versions.includes('8.00.0'), 'a version listed before must still be listed');
|
||||
assert.ok(versions.includes('7.99.0'), 'and the package that was missing is added');
|
||||
});
|
||||
});
|
||||
|
||||
test('every entry it writes points at a package that is there, with that package\'s digest', () => {
|
||||
withCharts(dir => {
|
||||
const digests = {
|
||||
'10.79.0': makePackage(dir, 'wekan-10.79.0.tgz', { version: '10.79.0', appVersion: '10.79' }),
|
||||
'10.78.0': makePackage(dir, 'wekan-10.78.0.tgz', { version: '10.78.0', appVersion: '10.78' }),
|
||||
};
|
||||
writeIndex(dir, []);
|
||||
run(dir, ['--write']);
|
||||
for (const e of readIndex(dir)) {
|
||||
assert.strictEqual(e.digest, digests[e.version],
|
||||
`${e.version}: the digest must be the sha256 of the file being served`);
|
||||
assert.ok(fs.existsSync(path.join(dir, `wekan-${e.version}.tgz`)),
|
||||
`${e.version}: an entry must not point at a package that is not there`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('a report run leaves index.yaml exactly as it was', () => {
|
||||
withCharts(dir => {
|
||||
makePackage(dir, 'wekan-9.63.0.tgz', { version: '9.63.0', appVersion: '9.63' });
|
||||
writeIndex(dir, []);
|
||||
const before = fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8');
|
||||
const out = run(dir);
|
||||
assert.strictEqual(fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8'), before,
|
||||
'without --write the file must not be touched at all');
|
||||
assert.ok(/Report only\. Nothing was written\./.test(out), out);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── charts that cannot be installed ─────────────────────────────────────────
|
||||
//
|
||||
// Backfilling 146 old packages taught this the hard way. Artifact Hub scans
|
||||
// EVERY entry in the index and mailed a list of errors within the hour:
|
||||
//
|
||||
// error scanning image ghcr.io/wekan/wekan:v9.62: image not found
|
||||
// error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found
|
||||
//
|
||||
// A chart is a pointer to container images; one whose images are gone installs
|
||||
// and then fails at the pull, so listing it tells everyone the repository is
|
||||
// broken when the repository is fine. 135 of the 360 packages are in that state
|
||||
// - six WeKan images that were never pushed (the releases whose docker job
|
||||
// failed) and 129 older charts vendoring the Bitnami mongodb subchart, whose
|
||||
// pinned tags Bitnami has deleted.
|
||||
|
||||
test('the exclusion list is a file in the repository, not a decision made per run', () => {
|
||||
const src = fs.readFileSync(script, 'utf8');
|
||||
assert.ok(/UNINDEXED_FILE = "unindexed\.txt"/.test(src),
|
||||
'which packages are not listed must be RECORDED, so a rebuild during a ' +
|
||||
'release does not depend on reaching two registries - a network blip would ' +
|
||||
'otherwise drop half the index');
|
||||
assert.ok(/def read_unindexed\(/.test(src), 'and read back on every run');
|
||||
assert.ok(/--check-images/.test(src),
|
||||
'with an explicit flag to regenerate it, rather than doing it implicitly');
|
||||
});
|
||||
|
||||
test('an image that cannot be checked is never treated as missing', () => {
|
||||
const src = fs.readFileSync(script, 'utf8');
|
||||
assert.ok(/never "missing"/.test(src),
|
||||
'image_exists must distinguish "gone" from "could not tell"');
|
||||
assert.ok(/if verdicts\.get\(\(h, r, t\)\) is False/.test(src),
|
||||
'only a definite False may exclude a chart - None must not, or a registry ' +
|
||||
'hiccup silently unpublishes charts');
|
||||
});
|
||||
|
||||
test('the registry handshake is the registry\'s own, not a guess per host', () => {
|
||||
const src = fs.readFileSync(script, 'utf8');
|
||||
assert.ok(/WWW-Authenticate/.test(src),
|
||||
'a hard-coded token URL per registry reported every quay.io image as missing, ' +
|
||||
'including quay.io/wekan/wekan:latest - the 401 challenge says which realm ' +
|
||||
'and scope to use');
|
||||
assert.ok(/localhost/.test(src) && /"\." in head/.test(src),
|
||||
'and a repository that already begins with a host must not be read as a ' +
|
||||
'docker.io path - that is how ghcr.io/wekan/wekan came back unknown');
|
||||
});
|
||||
|
||||
test('publishing refuses a version with no container image', () => {
|
||||
const rel = fs.readFileSync(path.join(repoRoot, 'releases/release-charts.sh'), 'utf8');
|
||||
assert.ok(/does not exist/.test(rel) && /image pull/.test(rel),
|
||||
'release-charts.sh must not publish a chart pointing at an image that is not ' +
|
||||
'there - that is exactly what produced the Artifact Hub scan errors');
|
||||
const guard = rel.indexOf('ghcr.io/wekan/wekan');
|
||||
const work = rel.indexOf('git checkout main');
|
||||
assert.ok(guard !== -1 && guard < work,
|
||||
'and it must check BEFORE it starts changing the charts repository');
|
||||
assert.ok(/continuing\./.test(rel),
|
||||
'a registry it cannot reach must not block a release either');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,231 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: the armv6 bundle is assembled in a userland its own Node.js can run in,
|
||||
// and the base-image preflight compares the ARM VARIANT rather than just "arm".
|
||||
// Run: node tests/releaseArmv6Container.test.cjs
|
||||
//
|
||||
// What this pins, from the v10.80 release run:
|
||||
//
|
||||
// qemu-arm: Could not open '/lib/ld-linux-armhf.so.3': No such file or directory
|
||||
// install-node-for-arch.sh: the Node.js binary for armv6 does not run in a
|
||||
// armv6 container.
|
||||
//
|
||||
// The matrix asked for `platform: linux/arm/v6` against `debian:trixie`. DEBIAN
|
||||
// HAS NO ARMv6 PORT - that image publishes arm/v5 and arm/v7 only - and
|
||||
// containerd treats a LOWER ARM variant as compatible, so the request quietly
|
||||
// resolved to arm/v5: Debian armel, ARMv5 SOFT-float, which has no hard-float
|
||||
// loader in it at all. node-armv6 is hard-float, so it could not start, and the
|
||||
// job found out 400 lines in, after the whole apt install.
|
||||
//
|
||||
// Two things went wrong and both are checked here:
|
||||
// 1. The preflight (check-arch-binaries.sh) matched on `"architecture": "arm"`
|
||||
// and ignored the variant, so "debian:trixie has no linux/arm/v6" was never
|
||||
// the answer it gave.
|
||||
// 2. armv6 was pointed at a platform that does not exist. It is built in
|
||||
// Debian's arm/v7 (armhf) container instead: same hard-float ABI, same
|
||||
// loader, so node-armv6 runs. Nothing ARMv7 reaches the bundle - the
|
||||
// container's npm install compiles nothing, and node/ferretdb/the MongoDB
|
||||
// tools are all downloaded already built for ARMv6 - EXCEPT the bundled
|
||||
// qemu-user, which is copied out of the container, and is therefore gated.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
|
||||
const preflight = fs.readFileSync(
|
||||
path.join(repoRoot, 'releases/check-arch-binaries.sh'), 'utf8');
|
||||
const installNode = fs.readFileSync(
|
||||
path.join(repoRoot, 'releases/install-node-for-arch.sh'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// The platforms debian:trixie actually publishes, as `docker manifest inspect`
|
||||
// reports them. arm/v6 is deliberately absent: that is the fact this is about.
|
||||
const DEBIAN_TRIXIE = new Set([
|
||||
'linux/386', 'linux/amd64', 'linux/arm/v5', 'linux/arm/v7', 'linux/arm64',
|
||||
'linux/ppc64le', 'linux/riscv64', 'linux/s390x',
|
||||
]);
|
||||
|
||||
// The build-extra-arches matrix entries, read out of the workflow text.
|
||||
function matrixEntries() {
|
||||
const start = workflow.indexOf('\n build-extra-arches:\n');
|
||||
assert.notStrictEqual(start, -1, 'release-all.yml has no build-extra-arches job');
|
||||
const rest = workflow.slice(start + 1);
|
||||
const next = rest.search(/\n [a-z0-9-]+:\n/);
|
||||
const body = next === -1 ? rest : rest.slice(0, next);
|
||||
|
||||
const entries = [];
|
||||
let current = null;
|
||||
for (const line of body.split('\n')) {
|
||||
const first = line.match(/^\s*- (arch):\s*(\S+)\s*$/);
|
||||
if (first) {
|
||||
current = { arch: first[2] };
|
||||
entries.push(current);
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const kv = line.match(/^\s{12}([a-z_]+):\s*(\S+)\s*$/);
|
||||
if (kv) current[kv[1]] = kv[2];
|
||||
else if (/^\s{8,10}[a-z_-]+:/.test(line) && !/^\s{12}/.test(line)) current = null;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
const entries = matrixEntries();
|
||||
|
||||
test('the build-extra-arches matrix was parsed', () => {
|
||||
assert.ok(entries.length >= 8, `expected the exotic-arch matrix, got ${entries.length} entries`);
|
||||
for (const arch of ['armv6', 'armhf', 'armv7', 'i386', 's390x']) {
|
||||
assert.ok(entries.some(e => e.arch === arch), `matrix has no ${arch} entry`);
|
||||
}
|
||||
});
|
||||
|
||||
// A platform the base image simply does not have (loong64: no debian, no ubuntu,
|
||||
// at any tag) is NOT this failure. Docker says "no matching manifest", the
|
||||
// preflight turns that into a warning, and a best-effort arch skips - which is
|
||||
// exactly what loong64 does every release, on purpose. 32-bit ARM is the case
|
||||
// that does not fail cleanly: containerd accepts a LOWER variant as compatible,
|
||||
// so the request succeeds against the wrong userland and nothing says so.
|
||||
test('no 32-bit ARM entry names an arm variant its base image does not publish', () => {
|
||||
const bad = entries
|
||||
.filter(e => e.image === 'debian:trixie' && e.platform.startsWith('linux/arm/'))
|
||||
.filter(e => !DEBIAN_TRIXIE.has(e.platform))
|
||||
.map(e => `${e.arch}: ${e.image} publishes no ${e.platform}, so the request ` +
|
||||
`resolves DOWNWARDS to a lower ARM variant and the bundle is built ` +
|
||||
`in the wrong userland`);
|
||||
assert.deepStrictEqual(bad, [], 'silent ARM variant downgrade:\n ' + bad.join('\n '));
|
||||
});
|
||||
|
||||
test('loong64 is still the knowingly-absent, cleanly-skipped platform (not ARM-style silent)', () => {
|
||||
const loong = entries.find(e => e.arch === 'loong64');
|
||||
assert.ok(loong, 'matrix has no loong64 entry');
|
||||
assert.strictEqual(DEBIAN_TRIXIE.has(loong.platform), false,
|
||||
'debian:trixie publishes no linux/loong64 - if that changes, this test is the note ' +
|
||||
'to stop treating loong64 as unbuildable');
|
||||
assert.strictEqual(loong.optional, 'true',
|
||||
'loong64 must stay best-effort: the preflight warns and skips it, and a hard ' +
|
||||
'failure there fails the whole matrix job and takes docker down with it');
|
||||
});
|
||||
|
||||
test('armv6 builds in the armhf (arm/v7) container, which has the hard-float loader', () => {
|
||||
const armv6 = entries.find(e => e.arch === 'armv6');
|
||||
assert.strictEqual(armv6.platform, 'linux/arm/v7',
|
||||
'armv6 must build in Debian\'s arm/v7 (armhf) container: it is the hard-float ' +
|
||||
'userland node-armv6 needs. linux/arm/v6 does not exist for debian and silently ' +
|
||||
'resolves to arm/v5 (armel, soft-float), where node-armv6 cannot start.');
|
||||
assert.strictEqual(armv6.node_arch, 'armv6',
|
||||
'the Node.js SHIPPED is still ARMv6 - only the container it runs in is armhf');
|
||||
assert.strictEqual(armv6.ferretdb_arch, 'armv6',
|
||||
'the FerretDB shipped is still the GOARM=6 build');
|
||||
});
|
||||
|
||||
test('armv6 is marked as a container that is not its own CPU, so no qemu-user is copied out', () => {
|
||||
const armv6 = entries.find(e => e.arch === 'armv6');
|
||||
assert.strictEqual(armv6.same_arch_container, 'false',
|
||||
'armv6 builds in an armv7l container, so same_arch_container must be false: the ' +
|
||||
'bundled qemu-user is the one artifact copied FROM the container, and an ARMv7 ' +
|
||||
'qemu-armv7l in an ARMv6 bundle is a binary a Raspberry Pi 1 cannot execute');
|
||||
const sameArch = entries.filter(e => e.same_arch_container === 'false').map(e => e.arch);
|
||||
assert.deepStrictEqual(sameArch, ['armv6'],
|
||||
'only armv6 is built in a foreign-CPU container; every other arch has one of its own');
|
||||
});
|
||||
|
||||
test('the docker run passes SAME_ARCH_CONTAINER into the container', () => {
|
||||
assert.ok(/-e SAME_ARCH_CONTAINER='\$\{\{ matrix\.same_arch_container \}\}'/.test(workflow),
|
||||
'the matrix flag has to reach install-node-for-arch.sh to have any effect');
|
||||
});
|
||||
|
||||
test('install-node-for-arch.sh skips bundling qemu-user when the container is not the target CPU', () => {
|
||||
assert.ok(/SAME_ARCH_CONTAINER:-true/.test(installNode),
|
||||
'install-node-for-arch.sh must read SAME_ARCH_CONTAINER, defaulting to true so ' +
|
||||
'every same-arch build keeps bundling its qemu-user (#6458)');
|
||||
const gate = installNode.indexOf('SAME_ARCH_CONTAINER:-true');
|
||||
const qemuCopy = installNode.indexOf('qemu-user-static');
|
||||
assert.ok(gate !== -1 && qemuCopy > gate,
|
||||
'the gate must come BEFORE the qemu-user install/copy, or it copies it anyway');
|
||||
});
|
||||
|
||||
test('the base-image preflight compares the ARM variant, not just the architecture', () => {
|
||||
assert.ok(/want_variant=/.test(preflight),
|
||||
'check-arch-binaries.sh must extract the variant from the platform string');
|
||||
assert.ok(!/grep -q .*"architecture": \*"\$\{want_arch\}"/.test(preflight),
|
||||
'the architecture-only grep is what let linux/arm/v6 through against an image ' +
|
||||
'that publishes only arm/v5 and arm/v7');
|
||||
assert.ok(/p\.get\("variant", ""\) == want_variant/.test(preflight),
|
||||
'a REQUESTED variant must be compared exactly - arm/v6 and arm/v5 are different ' +
|
||||
'userlands, not near neighbours');
|
||||
});
|
||||
|
||||
// The preflight's platform match is small enough to run here directly, against
|
||||
// the manifest shapes it really sees: debian:trixie's index, an arm64 index
|
||||
// (published as arm64/v8 but always written linux/arm64), and a single-platform
|
||||
// image with no manifest list at all.
|
||||
test('the preflight platform match answers correctly for the manifests it meets', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
|
||||
const script = preflight
|
||||
.split("if ! WANT_ARCH=\"$want_arch\" WANT_VARIANT=\"$want_variant\" python3 - <<'PYEOF'\n")[1]
|
||||
.split('\nPYEOF\n')[0];
|
||||
assert.ok(script.includes('want_variant'), 'could not extract the preflight python block');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-preflight-'));
|
||||
const scriptPath = path.join(tmp, 'match.py');
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
|
||||
const manifestPath = '/tmp/manifest.json';
|
||||
const saved = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath) : null;
|
||||
|
||||
function matches(manifest, platform) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
const [, arch, variant = ''] = platform.split('/');
|
||||
try {
|
||||
execFileSync('python3', [scriptPath], {
|
||||
env: { ...process.env, WANT_ARCH: arch, WANT_VARIANT: variant },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const debian = {
|
||||
manifests: [...DEBIAN_TRIXIE].map(p => {
|
||||
const [, architecture, variant] = p.split('/');
|
||||
return { platform: { os: 'linux', architecture, ...(variant ? { variant } : {}) } };
|
||||
}),
|
||||
};
|
||||
const arm64Only = {
|
||||
manifests: [{ platform: { os: 'linux', architecture: 'arm64', variant: 'v8' } }],
|
||||
};
|
||||
const singlePlatform = { architecture: 'arm', variant: 'v6', os: 'linux' };
|
||||
|
||||
try {
|
||||
// The bug: debian:trixie has arm/v5 and arm/v7, and MUST NOT answer yes to arm/v6.
|
||||
assert.strictEqual(matches(debian, 'linux/arm/v6'), false,
|
||||
'debian:trixie publishes no linux/arm/v6 - saying it does is the whole failure');
|
||||
assert.strictEqual(matches(debian, 'linux/arm/v7'), true, 'debian:trixie has arm/v7');
|
||||
assert.strictEqual(matches(debian, 'linux/arm/v5'), true, 'debian:trixie has arm/v5');
|
||||
assert.strictEqual(matches(debian, 'linux/386'), true, 'debian:trixie has 386');
|
||||
assert.strictEqual(matches(debian, 'linux/loong64'), false, 'debian:trixie has no loong64');
|
||||
// linux/arm64 is written without the /v8 everyone's registry records.
|
||||
assert.strictEqual(matches(arm64Only, 'linux/arm64'), true,
|
||||
'an unrequested variant matches anything, or every linux/arm64 build breaks');
|
||||
// An image with no manifest list still answers for its own platform.
|
||||
assert.strictEqual(matches(singlePlatform, 'linux/arm/v6'), true,
|
||||
'a single-platform image names its architecture in its own config');
|
||||
assert.strictEqual(matches(singlePlatform, 'linux/arm/v7'), false,
|
||||
'and it must not answer for a variant it is not');
|
||||
} finally {
|
||||
if (saved !== null) fs.writeFileSync(manifestPath, saved);
|
||||
else fs.rmSync(manifestPath, { force: true });
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: the jobs that run AFTER the release is already published cannot be
|
||||
// skipped by a cancellation somewhere else in the run.
|
||||
// Run: node tests/releaseCancelSafeJobs.test.cjs
|
||||
//
|
||||
// v10.80 is the case this exists for. Everything shipped - the tag, the GitHub
|
||||
// release, every bundle, the multi-arch image - and then `charts`, `ucs` and
|
||||
// `nextcloud` were all SKIPPED, in the same second the `docker` job they wait on
|
||||
// finished. Nothing about them had failed. build-mac-x64 was queued on `macos-13`,
|
||||
// a runner label GitHub had retired, so it sat there for nearly two hours and was
|
||||
// cancelled by hand; a cancelled job cancels the RUN, and a cancelling run SKIPS
|
||||
// every job that has not started yet. The Helm chart for a published WeKan was
|
||||
// never pushed, and no ::error:: anywhere said why.
|
||||
//
|
||||
// A job whose `if` is `always()` still runs while a run is cancelling. So these
|
||||
// three say `always() && <the needs that actually matter> == 'success'`: the
|
||||
// condition is then explicit about the only thing that was ever required (the
|
||||
// image is on the registries before the chart points at it), instead of being
|
||||
// implied by a default that a cancellation elsewhere quietly overrides.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
|
||||
|
||||
function job(name) {
|
||||
const start = workflow.indexOf(`\n ${name}:\n`);
|
||||
assert.notStrictEqual(start, -1, `release-all.yml has no ${name} job`);
|
||||
const rest = workflow.slice(start + 1);
|
||||
const next = rest.search(/\n [a-z0-9-]+:\n/);
|
||||
return next === -1 ? rest : rest.slice(0, next);
|
||||
}
|
||||
|
||||
// The `if:` of a job, i.e. the one at job level (two-space indent), not a step's.
|
||||
function jobIf(name) {
|
||||
const m = job(name).match(/^ {4}if:\s*(.+)$/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// The `needs:` of a job, in either the inline-list or the bare-scalar form.
|
||||
function jobNeeds(name) {
|
||||
const body = job(name);
|
||||
const inline = body.match(/^ {4}needs:\s*\[([^\]]+)\]/m);
|
||||
if (inline) return inline[1].split(',').map(s => s.trim());
|
||||
const scalar = body.match(/^ {4}needs:\s*(\S+)\s*$/m);
|
||||
if (scalar) return [scalar[1]];
|
||||
const block = body.match(/^ {4}needs:\s*\n((?: {6}- .+\n)+)/m);
|
||||
if (block) return block[1].trim().split('\n').map(s => s.replace(/^\s*-\s*/, '').trim());
|
||||
return [];
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// Every job that runs after the release is published and must survive a
|
||||
// cancellation elsewhere in the run.
|
||||
const POST_RELEASE_JOBS = ['charts', 'ucs', 'nextcloud'];
|
||||
|
||||
for (const name of POST_RELEASE_JOBS) {
|
||||
test(`${name} runs on always(), so a cancellation elsewhere cannot skip it`, () => {
|
||||
const cond = jobIf(name);
|
||||
assert.ok(cond, `${name} has no job-level if:, so a cancelled run skips it`);
|
||||
assert.ok(/always\(\)/.test(cond),
|
||||
`${name} must be guarded with always() - without it a cancelled run (which is ` +
|
||||
`what a hand-cancelled build-mac-x64 produces) skips it before it starts. Got: ${cond}`);
|
||||
});
|
||||
|
||||
test(`${name} still requires each job it needs to have SUCCEEDED`, () => {
|
||||
const cond = jobIf(name);
|
||||
const needs = jobNeeds(name);
|
||||
assert.ok(needs.length > 0, `${name} has no needs: to guard`);
|
||||
for (const dep of needs) {
|
||||
assert.ok(cond.includes(`needs.${dep}.result == 'success'`),
|
||||
`${name} uses always(), which also runs when ${dep} FAILED - so it has to say ` +
|
||||
`needs.${dep}.result == 'success' explicitly. Got: ${cond}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('charts waits on docker, so the chart never points at an image that is not pushed', () => {
|
||||
assert.deepStrictEqual(jobNeeds('charts'), ['docker'],
|
||||
'the chart advertises the freshly published multi-arch image; docker is what pushes it');
|
||||
});
|
||||
|
||||
test('ucs and nextcloud still wait on prepare, release and docker', () => {
|
||||
for (const name of ['ucs', 'nextcloud']) {
|
||||
assert.deepStrictEqual(jobNeeds(name).sort(), ['docker', 'prepare', 'release'],
|
||||
`${name} publishes an app that points at the released image`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ucs and nextcloud stay continue-on-error, so neither can fail the release', () => {
|
||||
for (const name of ['ucs', 'nextcloud']) {
|
||||
assert.ok(/^ {4}continue-on-error:\s*true\s*$/m.test(job(name)),
|
||||
`${name} is best-effort reporting; it must not be able to fail the release`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: publishing a chart derives the index from the packages, and refuses a
|
||||
// package whose filename and contents disagree.
|
||||
// Run: node tests/releaseChartsIndex.test.cjs
|
||||
//
|
||||
// The charts repository keeps the chart SOURCE on `main` and the published
|
||||
// packages plus index.yaml on `gh-pages`, and its own two scripts move between
|
||||
// them: main's release.sh commits the source, tars `wekan/` into
|
||||
// wekan-<version>.0.tgz, checks out gh-pages and drops the package there;
|
||||
// gh-pages' release2.sh commits and pushes. releases/release-charts.sh drives
|
||||
// both and owns the index in between - and that middle part is what this pins.
|
||||
//
|
||||
// TWO THINGS GO WRONG THERE, and both have already happened.
|
||||
//
|
||||
// The index used to be EDITED: the newest entry was copied, a few fields
|
||||
// substituted, and the result prepended. All four of the index's defects came
|
||||
// from that - a re-run prepended a second entry for a version instead of
|
||||
// replacing it (9.36.0 ended with four, each a different digest, so a client
|
||||
// could pick one matching no file); appVersion was set to the CHART version on
|
||||
// every entry; fields nobody substituted never changed, so every entry claimed
|
||||
// mongodb 0.7.2 while the packages were 0.7.6; and a release whose charts job
|
||||
// did not run left a hole nothing went back for. Deriving the index from the
|
||||
// packages makes all four impossible instead of fixed.
|
||||
//
|
||||
// And release.sh names the package from its ARGUMENT (`tar -cvzf
|
||||
// wekan-$1.0.tgz wekan`) while the version INSIDE comes from the Chart.yaml that
|
||||
// release-charts.sh sed-ed a moment earlier. When those drift the repository
|
||||
// gains a file called one version that declares another: wekan-1.2.7.tgz
|
||||
// (containing 1.2.6) and wekan-6.96.tgz (containing 6.9.6) were both in the
|
||||
// repository until they were removed. A Helm index keys on the version INSIDE,
|
||||
// so neither could ever be installed under the name it was given.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const script = fs.readFileSync(path.join(repoRoot, 'releases/release-charts.sh'), 'utf8');
|
||||
const missing = fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release-all-missing.yml'), 'utf8');
|
||||
const releaseAll = fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
test('the index is rebuilt from the packages, not edited in place', () => {
|
||||
assert.ok(/reindex-charts\.py" --write --charts-dir/.test(script),
|
||||
'release-charts.sh must rebuild index.yaml from the packages on gh-pages');
|
||||
assert.ok(!/Prepended chart/.test(script),
|
||||
'the hand-rolled "prepend a copy of the previous entry" step must be gone - ' +
|
||||
'it is where the duplicate entries, the wrong appVersion and the frozen ' +
|
||||
'dependency versions all came from');
|
||||
assert.ok(!/set_field\(block/.test(script),
|
||||
'and so must the field substitution it did');
|
||||
});
|
||||
|
||||
test('the packaged chart is checked against its own filename before it is indexed', () => {
|
||||
assert.ok(/declares chart version/.test(script),
|
||||
'release-charts.sh must refuse a package whose Chart.yaml version is not the ' +
|
||||
'version its filename claims - that is how wekan-1.2.7.tgz and wekan-6.96.tgz ' +
|
||||
'came to exist');
|
||||
const check = script.indexOf('declares chart version');
|
||||
const index = script.indexOf('reindex-charts.py" --write');
|
||||
assert.ok(check !== -1 && check < index,
|
||||
'the check has to come BEFORE the index is rebuilt, or the bad package is ' +
|
||||
'indexed and then reported');
|
||||
});
|
||||
|
||||
test('both release workflows publish the chart', () => {
|
||||
// release-all.yml has always had a charts job; it is release-all-missing.yml
|
||||
// that could not finish a release's chart, which is how a release that missed
|
||||
// its chart stayed missing it forever.
|
||||
assert.ok(/\n charts:\n/.test(releaseAll), 'release-all.yml must have a charts job');
|
||||
assert.ok(/\n charts:\n/.test(missing),
|
||||
'release-all-missing.yml must be able to publish a chart a release never got');
|
||||
for (const [name, text] of [['release-all', releaseAll], ['release-all-missing', missing]]) {
|
||||
assert.ok(/release-charts\.sh/.test(text),
|
||||
`${name}.yml's charts job must publish through releases/release-charts.sh, ` +
|
||||
'so both take the same path');
|
||||
}
|
||||
});
|
||||
|
||||
test('release-all-missing no longer claims it never touches the charts', () => {
|
||||
assert.ok(!/never touches Docker, snap, the website or the charts/.test(missing),
|
||||
'the header said the charts were out of scope; they are not any more, and a ' +
|
||||
'comment that contradicts the workflow is worse than none');
|
||||
assert.ok(/It DOES publish the Helm chart/.test(missing),
|
||||
'and it should say so, with why');
|
||||
});
|
||||
|
||||
test('a chart that is already published is not republished', () => {
|
||||
assert.ok(/grep -qxF " version: \$\{V\}\.0"/.test(missing),
|
||||
'the guard must match the published index exactly: -F because the dots in a ' +
|
||||
'version are regex wildcards otherwise, -x because a four-space-indented ' +
|
||||
'version line is an entry\'s own and a deeper one is the mongodb subchart\'s');
|
||||
assert.ok(/Re-publishing an existing version would re-tar the package/.test(missing),
|
||||
'and say why it matters - a re-tar changes the digest of a chart people have ' +
|
||||
'already pulled');
|
||||
});
|
||||
|
||||
test('the missing-release charts job cannot run without a token that can push', () => {
|
||||
const at = missing.indexOf('\n charts:\n');
|
||||
const body = missing.slice(at, missing.indexOf('\n done:\n'));
|
||||
assert.ok(/No WEKAN_REPO_TOKEN/.test(body),
|
||||
'it pushes to another repository, so a missing token must stop it with a ' +
|
||||
'named warning rather than failing somewhere inside git');
|
||||
assert.ok(/skip=true/.test(body), 'and skip, not fail');
|
||||
});
|
||||
|
||||
// ── the filename/contents check, run for real ───────────────────────────────
|
||||
|
||||
const hasPyYaml = spawnSync('python3', ['-c', 'import yaml'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
// The same shape release.sh produces: `tar -cvzf wekan-<v>.tgz wekan`, so the
|
||||
// archive holds wekan/Chart.yaml.
|
||||
function makePackage(file, declaredVersion) {
|
||||
const body = Buffer.from(
|
||||
`apiVersion: v2\nname: wekan\nversion: ${declaredVersion}\nappVersion: "x"\n`, 'utf8');
|
||||
const header = Buffer.alloc(512);
|
||||
header.write('wekan/Chart.yaml', 0, 'utf8');
|
||||
header.write('0000644\0', 100);
|
||||
header.write('0000000\0', 108);
|
||||
header.write('0000000\0', 116);
|
||||
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
header.write('00000000000\0', 136);
|
||||
header.write(' ', 148);
|
||||
header.write('0', 156);
|
||||
header.write('ustar\0', 257);
|
||||
header.write('00', 263);
|
||||
let sum = 0;
|
||||
for (const b of header) sum += b;
|
||||
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
|
||||
fs.writeFileSync(file, zlib.gzipSync(Buffer.concat([header, body, pad, Buffer.alloc(1024)])));
|
||||
}
|
||||
|
||||
// The check as release-charts.sh runs it, lifted out of the script so the test
|
||||
// exercises the real code rather than a copy of it.
|
||||
function readDeclaredVersion(file) {
|
||||
const py = script.split("inside=\"$(TGZ=\"$TGZ\" python3 - <<'PYEOF'\n")[1].split("\nPYEOF")[0];
|
||||
return execFileSync('python3', ['-c', py], {
|
||||
env: { ...process.env, TGZ: file }, encoding: 'utf8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
if (!hasPyYaml) {
|
||||
console.log(' -- python3 PyYAML not available; the package check is not exercised');
|
||||
} else {
|
||||
test('the check reads the version out of the package, and it is the one that counts', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-relcharts-'));
|
||||
try {
|
||||
const good = path.join(dir, 'wekan-10.81.0.tgz');
|
||||
makePackage(good, '10.81.0');
|
||||
assert.strictEqual(readDeclaredVersion(good), '10.81.0',
|
||||
'a package whose name and contents agree reports that version');
|
||||
|
||||
// The real 2023 slip: named 1.2.7, declaring 1.2.6.
|
||||
const bad = path.join(dir, 'wekan-1.2.7.tgz');
|
||||
makePackage(bad, '1.2.6');
|
||||
assert.strictEqual(readDeclaredVersion(bad), '1.2.6',
|
||||
'and a mis-named one reports what it really is, so the caller can refuse it');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
// "Profile pictures were lost" after upgrading a 6.x snap (#6583 follow-up):
|
||||
//
|
||||
// "I can see Profile Pics from the user in the Admin/User Pannel if i open a
|
||||
// User. But on the boards i see only the 'missing Picture' Icon"
|
||||
// "initials are OK, but there is broken avatar image for those that have image"
|
||||
//
|
||||
// Run: node tests/requestUserAuth.test.cjs
|
||||
//
|
||||
// Nothing was lost. The avatar FILES migrate correctly, and the migrated
|
||||
// Meteor-Files record even keeps the original CollectionFS `_id`
|
||||
// (cfsRecordToMeteorFile), so the old URL still points at the right thing. What
|
||||
// broke is the request for them.
|
||||
//
|
||||
// A 6.x install stores `profile.avatarUrl` as `/cfs/files/avatars/<id>`. That
|
||||
// route serves the legacy CollectionFS bytes if they are still there, and
|
||||
// otherwise redirects to `/cdn/storage/avatars/<id>` - which asked who was
|
||||
// asking like this:
|
||||
//
|
||||
// const userId = Meteor.userId();
|
||||
// if (!userId) { res.writeHead(401); ... }
|
||||
//
|
||||
// `Meteor.userId()` reads the current DDP invocation's environment. That exists
|
||||
// inside a method or a publication and NOT in a WebApp handler, where it does not
|
||||
// return "nobody" - it THROWS "Meteor.userId can only be invoked in method calls
|
||||
// or publications". The handler's own try/catch then turned that into a 500, so
|
||||
// the failure presented as a broken image rather than as broken authentication,
|
||||
// and no avatar served through that route ever reached anybody.
|
||||
//
|
||||
// Initials still worked because they are rendered client-side from the username
|
||||
// and never fetch anything, which is exactly the reported split.
|
||||
//
|
||||
// An HTTP request carries its identity in the request. server/lib/requestUser.js
|
||||
// resolves it the way universalFileServer.js always has.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
const helper = read('server/lib/requestUser.js');
|
||||
const avatarServer = read('server/routes/avatarServer.js');
|
||||
const legacy = read('server/routes/legacyAttachments.js');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// Declarations only - these files explain the old call at length.
|
||||
const code = src => src.split('\n')
|
||||
.filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
||||
.join('\n');
|
||||
|
||||
test('no WebApp file route asks Meteor.userId() any more', () => {
|
||||
for (const [name, src] of [['avatarServer', avatarServer], ['legacyAttachments', legacy]]) {
|
||||
assert.ok(!/Meteor\.userId\(\)/.test(code(src)),
|
||||
`${name}: Meteor.userId() THROWS in a WebApp handler - it does not return ` +
|
||||
`null - and the handler's catch turns that into a 500`);
|
||||
assert.ok(/getUserIdFromRequest\(req\)/.test(code(src)),
|
||||
`${name}: the identity has to come from the request`);
|
||||
assert.ok(/await getUserIdFromRequest\(req\)/.test(code(src)),
|
||||
`${name}: it is async; an unawaited Promise is truthy and would authorise ` +
|
||||
`everybody`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the resolver never throws, whatever it is given', () => {
|
||||
// The callers wrap everything in try/catch and answer 500, so an exception
|
||||
// here would look exactly like the bug it replaces.
|
||||
const body = code(helper);
|
||||
const at = body.indexOf('export async function getUserIdFromRequest');
|
||||
const fn = body.slice(at);
|
||||
assert.ok(/try \{/.test(fn) && /catch \(error\)/.test(fn),
|
||||
'a caller deciding whether to serve a file wants an answer, not an exception');
|
||||
assert.ok(/return null;/.test(fn));
|
||||
});
|
||||
|
||||
test('it reads the four places a request can carry a token', () => {
|
||||
const body = code(helper);
|
||||
for (const source of ['authorization', 'x-auth-token', 'authToken', 'meteor_login_token']) {
|
||||
assert.ok(body.includes(source),
|
||||
`${source} is one of the ways WeKan's own client sends it; missing one ` +
|
||||
`means avatars work in some contexts and not others, which is the bug`);
|
||||
}
|
||||
assert.ok(/Accounts\._hashLoginToken/.test(body),
|
||||
'the stored token is hashed, so the raw one has to be hashed to match');
|
||||
});
|
||||
|
||||
test('Sandstorm, where there is no login token at all, still resolves', () => {
|
||||
const body = code(helper);
|
||||
assert.ok(/x-sandstorm-user-id/.test(body),
|
||||
'on Sandstorm the platform authorises the request and injects the user id; ' +
|
||||
'token lookup would always fail there and every image would 403');
|
||||
const at = body.indexOf('export async function getUserIdFromRequest');
|
||||
const fn = body.slice(at, at + 600);
|
||||
assert.ok(fn.indexOf('isSandstormRequest') < fn.indexOf('extractLoginToken'),
|
||||
'checked before the token path, which cannot succeed on Sandstorm');
|
||||
});
|
||||
|
||||
test('a token too short to be one is not looked up', () => {
|
||||
assert.ok(/raw\.length < 10/.test(code(helper)),
|
||||
'a junk value should not become a database query on every image request');
|
||||
});
|
||||
|
||||
// ── the path a migrated 6.x avatar actually takes ───────────────────────────
|
||||
|
||||
test('the legacy avatar URL still has somewhere to go', () => {
|
||||
assert.ok(/\/cfs\/files\/avatars\/:fileName/.test(avatarServer),
|
||||
"a 6.x profile.avatarUrl is /cfs/files/avatars/<id>; that route is what " +
|
||||
'makes an unmigrated URL work at all');
|
||||
assert.ok(/cdn\/storage\/avatars\//.test(avatarServer),
|
||||
'and it redirects to the current route when the legacy bytes are gone');
|
||||
});
|
||||
|
||||
test('a visitor who is not signed in can still see a public board avatar', () => {
|
||||
// The client appends ?boardId= to every avatar URL "so public viewers can
|
||||
// access avatars on public boards" (userAvatar.js), and the route ignored it
|
||||
// and answered 401, so a public board showed the missing-picture icon to
|
||||
// everybody who was not logged in.
|
||||
const body = code(avatarServer);
|
||||
assert.ok(/avatarIsOnAPublicBoard/.test(body),
|
||||
'the ?boardId= the client sends has to be honoured somewhere');
|
||||
const at = body.indexOf('async function avatarIsOnAPublicBoard');
|
||||
const fn = body.slice(at, body.indexOf('\n}', at));
|
||||
assert.ok(/parseQuery\(req\)\.boardId/.test(fn), 'read from the query string');
|
||||
assert.ok(/board\.isPublic\(\)/.test(fn),
|
||||
'a private board must not open its avatars to anonymous callers');
|
||||
assert.ok(/board\.hasMember\(avatar\.userId\)/.test(fn),
|
||||
'without this, naming ANY public board would unlock ANY avatar on the ' +
|
||||
'instance - a public board publishes its own members, not everybody');
|
||||
assert.ok(/await avatarIsOnAPublicBoard/.test(body),
|
||||
'awaited, or the 401 branch is never taken and every avatar is public');
|
||||
});
|
||||
|
||||
test('the legacy redirect keeps the query string', () => {
|
||||
// Every migrated 6.x avatar URL goes through this redirect, so dropping
|
||||
// ?boardId= here would 401 exactly the installs this fixes.
|
||||
const body = code(avatarServer);
|
||||
const at = body.indexOf('/cdn/storage/avatars/${fileName}');
|
||||
assert.ok(at !== -1);
|
||||
const around = body.slice(at - 200, at + 120);
|
||||
assert.ok(/req\.url \|\| ''\)\.split\('\?'\)\[1\]/.test(around),
|
||||
'the query has to be carried across the redirect');
|
||||
assert.ok(/\$\{query \? `\?\$\{query\}` : ''\}/.test(around),
|
||||
'and appended only when there is one, so a plain URL stays plain');
|
||||
});
|
||||
|
||||
test('the migration keeps the id the old URL names', () => {
|
||||
// This is why the redirect can work at all: the Meteor-Files record created
|
||||
// from a CollectionFS filerecord reuses its _id.
|
||||
const migration = read('releases/migrate-mongodb-to-ferretdb.mjs');
|
||||
const at = migration.indexOf('function cfsRecordToMeteorFile');
|
||||
const body = migration.slice(at, at + 400);
|
||||
assert.ok(/_id:\s*doc\._id/.test(body),
|
||||
'if the migrated avatar got a fresh id, /cdn/storage/avatars/<old id> would ' +
|
||||
'find nothing and the pictures really would be gone');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,192 @@
|
||||
'use strict';
|
||||
|
||||
// GHSA-phm4-4v26-j2vq (WhereBleed) — NoSQL injection (CWE-943) through the
|
||||
// client-supplied query selector of eight Admin Panel handlers.
|
||||
// Run: node tests/selectorGuard.test.cjs
|
||||
//
|
||||
// The Admin Panel's people, org, team and translation publications and their
|
||||
// companion count/page methods took a selector from the client and validated only
|
||||
// its TYPE:
|
||||
//
|
||||
// check(query, Match.OneOf(Object, null));
|
||||
//
|
||||
// which is not validation at all, because a MongoDB selector is executable data.
|
||||
// `$where` makes the database run the caller's JavaScript once per document
|
||||
// scanned, so `{ $where: 'while(true){}' }` pins a database worker for as long as
|
||||
// the caller likes and is repeatable - denial of service for every tenant on the
|
||||
// instance, from one narrowly-scoped account. The reporter demonstrated it on
|
||||
// v10.81 against mongo:7: `{ $where: 'sleep(2000) || true' }` made the `people`
|
||||
// subscription take 2.03s and return the document, `{ $where: 'false' }` returned
|
||||
// nothing in 0.00s - the caller controlling, in JavaScript, which documents come
|
||||
// back.
|
||||
//
|
||||
// The defence was NOT missing from the codebase - only from these handlers.
|
||||
// classifySelector and hasWhere were written for exactly this and were already
|
||||
// wired into the card-window publication. So the fix is not new logic: it is the
|
||||
// same helper, moved to /server/lib/selectorGuard so there is ONE copy, and
|
||||
// called by all nine sites.
|
||||
//
|
||||
// This suite pins three things: what the guard classifies as injection, that
|
||||
// every handler routes its selector through it, and that only ONE copy of the
|
||||
// check exists - the bug was eight places not doing what one place did.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
|
||||
const { classifySelector } = require(path.join(repoRoot, 'models/lib/injectionDetect.js'));
|
||||
const { hasWhere } = require(path.join(repoRoot, 'models/lib/mongoSelectorSafety.js'));
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// What the guard does, in the guard's own terms: it refuses when classifySelector
|
||||
// says injection OR hasWhere says $where. Both halves matter - the second is what
|
||||
// catches a bare `$where`, which is the payload in the report.
|
||||
const wouldRefuse = selector =>
|
||||
classifySelector(selector).injection === true || hasWhere(selector) === true;
|
||||
|
||||
// ── the payloads from the advisory ──────────────────────────────────────────
|
||||
|
||||
test('the reported payloads are refused', () => {
|
||||
for (const payload of [
|
||||
{ $where: 'while(true){}' }, // the denial of service
|
||||
{ $where: 'sleep(2000) || true' }, // the timing proof
|
||||
{ $where: 'false' }, // the inclusion oracle's other half
|
||||
{ $where: 'this.isAdmin === true' },
|
||||
]) {
|
||||
assert.strictEqual(wouldRefuse(payload), true,
|
||||
`${JSON.stringify(payload)} must be refused - it is code for the database to run`);
|
||||
}
|
||||
});
|
||||
|
||||
test('$where nested inside an ordinary-looking query is refused too', () => {
|
||||
// The people/org handlers merge the caller's selector into `{ $and: [query,
|
||||
// restriction] }` rather than stripping operators out of it, so a payload one
|
||||
// level down is exactly as dangerous as one at the top.
|
||||
assert.strictEqual(wouldRefuse({ $and: [{ username: 'x' }, { $where: 'true' }] }), true);
|
||||
assert.strictEqual(wouldRefuse({ $or: [{ $where: 'true' }] }), true);
|
||||
assert.strictEqual(wouldRefuse({ profile: { $where: 'true' } }), true);
|
||||
});
|
||||
|
||||
test('the other execution operators are refused', () => {
|
||||
// The advisory notes MongoDB 7 happens to reject $where inside the count
|
||||
// methods' aggregation pipeline, but $function/$accumulator are legitimate
|
||||
// aggregation operators there and were never separately tested. The guard must
|
||||
// not depend on an engine-level accident.
|
||||
assert.strictEqual(wouldRefuse({ $expr: { $function: { body: 'function(){}', args: [], lang: 'js' } } }), true);
|
||||
assert.strictEqual(wouldRefuse({ $expr: { $accumulator: { init: 'function(){}', lang: 'js' } } }), true);
|
||||
});
|
||||
|
||||
// ── and what must keep working ──────────────────────────────────────────────
|
||||
|
||||
test('everything the Admin Panel really sends is untouched', () => {
|
||||
// If any of these were refused, an admin would see an empty People, Org, Team
|
||||
// or Translation table - the fix would be worse than the bug.
|
||||
for (const ordinary of [
|
||||
{},
|
||||
null,
|
||||
undefined,
|
||||
{ username: 'joe' },
|
||||
{ username: { $regex: 'jo', $options: 'i' } },
|
||||
{ 'profile.fullname': { $regex: 'Smith' } },
|
||||
{ emails: { $elemMatch: { address: 'a@b.c' } } },
|
||||
{ $or: [{ username: { $regex: 'x' } }, { 'emails.address': { $regex: 'x' } }] },
|
||||
{ orgs: { $in: ['id1', 'id2'] } },
|
||||
{ teamIsActive: true },
|
||||
{ language: 'fi', text: { $regex: 'board' } },
|
||||
{ createdAt: { $gte: new Date('2026-01-01') } },
|
||||
{ $and: [{ a: 1 }, { b: { $ne: 2 } }] },
|
||||
]) {
|
||||
assert.strictEqual(wouldRefuse(ordinary), false,
|
||||
`${JSON.stringify(ordinary)} is an ordinary Admin Panel query and must go through`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── the guard module ────────────────────────────────────────────────────────
|
||||
|
||||
const guard = read('server/lib/selectorGuard.js');
|
||||
|
||||
test('the refusal is a selector that matches nothing, not an exception', () => {
|
||||
assert.ok(/_id: Object\.freeze\(\{ \$in: Object\.freeze\(\[\]\) \}\)/.test(guard),
|
||||
'MATCH_NOTHING must be { _id: { $in: [] } } - the same refusal cardsWindow.js ' +
|
||||
'already uses in production, so a refused request returns nothing instead of ' +
|
||||
'throwing at an admin mid-page');
|
||||
assert.ok(/Object\.freeze/.test(guard),
|
||||
'and frozen, so a caller cannot mutate the shared object into one that matches');
|
||||
});
|
||||
|
||||
test('a refusal is recorded, not silently swallowed', () => {
|
||||
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(guard),
|
||||
'an execution operator in a selector is an attempt, and the canary is what ' +
|
||||
'names who sent it and from where');
|
||||
});
|
||||
|
||||
// ── every handler the advisory names ────────────────────────────────────────
|
||||
|
||||
const HANDLERS = [
|
||||
['server/publications/people.js', "safeSelector(query, 'people')", 'peopleScopeSelector(user, safeQuery)'],
|
||||
['server/publications/org.js', "safeSelector(query, 'org')", 'orgScopeSelector(user, safeQuery)'],
|
||||
['server/publications/team.js', "safeSelector(query, 'team')", 'getTeams(safeQuery'],
|
||||
['server/publications/translation.js', "safeSelector(query, 'translation')", 'getTranslations(safeQuery'],
|
||||
['server/models/users.js', "safeSelector(query || {}, 'getUsersCollectionCount')", null],
|
||||
['server/models/users.js', "safeSelector(query || {}, 'getPeoplePageIds')", null],
|
||||
['server/models/org.js', "safeSelector(query || {}, 'getOrgsCollectionCount')", null],
|
||||
['server/models/team.js', "safeSelector(query || {}, 'getTeamsCollectionCount')", null],
|
||||
['server/models/translation.js', "safeSelector(query || {}, 'getTranslationsCollectionCount')", null],
|
||||
];
|
||||
|
||||
test('all nine call sites route their selector through the guard', () => {
|
||||
for (const [file, call, usage] of HANDLERS) {
|
||||
const src = read(file);
|
||||
assert.ok(src.includes(call),
|
||||
`${file}: the advisory names this handler; it must call ${call}`);
|
||||
assert.ok(/import \{ safeSelector \} from '\/server\/lib\/selectorGuard'/.test(src),
|
||||
`${file}: must import the shared guard`);
|
||||
if (usage) {
|
||||
assert.ok(src.includes(usage),
|
||||
`${file}: the GUARDED selector has to be the one that reaches the query - ` +
|
||||
`computing safeQuery and then passing the raw one would fix nothing`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('no handler still passes the raw selector to the database', () => {
|
||||
// The specific mistake to guard against: calling safeSelector and then using
|
||||
// `query` anyway. Checked on the exact expressions the advisory quotes.
|
||||
const leftovers = [
|
||||
['server/publications/people.js', 'peopleScopeSelector(user, query)'],
|
||||
['server/publications/org.js', 'orgScopeSelector(user, query)'],
|
||||
['server/publications/team.js', 'getTeams(query,'],
|
||||
['server/publications/translation.js', 'getTranslations(query,'],
|
||||
['server/models/team.js', 'getTeams(query || {}'],
|
||||
['server/models/translation.js', 'getTranslations(query || {}'],
|
||||
['server/models/org.js', 'orgScopeSelector(user, query || {})'],
|
||||
['server/models/users.js', 'peopleScopeSelector(currentUser, query || {})'],
|
||||
];
|
||||
for (const [file, raw] of leftovers) {
|
||||
assert.ok(!read(file).includes(raw),
|
||||
`${file}: still passes the unguarded selector (${raw})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('there is exactly ONE copy of the check', () => {
|
||||
// The bug was eight places not doing what one place did. A second copy is that
|
||||
// set up to happen again, so cardsWindow.js imports the shared one rather than
|
||||
// keeping its own.
|
||||
const cards = read('server/publications/cardsWindow.js');
|
||||
assert.ok(/import \{ selectorIsInjection \} from '\/server\/lib\/selectorGuard'/.test(cards),
|
||||
'cardsWindow.js must import the guard it used to define');
|
||||
assert.ok(!/function selectorIsInjection/.test(cards),
|
||||
'and must not define its own any more');
|
||||
assert.ok(/function selectorIsInjection/.test(guard),
|
||||
'the one definition lives in the shared module');
|
||||
assert.ok(cards.includes("selectorIsInjection(cardSelector, 'boardCardsWindow')") &&
|
||||
cards.includes("selectorIsInjection(cardSelector, 'boardCardsCount')"),
|
||||
'and its two existing refusal sites are unchanged');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -81,13 +81,21 @@ test('it changes nothing and stops cleanly, rather than failing into a restart',
|
||||
test('mongodb-control does not start a mongod that is known to fail', () => {
|
||||
const at = mongodb.indexOf(MARKER);
|
||||
assert.notStrictEqual(at, -1, 'mongodb-control reads the marker');
|
||||
const block = mongodb.slice(at, at + 1400);
|
||||
// Window widened for #6471: the refusal is now preceded by the staleness check
|
||||
// that lets a NEW revision retry, so the block being asserted on starts further
|
||||
// up. The behaviour asserted below is unchanged.
|
||||
const block = mongodb.slice(at, at + 2600);
|
||||
assert.ok(/exit 0/.test(block), 'and exits 0 rather than looping through snapd restarts');
|
||||
// The marker check must come BEFORE mongod is started, or the loop is unchanged.
|
||||
assert.ok(mongodb.indexOf(MARKER) < mongodb.indexOf('handle_mongod_start_failure'),
|
||||
'the check is before the start-failure handling it replaces');
|
||||
assert.ok(/snap revert/.test(block) && /mongodump|dump the data/.test(block),
|
||||
'and the log says both ways forward');
|
||||
// #6471: ...but only when the marker is about THIS revision. A marker written
|
||||
// by an older snap, which had fewer readers, must not stop this one trying -
|
||||
// that is what made 10.81 behave exactly like 10.79 for the reporter.
|
||||
assert.ok(/stale-marker" "\$MONGODB_DATA_TOO_OLD"/.test(block),
|
||||
'the refusal must first ask whether the marker is even about this snap');
|
||||
});
|
||||
|
||||
test('wekan-control serves the page instead of waiting for a database that is not coming', () => {
|
||||
@@ -190,7 +198,13 @@ test('mongod 4.2 is bundled for amd64 and arm64, and only used to READ', () => {
|
||||
// It must RUN in the build, not merely exist.
|
||||
assert.ok(/LD_LIBRARY_PATH="\$dest\/lib" "\$dest\/bin\/mongod" --version/.test(part),
|
||||
'the build runs the staged binary once, so a missing library is found here');
|
||||
assert.ok(/does not run in this environment[\s\S]{0,200}rm -rf "\$dest"/.test(part),
|
||||
// CHANGED DELIBERATELY: it used to require `rm -rf "$dest"`, and removing the
|
||||
// DIRECTORY is what ended the whole snap build - `stage: mongo42` names that
|
||||
// path, and snapcraft fails on a filter whose path is missing rather than
|
||||
// skipping it. The give-up still unstages the binary; it just leaves the empty
|
||||
// directory behind for the filter to copy. migration-control guards every use
|
||||
// on `-x "$M42/bin/mongod"`, so an empty one still means "no 4.2 reader".
|
||||
assert.ok(/does not run in this environment[\s\S]{0,300}rm -rf "\$\{dest:\?\}"\/\*/.test(part),
|
||||
'and unstages it rather than shipping something that cannot start');
|
||||
|
||||
// Optional by design: a failure must not fail the snap.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: a part that names paths in `stage:` / `prime:` must produce them on
|
||||
// EVERY architecture it is built for.
|
||||
// Run: node tests/snapPartFilters.test.cjs
|
||||
//
|
||||
// This is the v10.80 snap failure, and it took four releases to read correctly
|
||||
// because the job's own error message guessed at it. All four Launchpad
|
||||
// architectures - armhf, s390x, ppc64el, riscv64, three attempts each - died on
|
||||
// one deterministic line in the build log:
|
||||
//
|
||||
// Staging mongo42
|
||||
// Failed to copy '/build/.../parts/mongo42/install/mongo42':
|
||||
// no such file or directory.
|
||||
// Build failed
|
||||
//
|
||||
// `mongo42` downloads MongoDB 4.2 so an old database can be migrated, and
|
||||
// MongoDB publishes 4.2 for amd64 and arm64 only - so on every other
|
||||
// architecture the part printed "nothing to migrate from there; skipping" and
|
||||
// exited 0 having created nothing. Its comment calls it "OPTIONAL BY DESIGN".
|
||||
// It was not optional: `stage: [mongo42]` and `prime: [mongo42]` name that path
|
||||
// explicitly, and snapcraft does not skip a filter whose path is missing - it
|
||||
// ends the build. So an optional migration helper took the whole snap down on
|
||||
// the four architectures that have NO native runner and can only be built there.
|
||||
//
|
||||
// The part creates its directory before anything can decide to skip now, so the
|
||||
// filters always have something to copy and "no MongoDB 4.2 here" stays a
|
||||
// missing BINARY rather than a failed build.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = (() => {
|
||||
try { return require('js-yaml'); } catch { return null; }
|
||||
})();
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const raw = fs.readFileSync(path.join(repoRoot, 'snapcraft.yaml'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// The parts, read without a YAML library so this suite needs no dependency:
|
||||
// a part is a key at four spaces under `parts:`, and its body runs to the next.
|
||||
function parts() {
|
||||
const at = raw.indexOf('\nparts:\n');
|
||||
assert.notStrictEqual(at, -1, 'snapcraft.yaml has no parts:');
|
||||
const body = raw.slice(at + 1);
|
||||
const out = {};
|
||||
const re = /^ {4}([a-z0-9][a-z0-9._-]*):$/gm;
|
||||
const starts = [];
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) starts.push({ name: m[1], at: m.index });
|
||||
for (let i = 0; i < starts.length; i += 1) {
|
||||
const end = i + 1 < starts.length ? starts[i + 1].at : body.length;
|
||||
out[starts[i].name] = body.slice(starts[i].at, end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allParts = parts();
|
||||
|
||||
test('the parts of snapcraft.yaml are found', () => {
|
||||
assert.ok(Object.keys(allParts).length >= 3,
|
||||
`expected several parts, found ${Object.keys(allParts).join(', ') || 'none'}`);
|
||||
for (const name of ['mongo42', 'wekan']) {
|
||||
assert.ok(allParts[name], `snapcraft.yaml has no ${name} part`);
|
||||
}
|
||||
});
|
||||
|
||||
test('snapcraft.yaml is valid YAML and its filters read the same as the text', () => {
|
||||
if (!yaml) return; // js-yaml is optional here; the text scan above is the guard
|
||||
const doc = yaml.load(raw);
|
||||
assert.deepStrictEqual(doc.parts.mongo42.stage, ['mongo42']);
|
||||
assert.deepStrictEqual(doc.parts.mongo42.prime, ['mongo42']);
|
||||
});
|
||||
|
||||
test('mongo42 creates its staged directory BEFORE it can decide to skip', () => {
|
||||
// Comment lines dropped: the part now EXPLAINS the `exit 0` at length above the
|
||||
// code, and prose about a line must not be mistaken for the line.
|
||||
const part = allParts.mongo42
|
||||
.split('\n').filter(l => !/^\s*#/.test(l)).join('\n');
|
||||
const mkdir = part.indexOf('mkdir -p "$dest/bin" "$dest/lib"');
|
||||
const skip = part.indexOf('exit 0');
|
||||
assert.notStrictEqual(mkdir, -1, 'mongo42 must create $SNAPCRAFT_PART_INSTALL/mongo42');
|
||||
assert.notStrictEqual(skip, -1, 'mongo42 must still skip on an unsupported architecture');
|
||||
assert.ok(mkdir < skip,
|
||||
'the directory has to exist before the unsupported-architecture `exit 0`, or ' +
|
||||
'`stage: [mongo42]` has nothing to copy and snapcraft ENDS THE BUILD - which ' +
|
||||
'is what failed every Launchpad architecture in v10.80');
|
||||
const dest = part.indexOf('dest="$SNAPCRAFT_PART_INSTALL/mongo42"');
|
||||
assert.ok(dest !== -1 && dest < skip, '$dest must be set before the skip too');
|
||||
});
|
||||
|
||||
test('no path in mongo42 deletes the directory its stage filter names', () => {
|
||||
// The first fix covered the unsupported-architecture `exit 0`. It did NOT cover
|
||||
// the other two ways this part gives up - OpenSSL 1.1 unavailable, and the
|
||||
// staged mongod failing its smoke test - which both did `rm -rf "$dest"` and
|
||||
// exited 0, leaving `stage: mongo42` naming a path that is not there. On
|
||||
// amd64/arm64, where the binary IS downloaded, either of those would have ended
|
||||
// the whole snap build exactly as the unsupported arches did.
|
||||
const part = allParts.mongo42;
|
||||
assert.ok(!/rm -rf "\$dest"\s*$/m.test(part) && !/rm -rf "\$\{dest\}"\s*$/m.test(part),
|
||||
'a give-up path must remove the CONTENTS and keep the directory, so the ' +
|
||||
'filter has something to copy: rm -rf "${dest:?}"/*');
|
||||
const gives_up = (part.match(/rm -rf "\$\{dest:\?\}"\/\*/g) || []).length;
|
||||
assert.ok(gives_up >= 2,
|
||||
`both give-up paths (no OpenSSL 1.1, smoke test failed) must clear contents ` +
|
||||
`only; found ${gives_up}`);
|
||||
});
|
||||
|
||||
test('mongo42 is still amd64/arm64 only - the fix is not "download it everywhere"', () => {
|
||||
const part = allParts.mongo42;
|
||||
assert.ok(/amd64\) MARCH=x86_64/.test(part) && /arm64\) MARCH=aarch64/.test(part),
|
||||
'the two architectures MongoDB publishes 4.2 for must stay the only two that ' +
|
||||
'download it');
|
||||
assert.ok(/nothing to migrate from there; skipping/.test(part),
|
||||
'and every other architecture must still skip, with a reason in the log');
|
||||
});
|
||||
|
||||
test('every path a part stages or primes is one that part can produce', () => {
|
||||
// The general rule the mongo42 failure is one instance of. A filter entry that
|
||||
// is a literal path (no glob) must appear in the part's override-build as
|
||||
// something it creates - otherwise there is an architecture where it will not
|
||||
// be there, and the build ends rather than skipping.
|
||||
const problems = [];
|
||||
for (const [name, body] of Object.entries(allParts)) {
|
||||
for (const key of ['stage', 'prime']) {
|
||||
const m = body.match(new RegExp(`^ {8}${key}:\\n((?: {12}- .+\\n)+)`, 'm'));
|
||||
if (!m) continue;
|
||||
for (const line of m[1].trim().split('\n')) {
|
||||
const entry = line.replace(/^\s*-\s*/, '').trim();
|
||||
if (entry.startsWith('-') || entry.includes('*')) continue; // exclusions/globs
|
||||
const build = body.match(/override-build: \|\n([\s\S]*?)(?=\n {8}[a-z-]+:|\n {4}[a-z0-9-]+:|$)/);
|
||||
if (!build) continue;
|
||||
if (!build[1].includes(entry)) {
|
||||
problems.push(`${name}: ${key} names "${entry}", which its override-build never creates`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(problems, [],
|
||||
'a stage:/prime: entry that the part does not always produce is not skipped - ' +
|
||||
'snapcraft fails the whole build on it:\n ' + problems.join('\n '));
|
||||
});
|
||||
|
||||
test('the wekan part retries npm install, which is what the build farm drops', () => {
|
||||
const part = allParts.wekan;
|
||||
assert.ok(/for attempt in 1 2 3; do/.test(part),
|
||||
'npm install must be retried: on Launchpad every request goes through the ' +
|
||||
'build farm proxy and a single tarball can be cut mid-stream (ECONNRESET), ' +
|
||||
'which ended the riscv64 snap in v10.80 after half an hour of building');
|
||||
assert.ok(/npm install failed three times in a row/.test(part),
|
||||
'and after three tries it must fail loudly rather than retry forever');
|
||||
});
|
||||
|
||||
test('the job error message points at the log instead of guessing', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release-all.yml'), 'utf8');
|
||||
// ALL of them: the job prints more than one - the retry loop's diagnosis and
|
||||
// the "Job result" step's one-liner - and picking the first found the wrong one.
|
||||
const lines = workflow.split('\n').filter(l => l.includes('::error::snap-launchpad'));
|
||||
assert.ok(lines.length, 'the snap-launchpad failure must print a named ::error::');
|
||||
assert.ok(lines.some(l => /the Launchpad build log is printed above - read it first/.test(l)),
|
||||
'the log is printed by the step above and it names the failing part; the ' +
|
||||
'message must send the reader there. Got:\n ' + lines.join('\n '));
|
||||
assert.ok(!lines.some(l => /often an OOM in the Meteor npm install/.test(l)),
|
||||
'that guess was wrong for four releases while the log said "Failed to copy ' +
|
||||
'.../parts/mongo42/install/mongo42" every time');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
// #6471 — "Snap 10.79 This version of MongoDB is too recent to start up on the
|
||||
// existing data files. Try MongoDB 4.2 or earlier.", reopened against 10.81:
|
||||
//
|
||||
// "on 10.81 I still have the exact same issue, MongoDB cannot start. The web
|
||||
// interface is still unreachable, and I do not see the messages you added in
|
||||
// the last commits."
|
||||
//
|
||||
// Run: node tests/snapStaleMarker.test.cjs
|
||||
//
|
||||
// The 4.2 reader added for this issue was present in their snap and never ran,
|
||||
// because TWO MARKERS IN $SNAP_COMMON WERE PERMANENT:
|
||||
//
|
||||
// .mongodb-data-too-old written when no reader in the snap could open the
|
||||
// data. mongodb-control then refuses to start mongod
|
||||
// at all, and wekan-control serves an explanatory page
|
||||
// instead of WeKan.
|
||||
// .mongod-start-failures a counter; past three, mongodb-control stops
|
||||
// re-running the migration so a migration<->mongod
|
||||
// restart loop cannot form.
|
||||
//
|
||||
// Both are right and both record a conclusion about what THAT SNAP could do. An
|
||||
// instance that failed on 10.79 or 10.80 - before the 4.2 reader existed - kept a
|
||||
// marker saying "no reader can open this" and a counter far past three, so 10.81
|
||||
// never started mongod, never ran the migration, and printed none of the new
|
||||
// messages. Upgrading to the version with the fix changed nothing, which is
|
||||
// exactly what their log shows: the database selection line, then "Waiting for
|
||||
// MongoDB replica set primary..." forever.
|
||||
//
|
||||
// A NEW REVISION IS A NEW CHANCE. Each marker records the revision that wrote it;
|
||||
// when the running revision differs it is stale and the snap tries again. Within
|
||||
// one revision nothing changes - the loop protection and the refusal both still
|
||||
// hold - so this does not reintroduce the loop they exist to prevent.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const helper = path.join(repoRoot, 'snap-src/bin/stale-marker');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
const mongodbControl = read('snap-src/bin/mongodb-control');
|
||||
const wekanControl = read('snap-src/bin/wekan-control');
|
||||
const migrationControl = read('snap-src/bin/migration-control');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
const run = (args, revision) =>
|
||||
spawnSync('bash', [helper, ...args], {
|
||||
env: { ...process.env, SNAP_REVISION: revision === undefined ? '' : String(revision) },
|
||||
}).status;
|
||||
const isStale = (marker, revision) => run([marker], revision) === 0;
|
||||
const stamp = (marker, revision) => run(['--stamp', marker], revision);
|
||||
|
||||
function withMarker(fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-marker-'));
|
||||
const marker = path.join(dir, '.mongodb-data-too-old');
|
||||
try { return fn(marker, dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
test('the helper exists, is executable and parses', () => {
|
||||
assert.ok(fs.existsSync(helper), 'snap-src/bin/stale-marker is missing');
|
||||
assert.ok(fs.statSync(helper).mode & 0o111, 'it must be executable');
|
||||
assert.strictEqual(spawnSync('bash', ['-n', helper]).status, 0);
|
||||
});
|
||||
|
||||
test('the reported case: a marker written by an older revision is stale', () => {
|
||||
withMarker(marker => {
|
||||
fs.writeFileSync(marker, '4.2\n');
|
||||
stamp(marker, 3449); // the revision that gave up
|
||||
assert.strictEqual(isStale(marker, 3500), true,
|
||||
'a newer revision may have readers the old one did not - which is the whole ' +
|
||||
'of this issue');
|
||||
});
|
||||
});
|
||||
|
||||
test('within the SAME revision the marker still holds', () => {
|
||||
withMarker(marker => {
|
||||
fs.writeFileSync(marker, '4.2\n');
|
||||
stamp(marker, 3500);
|
||||
assert.strictEqual(isStale(marker, 3500), false,
|
||||
'or the loop protection and the "do not start mongod on data it cannot ' +
|
||||
'read" refusal would both be gone');
|
||||
});
|
||||
});
|
||||
|
||||
test('a marker from before this mechanism is stale by definition', () => {
|
||||
withMarker(marker => {
|
||||
fs.writeFileSync(marker, '4.2\n'); // no companion revision file
|
||||
assert.strictEqual(isStale(marker, 3500), true,
|
||||
'that is precisely the marker the affected instances are carrying - written ' +
|
||||
'by a snap that did not record revisions');
|
||||
});
|
||||
});
|
||||
|
||||
test('not knowing the revision is never treated as evidence it changed', () => {
|
||||
withMarker(marker => {
|
||||
fs.writeFileSync(marker, '4.2\n');
|
||||
stamp(marker, 3449);
|
||||
assert.strictEqual(isStale(marker, undefined), false,
|
||||
'outside a snap there is no SNAP_REVISION; the marker must be honoured ' +
|
||||
'rather than cleared on a guess');
|
||||
});
|
||||
});
|
||||
|
||||
test('no marker is not stale, and stamping without a revision does nothing', () => {
|
||||
withMarker((marker, dir) => {
|
||||
assert.strictEqual(isStale(marker, 3500), false, 'nothing to be stale about');
|
||||
stamp(marker, undefined);
|
||||
assert.strictEqual(fs.existsSync(`${marker}.revision`), false,
|
||||
'a revision file with no revision in it would make every marker look current');
|
||||
assert.ok(fs.existsSync(dir));
|
||||
});
|
||||
});
|
||||
|
||||
// ── the three places that consult it ────────────────────────────────────────
|
||||
|
||||
test('mongodb-control retries when the "too old" marker is from another revision', () => {
|
||||
assert.ok(/stale-marker" "\$MONGODB_DATA_TOO_OLD"/.test(mongodbControl),
|
||||
'the refusal to start mongod must ask whether the marker is even about this snap');
|
||||
const at = mongodbControl.indexOf('stale-marker" "$MONGODB_DATA_TOO_OLD"');
|
||||
const after = mongodbControl.slice(at, at + 400);
|
||||
assert.ok(/rm -f "\$MONGODB_DATA_TOO_OLD"/.test(after),
|
||||
'and clear it, so the run proceeds normally rather than being skipped again');
|
||||
assert.ok(/mongod-start-failures/.test(after),
|
||||
'and clear the failure counter with it - an instance that gave up on an older ' +
|
||||
'revision has both, and leaving one of them latched fixes nothing');
|
||||
});
|
||||
|
||||
test('the failure counter is per revision, not per installation', () => {
|
||||
const at = mongodbControl.indexOf('local fails=0');
|
||||
const body = mongodbControl.slice(at, at + 700);
|
||||
assert.ok(/! bash "\$SNAP\/bin\/stale-marker" "\$MONGOD_FAIL_COUNT_FILE"/.test(body),
|
||||
'a count from another revision must not be carried forward');
|
||||
assert.ok(/stale-marker" --stamp "\$MONGOD_FAIL_COUNT_FILE"/.test(body),
|
||||
'and the count that is written has to record which revision it belongs to');
|
||||
});
|
||||
|
||||
test('a mongod that DOES start still clears the counter completely', () => {
|
||||
assert.ok(/rm -f "\$MONGOD_FAIL_COUNT_FILE" "\$\{MONGOD_FAIL_COUNT_FILE\}\.revision"/.test(mongodbControl),
|
||||
'success clears both files, or the next failure would inherit a revision stamp ' +
|
||||
'with no counter');
|
||||
});
|
||||
|
||||
test('the marker records the revision that wrote it', () => {
|
||||
assert.ok(/stale-marker" --stamp "\$SNAP_COMMON\/\.mongodb-data-too-old"/.test(migrationControl),
|
||||
'migration-control writes the marker, so it is what must stamp it');
|
||||
const write = migrationControl.indexOf("printf '%s\\n' \"$can\" >");
|
||||
const stampAt = migrationControl.indexOf('--stamp "$SNAP_COMMON/.mongodb-data-too-old"');
|
||||
assert.ok(write !== -1 && stampAt > write, 'stamped after it is written');
|
||||
});
|
||||
|
||||
test('wekan-control does not keep serving the page on a stale marker', () => {
|
||||
assert.ok(/stale-marker" "\$SNAP_COMMON\/\.mongodb-data-too-old"/.test(wekanControl),
|
||||
'this service can start before mongodb-control, so it has to make the same ' +
|
||||
'judgement rather than serving "too old for this snap" on a snap that has ' +
|
||||
'not tried yet');
|
||||
const at = wekanControl.indexOf('stale-marker" "$SNAP_COMMON/.mongodb-data-too-old"');
|
||||
const page = wekanControl.indexOf('DATABASE TOO OLD FOR THIS SNAP');
|
||||
assert.ok(at !== -1 && at < page, 'and check before it decides to serve it');
|
||||
});
|
||||
|
||||
test('the helper ships in the snap', () => {
|
||||
const snapcraft = read('snapcraft.yaml');
|
||||
assert.ok(/helpers:\s*\n\s*source: snap-src\s*\n\s*plugin: dump/.test(snapcraft),
|
||||
'snap-src is dumped wholesale, which is what puts bin/stale-marker in $SNAP/bin');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -104,18 +104,29 @@ test('the people publication and its count use the SAME scope function', () => {
|
||||
const count = read('server/models/users.js');
|
||||
assert.ok(/tenantAdmin\.canOpenAdminPanel\(user\)/.test(pub),
|
||||
'the publication opens for a per-tenant admin too');
|
||||
assert.ok(/tenantAdmin\.peopleScopeSelector\(user, query\)/.test(pub),
|
||||
'and scopes with the shared rule instead of the raw query');
|
||||
// GHSA-phm4-4v26-j2vq: what reaches the scope function is the selector AFTER the
|
||||
// injection guard, not the raw one. The scoping rule is unchanged - it still
|
||||
// merges the restriction under $and - but it no longer has to be handed a
|
||||
// selector that might carry $where, because merging never stripped that out.
|
||||
assert.ok(/tenantAdmin\.peopleScopeSelector\(user, safeQuery\)/.test(pub),
|
||||
'and scopes with the shared rule, applied to the guarded query');
|
||||
assert.ok(/safeSelector\(query, 'people'\)/.test(pub),
|
||||
'which means the guard has to run first');
|
||||
assert.ok(!/user\.isAdmin/.test(live(pub)), 'no second opinion left in the publication');
|
||||
assert.ok(/tenantAdmin\.peopleScopeSelector\(currentUser, query \|\| \{\}\)/.test(count),
|
||||
// Same rule, same guard: the count and the page ids both scope the selector the
|
||||
// publication scopes, and both hand it over only after the injection guard
|
||||
// (GHSA-phm4-4v26-j2vq) has had it.
|
||||
assert.ok(/tenantAdmin\.peopleScopeSelector\(currentUser, safeSelector\(query \|\| \{\}, 'getUsersCollectionCount'\)\)/.test(count),
|
||||
'getUsersCollectionCount is scoped the same way, or the pager counts the wrong set');
|
||||
assert.ok(/tenantAdmin\.peopleScopeSelector\(currentUser, safeSelector\(query \|\| \{\}, 'getPeoplePageIds'\)\)/.test(count),
|
||||
'and so is getPeoplePageIds, which reads the page back');
|
||||
});
|
||||
|
||||
test('the org publication and its count are scoped the same way', () => {
|
||||
const pub = read('server/publications/org.js');
|
||||
const count = read('server/models/org.js');
|
||||
assert.ok(/tenantAdmin\.orgScopeSelector\(user, query\)/.test(pub));
|
||||
assert.ok(/tenantAdmin\.orgScopeSelector\(user, query \|\| \{\}\)/.test(count));
|
||||
assert.ok(/tenantAdmin\.orgScopeSelector\(user, safeQuery\)/.test(pub));
|
||||
assert.ok(/tenantAdmin\.orgScopeSelector\(user, safeSelector\(query \|\| \{\}, 'getOrgsCollectionCount'\)\)/.test(count));
|
||||
assert.ok(/tenantAdmin\.canOpenAdminPanel\(user\)/.test(count));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
// Email feedback (../log/wekan17/email-feedback/cpu-monster): FerretDB at
|
||||
// 190-350% CPU on an instance with 14490 boards, of which 13404 are template
|
||||
// containers, for 9264 accounts of which 478 have ever logged in.
|
||||
// Run: node tests/unusedTemplateContainers.test.cjs
|
||||
//
|
||||
// Before v10.00 every new account got a "Templates" container board at signup
|
||||
// whether or not the person ever saved a template. That was made lazy in v10.00
|
||||
// (#2339, #5850), so no NEW account creates one - but nothing ever removed the
|
||||
// ones already made, and they are not visible enough for anyone to delete by
|
||||
// hand. They are 13x the boards collection on that instance, and every query
|
||||
// that touches boards carries it.
|
||||
//
|
||||
// THIS DELETES BOARDS, so the rule for what may go is the whole of the risk and
|
||||
// is deliberately narrow: only a container nobody ever used. Anything at all
|
||||
// having been done to one - a template saved into it, a list or card added, a
|
||||
// second member, a rename, a star, a manual archive - keeps it. This suite is
|
||||
// about the cases that must NOT be removed at least as much as the one that
|
||||
// should.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const {
|
||||
classifyTemplateContainer,
|
||||
templateContainersSelector,
|
||||
planTemplateContainerCleanup,
|
||||
} = require(path.join(repoRoot, 'models/lib/unusedTemplateContainers.js'));
|
||||
const method = fs.readFileSync(
|
||||
path.join(repoRoot, 'server/methods/cleanupTemplateContainers.js'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
const container = (over = {}) => ({
|
||||
_id: 'b1',
|
||||
type: 'template-container',
|
||||
title: 'Templates',
|
||||
members: [{ userId: 'u1', isAdmin: true }],
|
||||
archived: false,
|
||||
starred: false,
|
||||
...over,
|
||||
});
|
||||
const EMPTY = { templateCount: 0, listCount: 0, swimlaneCount: 0, cardCount: 0 };
|
||||
const OPTS = { defaultTitles: ['Templates', 'templates', 'Template Container'] };
|
||||
|
||||
test('an untouched, empty container is removable', () => {
|
||||
const v = classifyTemplateContainer(container(), EMPTY, OPTS);
|
||||
assert.strictEqual(v.removable, true, JSON.stringify(v.reasons));
|
||||
assert.deepStrictEqual(v.reasons, []);
|
||||
});
|
||||
|
||||
test('anything saved into it keeps it', () => {
|
||||
for (const [field, label] of [
|
||||
['templateCount', 'template'],
|
||||
['cardCount', 'card'],
|
||||
['listCount', 'list'],
|
||||
['swimlaneCount', 'swimlane'],
|
||||
]) {
|
||||
const v = classifyTemplateContainer(
|
||||
container(), { ...EMPTY, [field]: 1 }, OPTS);
|
||||
assert.strictEqual(v.removable, false,
|
||||
`a container holding a ${label} was used, so it is not ours to delete`);
|
||||
assert.ok(v.reasons.some(r => r.includes(label)),
|
||||
`and the reason has to say so: ${JSON.stringify(v.reasons)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a shared, renamed, starred or archived container keeps it', () => {
|
||||
const cases = [
|
||||
[container({ members: [{ userId: 'u1' }, { userId: 'u2' }] }), 'shared'],
|
||||
[container({ title: 'My project templates' }), 'renamed'],
|
||||
[container({ starred: true }), 'starred'],
|
||||
[container({ archived: true }), 'archived'],
|
||||
];
|
||||
for (const [board, why] of cases) {
|
||||
const v = classifyTemplateContainer(board, EMPTY, OPTS);
|
||||
assert.strictEqual(v.removable, false,
|
||||
`a ${why} container is somebody's, whatever is inside it`);
|
||||
assert.ok(v.reasons.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a rename is only a rename against the titles the app really used', () => {
|
||||
// The default title is translated and has changed, so the caller supplies the
|
||||
// set. Without it, nothing is judged on the title at all - better than
|
||||
// deleting a board because its default name is in another language.
|
||||
const german = container({ title: 'Vorlagen' });
|
||||
assert.strictEqual(classifyTemplateContainer(german, EMPTY, OPTS).removable, false,
|
||||
'an unknown title counts as renamed when a list is given');
|
||||
assert.strictEqual(classifyTemplateContainer(german, EMPTY, {}).removable, true,
|
||||
'and is not judged at all when no list is given');
|
||||
assert.strictEqual(
|
||||
classifyTemplateContainer(container({ title: ' Templates ' }), EMPTY, OPTS).removable,
|
||||
true, 'whitespace is not a rename');
|
||||
});
|
||||
|
||||
test('nothing that is not a template container is ever touched', () => {
|
||||
for (const type of ['board', 'template-board', 'template-list', undefined]) {
|
||||
const v = classifyTemplateContainer(container({ type }), EMPTY, OPTS);
|
||||
assert.strictEqual(v.removable, false, `type ${type} must be left alone`);
|
||||
}
|
||||
for (const junk of [null, undefined, 'nope', 42]) {
|
||||
assert.strictEqual(classifyTemplateContainer(junk, EMPTY, OPTS).removable, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the selector narrows by type only, so the counts decide', () => {
|
||||
assert.deepStrictEqual(templateContainersSelector(), { type: 'template-container' },
|
||||
'what makes one removable needs counts from four other collections, so the ' +
|
||||
'selector cannot express it and must not pretend to');
|
||||
});
|
||||
|
||||
test('the plan separates what would go from what stays, with reasons', () => {
|
||||
const plan = planTemplateContainerCleanup([
|
||||
{ board: container({ _id: 'empty' }), counts: EMPTY, options: OPTS },
|
||||
{ board: container({ _id: 'used' }), counts: { ...EMPTY, templateCount: 3 }, options: OPTS },
|
||||
{ board: container({ _id: 'mine', title: 'Renamed' }), counts: EMPTY, options: OPTS },
|
||||
]);
|
||||
assert.deepStrictEqual(plan.remove.map(r => r.boardId), ['empty']);
|
||||
assert.deepStrictEqual(plan.keep.map(r => r.boardId).sort(), ['mine', 'used']);
|
||||
assert.ok(plan.keep.every(k => k.reasons.length > 0),
|
||||
'every kept board must say why it was kept - that is what tells an admin the ' +
|
||||
'rule is doing what they think');
|
||||
});
|
||||
|
||||
// ── the method around it ────────────────────────────────────────────────────
|
||||
|
||||
test('it does nothing unless asked twice', () => {
|
||||
assert.ok(/const apply = options\.apply === true;/.test(method),
|
||||
'the default must be a dry run: for thirteen thousand boards that is the ' +
|
||||
'difference between a cleanup and an accident');
|
||||
const dry = method.indexOf('if (!apply)');
|
||||
const remove = method.indexOf('Boards.removeAsync');
|
||||
assert.ok(dry !== -1 && remove > dry, 'the dry run returns before anything is removed');
|
||||
assert.ok(/wouldRemove: plan\.remove\.length/.test(method),
|
||||
'and reports what WOULD go');
|
||||
assert.ok(/removeSample|keepSample/.test(method),
|
||||
'with a sample rather than thirteen thousand rows');
|
||||
});
|
||||
|
||||
test('only an admin can run it', () => {
|
||||
assert.ok(/\?\.isAdmin\) \{[\s\S]{0,120}throw new Meteor\.Error\('not-authorized'/.test(method),
|
||||
'it deletes boards across every account');
|
||||
const guard = method.indexOf("not-authorized");
|
||||
// the CALL, not the definition further up the file
|
||||
const scan = method.indexOf('await collectContainers(limit)');
|
||||
assert.ok(guard !== -1 && guard < scan,
|
||||
'and the check must come before it even reads the boards');
|
||||
});
|
||||
|
||||
test('the user pointer is cleared with the board', () => {
|
||||
assert.ok(/profile\.templatesBoardId/.test(method) && /\$unset/.test(method),
|
||||
'a profile still pointing at a deleted board is what ensureTemplatesBoard ' +
|
||||
'would trip over next');
|
||||
});
|
||||
|
||||
test('a failure on one board does not abandon the rest', () => {
|
||||
assert.ok(/failed\.push\(/.test(method) && /catch \(error\)/.test(method),
|
||||
'thirteen thousand deletions must not stop at the first problem, and the ' +
|
||||
'ones that failed have to be reported');
|
||||
});
|
||||
|
||||
test('the method is registered, or it does not exist', () => {
|
||||
const imports = fs.readFileSync(path.join(repoRoot, 'server/imports.js'), 'utf8');
|
||||
assert.ok(/import '\/server\/methods\/cleanupTemplateContainers';/.test(imports));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: every runner label any workflow asks for is one GitHub still offers.
|
||||
// Run: node tests/workflowRunnerLabels.test.cjs
|
||||
//
|
||||
// A retired label does not fail the job - that is the whole problem. The job
|
||||
// QUEUES, waiting for a runner that is never coming, and sits there until
|
||||
// somebody cancels it by hand. In the v10.80 release run, build-mac-x64 asked
|
||||
// for `macos-13`, which GitHub had retired; it queued for nearly two hours, was
|
||||
// cancelled, and cancelling it cancelled the RUN - which SKIPPED the charts, ucs
|
||||
// and nextcloud jobs, because a cancelled run skips every job that has not
|
||||
// started yet. The chart for a released, published WeKan was never pushed, and
|
||||
// nothing in the log said "macos-13 no longer exists". No release has ever
|
||||
// carried a wekan-<version>-mac-x64.zip.
|
||||
//
|
||||
// So the label is checked here, where it costs a second, instead of being
|
||||
// discovered as a two-hour queue during a release. Intel macOS did not go away -
|
||||
// it was RENAMED: macos-15-intel and macos-26-intel. macos-14/15/26 without the
|
||||
// suffix are Apple Silicon.
|
||||
//
|
||||
// This list is GitHub's hosted-runner set (docs.github.com "GitHub-hosted
|
||||
// runners"). When GitHub adds an image, add the label here too - a label this
|
||||
// file does not know is reported, because an unknown label and a retired one
|
||||
// look exactly the same from the outside, and both queue forever.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const workflowDir = path.join(repoRoot, '.github/workflows');
|
||||
|
||||
// Currently offered GitHub-hosted labels.
|
||||
const CURRENT = new Set([
|
||||
// Ubuntu x64 / arm64
|
||||
'ubuntu-latest', 'ubuntu-26.04', 'ubuntu-24.04', 'ubuntu-22.04', 'ubuntu-slim',
|
||||
'ubuntu-26.04-arm', 'ubuntu-24.04-arm', 'ubuntu-22.04-arm',
|
||||
// Windows x64 / arm64
|
||||
'windows-latest', 'windows-2025', 'windows-2025-vs2026', 'windows-2022',
|
||||
'windows-11-arm', 'windows-11-vs2026-arm',
|
||||
// macOS arm64, then macOS Intel
|
||||
'macos-latest', 'macos-26', 'macos-15', 'macos-14',
|
||||
'macos-26-intel', 'macos-15-intel',
|
||||
]);
|
||||
|
||||
// Labels GitHub has REMOVED. Kept by name so the failure can say what replaced
|
||||
// them rather than only "unknown label".
|
||||
const RETIRED = new Map([
|
||||
['macos-15-large', 'a larger-runner label, not available here'],
|
||||
['macos-13', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
|
||||
['macos-13-xl', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
|
||||
['macos-12', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
|
||||
['macos-11', 'retired - Intel macOS is now macos-15-intel / macos-26-intel'],
|
||||
['ubuntu-20.04', 'retired - use ubuntu-22.04 or newer'],
|
||||
['ubuntu-18.04', 'retired - use ubuntu-22.04 or newer'],
|
||||
['windows-2019', 'retired - use windows-2022 or newer'],
|
||||
['windows-2016', 'retired - use windows-2022 or newer'],
|
||||
]);
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// Every `runs-on:` in every workflow, and every matrix value one can expand to.
|
||||
// A `runs-on: ${{ matrix.foo }}` is followed to the `foo:` entries of the matrix,
|
||||
// which is where the real labels are - checking only the literal `runs-on:`
|
||||
// lines would miss the arm64 and Intel-Mac legs entirely.
|
||||
function collectLabels() {
|
||||
const found = [];
|
||||
for (const file of fs.readdirSync(workflowDir).filter(f => /\.ya?ml$/.test(f))) {
|
||||
const text = fs.readFileSync(path.join(workflowDir, file), 'utf8');
|
||||
text.split('\n').forEach((line, i) => {
|
||||
const where = `${file}:${i + 1}`;
|
||||
const direct = line.match(/^\s*runs-on:\s*(.+?)\s*$/);
|
||||
if (direct) {
|
||||
// Strip a trailing comment, and skip the expression forms - their real
|
||||
// values come from the matrix keys matched below.
|
||||
const value = direct[1].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '');
|
||||
if (!value.includes('${{') && value !== '' && !value.startsWith('[')) {
|
||||
found.push({ where, label: value });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Matrix entries that feed a runs-on, e.g. `runs_on: ubuntu-24.04-arm`
|
||||
// or `- runs_on: macos-15-intel`.
|
||||
const matrix = line.match(/^\s*-?\s*(?:runs_on|runner|runs-on-label):\s*(.+?)\s*$/);
|
||||
if (matrix) {
|
||||
const value = matrix[1].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '');
|
||||
if (!value.includes('${{') && value !== '') found.push({ where, label: value });
|
||||
}
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const labels = collectLabels();
|
||||
|
||||
test('workflows declare at least one runner label (the scan is not silently empty)', () => {
|
||||
assert.ok(labels.length >= 10,
|
||||
`expected to find runner labels across .github/workflows, found ${labels.length}`);
|
||||
});
|
||||
|
||||
test('no workflow asks for a runner label GitHub has retired', () => {
|
||||
const dead = labels.filter(l => RETIRED.has(l.label));
|
||||
assert.deepStrictEqual(dead, [],
|
||||
'these runner labels are retired and will QUEUE FOREVER instead of failing:\n' +
|
||||
dead.map(d => ` ${d.where}: ${d.label} - ${RETIRED.get(d.label)}`).join('\n'));
|
||||
});
|
||||
|
||||
test('every runner label is one GitHub currently offers', () => {
|
||||
const unknown = labels.filter(l => !CURRENT.has(l.label) && !RETIRED.has(l.label));
|
||||
assert.deepStrictEqual(unknown, [],
|
||||
'unknown runner label(s) - either GitHub added an image and CURRENT needs it, ' +
|
||||
'or this is a typo that will queue forever:\n' +
|
||||
unknown.map(u => ` ${u.where}: ${u.label}`).join('\n'));
|
||||
});
|
||||
|
||||
test('build-mac-x64 asks for an Intel macOS runner, not an Apple Silicon one', () => {
|
||||
const workflow = fs.readFileSync(path.join(workflowDir, 'release-all.yml'), 'utf8');
|
||||
const start = workflow.indexOf('\n build-mac-x64:\n');
|
||||
assert.notStrictEqual(start, -1, 'release-all.yml has no build-mac-x64 job');
|
||||
const rest = workflow.slice(start + 1);
|
||||
const next = rest.search(/\n [a-z0-9-]+:\n/);
|
||||
const body = next === -1 ? rest : rest.slice(0, next);
|
||||
const m = body.match(/^\s*runs-on:\s*(\S+)/m);
|
||||
assert.ok(m, 'build-mac-x64 has no runs-on');
|
||||
assert.ok(/-intel$/.test(m[1]),
|
||||
`build-mac-x64 builds the x86_64 macOS bundle, so it needs an Intel runner ` +
|
||||
`(macos-15-intel / macos-26-intel); it asks for "${m[1]}", which is Apple Silicon`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
Reference in new issue
Block a user