mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149944726e | ||
|
|
bf0b757862 | ||
|
|
1c7de782f0 | ||
|
|
a820b056b2 | ||
|
|
6dce9dda79 | ||
|
|
47c81cd478 | ||
|
|
bc426ba7c2 | ||
|
|
a0f445c723 | ||
|
|
ce69db8c5f | ||
|
|
5b31695343 | ||
|
|
4a8fbd954e | ||
|
|
04fb6e3381 | ||
|
|
5957fa0ad8 | ||
|
|
97e57e531b | ||
|
|
fa296d0c80 | ||
|
|
040c18bc23 | ||
|
|
7241570491 | ||
|
|
979ad3a5cd | ||
|
|
3c9291338c | ||
|
|
a5d8a1fd97 | ||
|
|
ab29f74f6a | ||
|
|
48d8dc9de8 | ||
|
|
983aba8488 | ||
|
|
988e2f0794 | ||
|
|
90a41dbb22 | ||
|
|
8da91adaa7 | ||
|
|
80830cd43e | ||
|
|
f2edde0651 | ||
|
|
ada480c93a | ||
|
|
114d6c0599 | ||
|
|
fc0ce5137b | ||
|
|
c3c95cefb9 | ||
|
|
aea9811a76 | ||
|
|
5a1227da6c | ||
|
|
8a5ca2c2de | ||
|
|
30e8e38f6c | ||
|
|
2cc0d42ddf | ||
|
|
d577f07700 | ||
|
|
839d3a820c | ||
|
|
a4a9c03b1f | ||
|
|
156121c4bc | ||
|
|
2c4e81ae34 | ||
|
|
b37adda8a4 | ||
|
|
a8fa6cfa3f | ||
|
|
35daaaa279 | ||
|
|
08baf7fd1b | ||
|
|
0a9eb0ef4f | ||
|
|
b2ef10d9d1 | ||
|
|
ad62101e10 | ||
|
|
a1c500e33b | ||
|
|
454ec04b97 | ||
|
|
f4e5843016 | ||
|
|
0dbcfc44e0 | ||
|
|
0612630836 | ||
|
|
eab75eab70 | ||
|
|
aa8f591839 | ||
|
|
e16ffbc12b | ||
|
|
d911d71e53 | ||
|
|
79b10a1b43 | ||
|
|
8c2565e996 | ||
|
|
6def3a14dd | ||
|
|
0409b348af | ||
|
|
466bf21a57 | ||
|
|
c2ded4a599 | ||
|
|
9dbdcbefc0 | ||
|
|
74223ce69f | ||
|
|
f3a12533d7 |
No files matched your search
@@ -84,7 +84,7 @@ jobs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: find
|
||||
env:
|
||||
@@ -92,7 +92,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag='${{ inputs.tag }}'
|
||||
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
|
||||
# --repo on every gh call, whether or not the job happens to have
|
||||
# checked out: a call that relies on an inferred remote breaks the day
|
||||
# somebody removes the checkout, and the error it gives - "fatal: not
|
||||
# a git repository" - names neither gh nor the workflow.
|
||||
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
|
||||
--limit 1 --json tagName --jq '.[0].tagName')"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::No release to wrap, and no tag was given."
|
||||
exit 1
|
||||
@@ -107,6 +112,9 @@ jobs:
|
||||
name: AppImage ${{ matrix.appimage_arch }}
|
||||
needs: release
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
# A 32-bit job that cannot run on the runner it was given must not fail the
|
||||
# release for the architectures that built fine.
|
||||
continue-on-error: ${{ matrix.tolerate_failure || false }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -118,6 +126,28 @@ jobs:
|
||||
- appimage_arch: aarch64
|
||||
bundle_arch: arm64
|
||||
runs_on: ubuntu-24.04-arm
|
||||
# The other two architectures an AppImage runtime EXISTS for. There
|
||||
# are four in total - x86_64, aarch64, i686, armhf - and that is the
|
||||
# whole limit: the runtime is the ELF the kernel executes, so an
|
||||
# architecture with no runtime cannot have an AppImage at all. That is
|
||||
# why ppc64le, s390x, riscv64 and loong64 are not here and never will
|
||||
# be; they are served by the bundle zip and the .deb.
|
||||
#
|
||||
# i686 builds on the x86_64 runner, which runs 32-bit x86 natively -
|
||||
# no emulation, no multiarch, and appimagetool-i686 runs as-is.
|
||||
- appimage_arch: i686
|
||||
bundle_arch: i386
|
||||
runs_on: ubuntu-24.04
|
||||
# armhf builds on the aarch64 runner, which runs 32-bit ARM only if
|
||||
# the kernel was built with CONFIG_COMPAT and the 32-bit loader is
|
||||
# installed. GitHub does not promise either, so this one is allowed to
|
||||
# fail without taking the release with it - the same treatment TSC
|
||||
# gives its emulated armhf job. If it turns out to work reliably,
|
||||
# drop the flag; if it never does, drop the entry.
|
||||
- appimage_arch: armhf
|
||||
bundle_arch: armhf
|
||||
runs_on: ubuntu-24.04-arm
|
||||
tolerate_failure: true
|
||||
# WHICH entries this run wants, decided here because it cannot be decided
|
||||
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
|
||||
# that context to runs-on, env, name, steps and a few others, but not to a
|
||||
@@ -130,7 +160,7 @@ jobs:
|
||||
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.appimage_arch) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
# An AppImage only WRAPS an existing release bundle
|
||||
@@ -163,7 +193,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
|
||||
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
|
||||
gh release download '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --pattern "$zip" --clobber
|
||||
unzip -q "$zip"
|
||||
# A zip that exists is not a bundle that runs; these are the two files
|
||||
# the AppImage is built around.
|
||||
@@ -281,6 +312,22 @@ jobs:
|
||||
# for WeKan to answer on its port, then stop it - so a broken bundle
|
||||
# is caught here rather than by whoever downloads it.
|
||||
export WRITABLE_PATH="$PWD/smoke-data" PORT=8080
|
||||
|
||||
# A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK. The 32-bit builds are
|
||||
# packed on a 64-bit runner, which executes them only if the kernel and
|
||||
# loader for that word size are present - GitHub promises neither. If
|
||||
# the binary cannot be executed AT ALL, the AppImage is as good as
|
||||
# anything here knows and the release must not be withheld for it; say
|
||||
# so and move on. Anything else - it starts and does not answer - is
|
||||
# the bug this step exists for and still fails.
|
||||
if ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --appimage-help >/dev/null 2>&1 \
|
||||
&& ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --help >/dev/null 2>&1; then
|
||||
if ! file "$app" | grep -q "$(uname -m)"; then
|
||||
echo "::warning::this runner cannot execute a ${{ matrix.appimage_arch }} binary, so the AppImage was packed but not started. It is uploaded unchecked."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$app" > smoke.log 2>&1 &
|
||||
pid=$!
|
||||
ok=false
|
||||
@@ -298,7 +345,7 @@ jobs:
|
||||
fi
|
||||
echo "WeKan answered on port 8080."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: appimage-${{ matrix.appimage_arch }}
|
||||
@@ -311,7 +358,7 @@ jobs:
|
||||
if: ${{ always() && inputs.publish != false }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: appimage-*
|
||||
path: assets
|
||||
@@ -324,7 +371,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
ls -lh assets 2>/dev/null || true
|
||||
if ls assets/*.AppImage >/dev/null 2>&1; then
|
||||
for want in x86_64 aarch64; do
|
||||
# Every architecture the matrix builds. A missing one WARNS rather
|
||||
# than fails: the 32-bit pair is allowed not to build, and an
|
||||
# architecture whose base bundle was never published has nothing to
|
||||
# wrap. What must not happen silently is one going missing unnoticed.
|
||||
for want in x86_64 aarch64 i686 armhf; do
|
||||
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
|
||||
echo "::warning::no AppImage for $want - see that build job's log"
|
||||
done
|
||||
@@ -334,7 +385,7 @@ jobs:
|
||||
# build jobs skip (not fail) when the base bundle is not published yet.
|
||||
published="$(gh release view '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
base_present=false
|
||||
for arch in amd64 arm64; do
|
||||
for arch in amd64 arm64 i386 armhf; do
|
||||
printf '%s\n' "$published" | grep -qx "wekan-${{ needs.release.outputs.version }}-${arch}.zip" && base_present=true
|
||||
done
|
||||
if [ "$base_present" = false ]; then
|
||||
@@ -354,5 +405,13 @@ jobs:
|
||||
echo "Nothing to upload (no base bundle was published to wrap)."
|
||||
exit 0
|
||||
fi
|
||||
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
|
||||
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
|
||||
# --repo, because this job does NOT check the repository out - it only
|
||||
# downloads artifacts - so `gh` has no git remote to infer the repo
|
||||
# from and fails with "fatal: not a git repository". Every other gh
|
||||
# call in this workflow already passes it; these two did not, so the
|
||||
# AppImages were built, smoke-tested and uploaded as artifacts, and
|
||||
# then nothing was attached to the release.
|
||||
gh release upload '${{ needs.release.outputs.tag }}' assets/* \
|
||||
--repo "${{ github.repository }}" --clobber
|
||||
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --json url --jq .url)"
|
||||
@@ -24,6 +24,15 @@ name: Flatpak
|
||||
# and WeKan publishes Linux bundles for amd64 and arm64, so those are the two.
|
||||
# Each builds on a runner of its own CPU rather than under emulation.
|
||||
#
|
||||
# AND THAT IS THE WHOLE LIST - there is no third to add. A flatpak runs against
|
||||
# a RUNTIME, not against the host's libraries, so an architecture exists here
|
||||
# only if freedesktop.org publishes org.freedesktop.Platform for it. It does so
|
||||
# for x86_64 and aarch64; the i386 and arm runtimes were discontinued. That is
|
||||
# the difference from AppImage.yml, which was able to grow from two
|
||||
# architectures to four: an AppImage carries its own runtime binary, and
|
||||
# runtimes exist for i686 and armhf as well. WeKan's ppc64le, s390x, riscv64,
|
||||
# loong64 and 32-bit bundles are served by the zip and the .deb.
|
||||
#
|
||||
# THE SANDBOX. WeKan is a server you reach in a browser, so it needs the network
|
||||
# and a place to keep its data, and nothing else - no X11, no audio, no access
|
||||
# to your files. --share=network and --persist=.wekan are the whole permission
|
||||
@@ -89,14 +98,18 @@ jobs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag='${{ inputs.tag }}'
|
||||
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
|
||||
# --repo on every gh call, whether or not the job happens to have
|
||||
# checked out: one that relies on an inferred remote breaks the day
|
||||
# somebody removes the checkout.
|
||||
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
|
||||
--limit 1 --json tagName --jq '.[0].tagName')"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::No release to wrap, and no tag was given."
|
||||
exit 1
|
||||
@@ -133,7 +146,7 @@ jobs:
|
||||
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
- name: Install flatpak
|
||||
@@ -154,7 +167,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
|
||||
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
|
||||
gh release download '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --pattern "$zip" --clobber
|
||||
unzip -q "$zip"
|
||||
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
|
||||
if [ ! -e "$f" ]; then
|
||||
@@ -273,14 +287,14 @@ jobs:
|
||||
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
|
||||
ls -lh dist
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: flatpak-${{ matrix.flatpak_arch }}
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: flatpak-repo-${{ matrix.flatpak_arch }}
|
||||
@@ -293,7 +307,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: flatpak-[0-9a-z]*
|
||||
path: assets
|
||||
@@ -318,11 +332,24 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
|
||||
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
|
||||
# --repo, because this job does NOT check the repository out - it
|
||||
# only downloads artifacts - so `gh` has no git remote to infer the
|
||||
# repo from and fails with "fatal: not a git repository". The same
|
||||
# line was wrong in AppImage.yml for the same reason.
|
||||
#
|
||||
# And the bundles BY NAME, not assets/*: the artifacts also carry the
|
||||
# ostree repository the flatpak was exported through - config,
|
||||
# objects/, refs/, summaries/, summary, summary.idx - which is build
|
||||
# scaffolding, not something to attach to a release. TSC hit this
|
||||
# exact thing ("upload only the bundle files, not the ostree repo").
|
||||
gh release upload '${{ needs.release.outputs.tag }}' \
|
||||
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
|
||||
--repo "${{ github.repository }}" --clobber
|
||||
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
|
||||
--repo "${{ github.repository }}" --json url --jq .url)"
|
||||
|
||||
# ---- the repository -------------------------------------------------
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
with:
|
||||
pattern: flatpak-repo-*
|
||||
@@ -395,7 +422,7 @@ jobs:
|
||||
TXT
|
||||
du -sh flatpak-repo
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
with:
|
||||
name: flatpak-repository
|
||||
|
||||
@@ -87,3 +87,7 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Full build provenance (SLSA), not the minimal default that BuildKit
|
||||
# attaches on its own. See release-all.yml for what it is and why the
|
||||
# manifest list gains an `unknown/unknown` entry per platform.
|
||||
provenance: mode=max
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
manual: ${{ steps.plan.outputs.manual }}
|
||||
total: ${{ steps.plan.outputs.total }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: plan
|
||||
env:
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
# Before the download, because actions/checkout cleans the workspace.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
|
||||
# THE DIFFERENCE from release-all.yml, and the whole point of this
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
V: ${{ needs.plan.outputs.version }}
|
||||
|
||||
@@ -622,13 +622,23 @@ jobs:
|
||||
sha256sum "wekan-${VERSION}-amd64.zip" > "wekan-${VERSION}-amd64.zip.sha256sum"
|
||||
cat "wekan-${VERSION}-amd64.zip.sha256sum"
|
||||
# Where this bundle's binaries came from, for the release notes.
|
||||
mkdir -p provenance
|
||||
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
|
||||
#
|
||||
# $GITHUB_WORKSPACE, not a relative path: this step ran `cd .build`
|
||||
# above, so `mkdir -p provenance` made .build/provenance while the
|
||||
# upload-artifact below looks at provenance/ from the workspace root.
|
||||
# It found nothing - "No files were found with the provided path:
|
||||
# provenance/" - and amd64, the platform most people download, was the
|
||||
# one bundle missing from the v10.77 release-notes table. Every other
|
||||
# build job records from the workspace root, which is why only this one
|
||||
# was affected.
|
||||
mkdir -p "$GITHUB_WORKSPACE/provenance"
|
||||
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
|
||||
'amd64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
|
||||
"${NODE_URL:-}" \
|
||||
"${NODE_SHA256:-}" || true
|
||||
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
|
||||
'amd64' 'FerretDB' 'wekan/FerretDB' 'latest' \
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
|
||||
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
|
||||
'amd64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64' \
|
||||
"${FERRET_SHA:-}" || true
|
||||
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
|
||||
@@ -659,7 +669,7 @@ jobs:
|
||||
done
|
||||
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: provenance-amd64
|
||||
path: provenance/
|
||||
@@ -832,8 +842,9 @@ jobs:
|
||||
'arm64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
|
||||
"${NODE_URL:-}" \
|
||||
"${NODE_SHA256:-}" || true
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
|
||||
PROVENANCE_FILE=provenance/arm64.tsv bash releases/record-provenance.sh \
|
||||
'arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
|
||||
'arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64' \
|
||||
"${FERRET_SHA:-}" || true
|
||||
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
|
||||
@@ -864,7 +875,7 @@ jobs:
|
||||
done
|
||||
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
with:
|
||||
name: provenance-arm64
|
||||
@@ -921,7 +932,7 @@ jobs:
|
||||
# 404'd on wekan-<version>-amd64.zip - v10.63 and v10.64 both. Checking out
|
||||
# first, then downloading on top, keeps the bundles. (The checkout is here
|
||||
# for the provenance script under releases/.)
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download all bundles
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -931,7 +942,7 @@ jobs:
|
||||
|
||||
- name: Collect where the binaries came from
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: provenance-*
|
||||
path: provenance
|
||||
@@ -1219,8 +1230,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb.exe ]; then
|
||||
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
|
||||
PROVENANCE_FILE=provenance/win64.tsv bash src/releases/record-provenance.sh \
|
||||
'win64' 'FerretDB' 'wekan/FerretDB' 'latest' \
|
||||
'win64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe' \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
@@ -1561,8 +1573,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb.exe ]; then
|
||||
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
|
||||
PROVENANCE_FILE=provenance/win32.tsv bash src/releases/record-provenance.sh \
|
||||
'win32' 'FerretDB' 'wekan/FerretDB' 'latest' \
|
||||
'win32' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe' \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
@@ -1805,8 +1818,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb ]; then
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
|
||||
PROVENANCE_FILE=provenance/mac-arm64.tsv bash releases/record-provenance.sh \
|
||||
'mac-arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
|
||||
'mac-arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64' \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
@@ -2057,8 +2071,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb ]; then
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
|
||||
PROVENANCE_FILE=provenance/mac-x64.tsv bash releases/record-provenance.sh \
|
||||
'mac-x64' 'FerretDB' 'wekan/FerretDB' 'latest' \
|
||||
'mac-x64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64' \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
@@ -2290,7 +2305,7 @@ jobs:
|
||||
# checkout - the job would stop at "No such file or directory". It goes
|
||||
# first because actions/checkout cleans the workspace, which would remove
|
||||
# the bundle the next step downloads.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -2421,7 +2436,7 @@ jobs:
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${{ matrix.ferretdb_arch }} on this release, so it is unverified."
|
||||
fi
|
||||
FERRET_TAG="$(curl -fsSL https://api.github.com/repos/wekan/FerretDB/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tag_name",""))' || true)"
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
|
||||
bash releases/record-provenance.sh '${{ matrix.arch }}' 'FerretDB' \
|
||||
'wekan/FerretDB' "${FERRET_TAG:-latest}" "$FERRET_URL" "$FERRET_SHA"
|
||||
# Overwrite the inherited amd64 MongoDB Database Tools with this arch's ones.
|
||||
@@ -2490,7 +2505,7 @@ jobs:
|
||||
mkdir -p provenance
|
||||
[ -f provenance.tsv ] && mv provenance.tsv "provenance/${{ matrix.arch }}.tsv" || true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
with:
|
||||
name: provenance-${{ matrix.arch }}
|
||||
@@ -2966,7 +2981,21 @@ jobs:
|
||||
# manifest yet, so it ships as a .zip bundle only. Every candidate arch
|
||||
# is in debian:trixie's manifest AND has a fork Node the Dockerfile can
|
||||
# install.
|
||||
# --provenance=mode=max: full build provenance (SLSA), not the
|
||||
# minimal default - what built this image, from which commit, with
|
||||
# which base and build arguments. It rides in the manifest list as an
|
||||
# `unknown/unknown` entry per platform, the OCI marker for "not a
|
||||
# runnable image" so `docker pull` never selects it, which is why Quay
|
||||
# lists one beside each real platform. mode=max makes those entries
|
||||
# BIGGER, not fewer; `--provenance=false` is what removes them, at the
|
||||
# cost of the evidence.
|
||||
#
|
||||
# The comment is here and not among the arguments: a `#` after a line
|
||||
# continuation comments out the rest of the joined line, leaving
|
||||
# `docker buildx build` with no platforms and no tags. This block is a
|
||||
# shell script like any other, and it was written the wrong way first.
|
||||
docker buildx build \
|
||||
--provenance=mode=max \
|
||||
--platform "${PLATFORMS}" \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
-t wekanteam/wekan:v${VERSION} \
|
||||
@@ -3521,6 +3550,19 @@ jobs:
|
||||
fi
|
||||
done
|
||||
echo "::error::Remote build ${{ matrix.arch }} failed after $attempts attempts (no wekan_${VERSION}_${{ matrix.arch }}.snap produced). The Launchpad build log printed above says why; the builds are also at https://launchpad.net/~xet7/+snaps" >&2
|
||||
# A DETERMINISTIC failure, named before the guesses below. Three
|
||||
# identical attempts on ONE architecture while the others build on
|
||||
# attempt 1 is not the build farm having a bad day - and the guidance
|
||||
# below ("often an OOM, or a transient reset; re-run") sent v10.77's
|
||||
# armhf failure down exactly that path. snapcraft ends a missing
|
||||
# stage-package as a "Stopped" build with no artifact, which looks
|
||||
# identical to a cancelled one from the outside; the difference is one
|
||||
# line in the log, so say it here rather than leave it to be found.
|
||||
if grep -qs 'Stage package not found' snapcraft-wekan-*.txt; then
|
||||
missingpkg="$(grep -hs -o "Stage package not found in part '[^']*': [^ ]*" snapcraft-wekan-*.txt | sort -u | tr '\n' ' ')"
|
||||
echo "::error::This is NOT a transient failure and re-running will not fix it: ${missingpkg}. A stage-package that resolves on the 64-bit architectures can be missing on ${{ matrix.arch }} - Ubuntu 24.04's 64-bit time_t transition renamed libraries to a t64 suffix, and the renamed package carries 'Provides: <old name>' on amd64/arm64 but NOT on armhf, where the ABI really changed. Name the real package in snapcraft.yaml (libssl3 -> libssl3t64, libcurl4 -> libcurl4t64, libgoogle-perftools4 -> libgoogle-perftools4t64). Check a name against the archive: curl -fsSL http://ports.ubuntu.com/ubuntu-ports/dists/noble/main/binary-armhf/Packages.gz | gunzip -c | grep '^Package: <name>$'" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A "Stopped" build with no artifact is Launchpad ENDING the build
|
||||
# itself, not a compile error in it. Two causes, in the order to check:
|
||||
# 1. The auto-generated Launchpad PROJECT (xet7-craft-remote-build)
|
||||
@@ -3563,11 +3605,29 @@ jobs:
|
||||
run: |
|
||||
set -uo pipefail
|
||||
mkdir -p snap-logs
|
||||
cp -f snapcraft-wekan-*.txt snap-logs/ 2>/dev/null || true
|
||||
cp -f "$HOME"/.local/state/snapcraft/log/*.log snap-logs/ 2>/dev/null || true
|
||||
# RENAMED, not just copied. snapcraft names a remote-build log after the
|
||||
# recipe and an ISO timestamp:
|
||||
# snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt
|
||||
# and upload-artifact REFUSES a colon - "Invalid characters include:
|
||||
# Double quote, Colon, Less than, ..." - because NTFS cannot hold one.
|
||||
# That failed the whole job for s390x, ppc64el and riscv64 in v10.77
|
||||
# AFTER all three snaps had built and downloaded on attempt 1, so three
|
||||
# good builds were reported as FAILED by the step that saves their logs.
|
||||
# The timestamp is worth keeping, so the colons become dashes rather
|
||||
# than the name being thrown away.
|
||||
for f in snapcraft-wekan-*.txt "$HOME"/.local/state/snapcraft/log/*.log; do
|
||||
[ -f "$f" ] || continue
|
||||
base="$(basename "$f")"
|
||||
cp -f "$f" "snap-logs/${base//:/-}" || true
|
||||
done
|
||||
ls -la snap-logs || true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
# A DIAGNOSTIC upload can never fail the build it is diagnosing. The snap
|
||||
# is the deliverable; these logs are here so a failure can be read
|
||||
# afterwards, and the renaming above is not the last thing that could
|
||||
# ever make one of them unuploadable.
|
||||
continue-on-error: true
|
||||
if: always()
|
||||
with:
|
||||
name: snap-launchpad-logs-${{ matrix.arch }}
|
||||
@@ -4002,6 +4062,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
cd exapp
|
||||
docker buildx build --push \
|
||||
--provenance=mode=max \
|
||||
-t ghcr.io/wekan/wekan-exapp:v${VERSION} \
|
||||
-t ghcr.io/wekan/wekan-exapp:latest \
|
||||
--build-arg WEKAN_VERSION=${VERSION} .
|
||||
@@ -4089,9 +4150,9 @@ jobs:
|
||||
if: ${{ always() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: provenance-*
|
||||
|
||||
+1154
-1
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -12,7 +12,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=10.72
|
||||
ARG VERSION=10.78
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v10.72.0"
|
||||
appVersion: "v10.78.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -100,12 +100,14 @@ echo.
|
||||
echo -- Setup -- ^(0 = Back^)
|
||||
echo 1^) Install dependencies
|
||||
echo 2^) Build WeKan
|
||||
echo 3^) Update git ^(fetch + rebase onto origin, fix CHANGELOG hashes, status^)
|
||||
echo 3^) git pull ^(fetch, fast-forward or rebase, repoint moved CHANGELOG links^)
|
||||
echo 4^) git push ^(check the CHANGELOG links, push, pull-and-retry once if rejected^)
|
||||
set "choice="
|
||||
set /p "choice=Choose: "
|
||||
if "%choice%"=="1" goto install
|
||||
if "%choice%"=="2" goto build
|
||||
if "%choice%"=="3" goto updategit
|
||||
if "%choice%"=="3" goto gitpull
|
||||
if "%choice%"=="4" goto gitpush
|
||||
if "%choice%"=="0" goto menu
|
||||
goto menu_setup
|
||||
|
||||
@@ -301,6 +303,7 @@ if "%choice%"=="3" call :rel_run "releases/snap-push-to-store.sh" "Path to the .
|
||||
if "%choice%"=="4" call :rel_run "releases/release-snap.sh" "WeKan version, e.g. 10.50"
|
||||
if "%choice%"=="5" call :rel_run "releases/snap-store-revisions.sh" ""
|
||||
if "%choice%"=="6" call :rel_run "releases/snap-store-release-revision-to-channels.sh" "Snap Store revision number"
|
||||
if "%choice%"=="7" call :rel_run "releases/snap-release-all-channels.sh" "Version, or empty for the newest"
|
||||
if "%choice%"=="7" call :rel_run "releases/snap-edge.sh" ""
|
||||
if "%choice%"=="8" call :rel_run "releases/snap-stable.sh" ""
|
||||
if "%choice%"=="9" call :rel_run "releases/snapcraft-help.sh" ""
|
||||
@@ -558,6 +561,7 @@ echo snap-push-to-store Push one .snap to the Snap Store ^<P
|
||||
echo release-snap Release the snap for one version ^<WeKan version, e.g. 10.50^>
|
||||
echo snap-store-revisions List the newest Snap Store revisions
|
||||
echo snap-store-release-revision-to-channels Release one store revision to edge, beta and candidate ^<Snap Store revision number^>
|
||||
echo snap-release-all-channels Release every snap, every architecture, to all four channels ^<Version, or empty for the newest^>
|
||||
echo snap-edge Switch the installed snap to the edge channel
|
||||
echo snap-stable Switch the installed snap to the stable channel
|
||||
echo snapcraft-help snapcraft help topics
|
||||
@@ -640,6 +644,7 @@ if /I "%K%"=="snap-push-to-store" (set "CMD=bash releases/snap-push-to-store.sh"
|
||||
if /I "%K%"=="release-snap" (set "CMD=bash releases/release-snap.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="snap-store-revisions" (set "CMD=bash releases/snap-store-revisions.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="snap-store-release-revision-to-channels" (set "CMD=bash releases/snap-store-release-revision-to-channels.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="snap-release-all-channels" (set "CMD=bash releases/snap-release-all-channels.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="snap-edge" (set "CMD=bash releases/snap-edge.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="snap-stable" (set "CMD=bash releases/snap-stable.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="snapcraft-help" (set "CMD=bash releases/snapcraft-help.sh" ^& goto cli_go)
|
||||
@@ -674,6 +679,11 @@ if /I "%K%"=="push-copy-en-gb-translation" (set "CMD=bash releases/translations/
|
||||
if /I "%K%"=="report-english-regressions" (set "CMD=node releases/translations/report-english-regressions.mjs" ^& goto cli_go)
|
||||
if /I "%K%"=="verify-human-preference" (set "CMD=node releases/translations/verify-human-preference.mjs" ^& goto cli_go)
|
||||
if /I "%K%"=="merge-translations" (set "CMD=node releases/translations/merge-translations.mjs" ^& goto cli_go)
|
||||
REM `git pull` and `git push` are shell functions in build.sh, not scripts in
|
||||
REM releases/, so this answers to the same two names with its own labels - the
|
||||
REM ones the interactive menu already uses.
|
||||
if /I "%K%"=="git-pull" (goto gitpull)
|
||||
if /I "%K%"=="git-push" (goto gitpush)
|
||||
if /I "%K%"=="commit" (set "CMD=bash releases/commit.sh" ^& goto cli_go)
|
||||
if /I "%K%"=="git-add-revert" (set "SHCMD=git restore --staged" ^& goto cli_go)
|
||||
if /I "%K%"=="delete-branch-local-and-remote" (set "CMD=bash releases/delete-branch-local-and-remote.sh" ^& goto cli_go)
|
||||
@@ -801,51 +811,32 @@ echo Done.
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
REM Make the working copy current in one step: fetch + rebase the current branch
|
||||
REM onto its upstream, repoint any CHANGELOG commit links the rebase made stale
|
||||
REM (the same shared releases\fix-changelog-hashes.sh release-all.sh uses), and
|
||||
REM show the status. Parity with build.sh's Setup -> "Update git ...".
|
||||
:updategit
|
||||
echo Updating git: fetch + rebase onto origin, fix CHANGELOG hashes, show status.
|
||||
git rev-parse --git-dir >nul 2>&1
|
||||
if errorlevel 1 ( echo Not a git repository. & goto end )
|
||||
set "BRANCH="
|
||||
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "BRANCH=%%b"
|
||||
echo Branch: %BRANCH%
|
||||
echo (A dirty tree is auto-stashed for the rebase and re-applied after.)
|
||||
echo --- git fetch --all --prune ---
|
||||
git fetch --all --prune
|
||||
git rev-parse --verify --quiet "origin/%BRANCH%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo No upstream origin/%BRANCH% - skipping rebase.
|
||||
) else (
|
||||
echo --- git pull --rebase --autostash origin %BRANCH% ---
|
||||
git pull --rebase --autostash origin "%BRANCH%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Rebase stopped ^(conflicts^). Resolve them, then:
|
||||
echo git rebase --continue ^(or: git rebase --abort^)
|
||||
echo and run this option again to finish the hash fix + status.
|
||||
goto end
|
||||
)
|
||||
)
|
||||
echo --- Fixing CHANGELOG commit links ^(releases\fix-changelog-hashes.sh^) ---
|
||||
echo ^(whole file, including released sections: bash releases/fix-changelog-hashes.sh --all-sections^)
|
||||
:gitpull
|
||||
REM Both git actions are build.sh's git_pull / git_push, run through bash rather
|
||||
REM than written a second time here. What they do - fast-forward or rebase,
|
||||
REM repoint the CHANGELOG links a rebase moved, abort a conflicting rebase so the
|
||||
REM repo is left exactly as it was, pull-and-retry once when a push is rejected -
|
||||
REM is shell logic, and the batch copy of the old "Update git" that used to live
|
||||
REM here is why this file drifted: it reimplemented the same steps, so a fix to
|
||||
REM one never reached the other.
|
||||
where bash >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo bash not found - the CHANGELOG hash fix needs Git Bash, bundled with Git for Windows.
|
||||
echo Skipping it. Run it yourself in Git Bash: bash releases/fix-changelog-hashes.sh
|
||||
) else (
|
||||
bash releases/fix-changelog-hashes.sh
|
||||
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
|
||||
goto end
|
||||
)
|
||||
echo --- git status ---
|
||||
git status
|
||||
echo.
|
||||
echo NOTE: if CHANGELOG.md was changed above, review 'git diff CHANGELOG.md' and
|
||||
echo commit it - this script never commits for you.
|
||||
bash ./build.sh git-pull
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
:gitpush
|
||||
where bash >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
|
||||
goto end
|
||||
)
|
||||
bash ./build.sh git-push
|
||||
goto end
|
||||
|
||||
:dev_local
|
||||
call :ensure_dirs
|
||||
call :set_dev_env
|
||||
|
||||
@@ -1637,63 +1637,161 @@ function ask_dev_url(){
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Update git: make the working copy current in one step ────────────────────
|
||||
# ── Keeping the checkout in step with origin ─────────────────────────────────
|
||||
# Fetch + rebase the current branch onto its upstream, then repoint any CHANGELOG
|
||||
# commit links the rebase made stale (same shared script release-all.sh uses), and
|
||||
# show the resulting status. One menu action instead of the fetch/pull/rebase/
|
||||
# hash-fix/status dance by hand.
|
||||
function update_git(){
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 0; }
|
||||
local branch upstream here
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
||||
echo "== Update git — branch: ${branch:-?} =="
|
||||
# ── git pull / git push, doing the whole job ─────────────────────────────────
|
||||
#
|
||||
# These replace the old "Update git", which did a fetch+rebase, ran the CHANGELOG
|
||||
# hash repair, printed `git status` and left the rest to the reader. The rest is
|
||||
# where the mistakes were: a rebase rewrites local commits, so the CHANGELOG links
|
||||
# written before it point at commits that no longer exist; the repair fixes the
|
||||
# file but commits nothing, so the next push carries a dirty tree; and a push
|
||||
# rejected as non-fast-forward leaves a repo that needs a pull the caller has to
|
||||
# know to run.
|
||||
#
|
||||
# So: one action per direction, each ending in a state that is either correct or
|
||||
# unchanged. Conflicts are the one thing a script must not paper over - a rebase
|
||||
# that stops half-applied IS the wrong state - so on conflict the rebase is
|
||||
# aborted, the working tree is exactly what it was, and the message says what to
|
||||
# do by hand.
|
||||
#
|
||||
# Nothing here force-pushes, ever.
|
||||
|
||||
# Do not surprise anyone: a dirty tree is stashed by --autostash below, but say
|
||||
# so first and let the user opt out.
|
||||
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
echo "Working tree has uncommitted changes:"
|
||||
git status --short
|
||||
printf "They will be auto-stashed for the rebase and re-applied after. Continue? [y/N] "
|
||||
read -r ans
|
||||
case "$ans" in y|Y|yes|YES) : ;; *) echo "Aborted (nothing changed)."; return 0 ;; esac
|
||||
fi
|
||||
|
||||
echo "--- git fetch --all --prune ---"
|
||||
git fetch --all --prune || { echo "fetch failed - check network/remote and retry."; return 0; }
|
||||
|
||||
upstream="origin/$branch"
|
||||
if git rev-parse --verify --quiet "$upstream" >/dev/null 2>&1; then
|
||||
echo "--- git pull --rebase --autostash origin $branch ---"
|
||||
if ! git pull --rebase --autostash origin "$branch"; then
|
||||
echo
|
||||
echo "Rebase stopped (conflicts or autostash conflict). Resolve them, then:"
|
||||
echo " git rebase --continue # or: git rebase --abort"
|
||||
echo "and run this option again to finish the hash fix + status."
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "No upstream '$upstream' - skipping rebase (nothing to pull for this branch)."
|
||||
fi
|
||||
|
||||
# The rebase may have rewritten commits the CHANGELOG links to; repoint the
|
||||
# stale links in the unreleased section (shared with releases/release-all.sh).
|
||||
# A link is only repointed when NO ref in this clone reaches its commit, so a
|
||||
# link into an old release tag - which GitHub serves fine - is left alone.
|
||||
echo "--- Fixing CHANGELOG commit links (releases/fix-changelog-hashes.sh) ---"
|
||||
bash "$here/releases/fix-changelog-hashes.sh" || true
|
||||
echo " (whole file, including released sections:"
|
||||
echo " bash releases/fix-changelog-hashes.sh --all-sections [--dry-run])"
|
||||
|
||||
echo "--- git status ---"
|
||||
git status
|
||||
# The CHANGELOG links after history moved. The repair only repoints a link whose
|
||||
# commit is reachable from NO ref in this clone (a link into an old release tag
|
||||
# is fine and is left alone), so running it when nothing moved is a no-op.
|
||||
# Returns 0 when the file was changed and committed, 1 when there was nothing to do.
|
||||
function git_fix_changelog_links(){
|
||||
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
|
||||
[ -f "$script" ] || return 1
|
||||
bash "$script" || true
|
||||
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
|
||||
echo
|
||||
echo "NOTE: CHANGELOG.md was updated by the hash fix above."
|
||||
echo " Review 'git diff CHANGELOG.md' and commit it (this script never commits for you)."
|
||||
git add CHANGELOG.md
|
||||
git commit -q -m "CHANGELOG: repoint commit links after history moved.
|
||||
|
||||
A rebase rewrote the commits these entries link to, so the links named commits
|
||||
that no longer exist and would have 404ed on GitHub. Repointed by
|
||||
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
|
||||
can reach.
|
||||
|
||||
Thanks to xet7 !"
|
||||
echo "==> CHANGELOG commit links repointed and committed."
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# git pull: fast-forward when that is all it takes, rebase when the branch has
|
||||
# diverged, and never end half-way through either.
|
||||
function git_pull(){
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
|
||||
local branch upstream before ahead behind
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
upstream="origin/$branch"
|
||||
echo "== git pull - branch $branch =="
|
||||
|
||||
# The exact commit to return to if anything goes wrong.
|
||||
before="$(git rev-parse HEAD)"
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "==> Working tree is not clean; the changes are stashed for the pull and"
|
||||
echo " re-applied afterwards (git's own --autostash)."
|
||||
fi
|
||||
|
||||
echo "--- git fetch origin $branch ---"
|
||||
git fetch origin "$branch" || { echo "ERROR: fetch failed - network or remote. Nothing changed."; return 1; }
|
||||
git rev-parse --verify --quiet "$upstream" >/dev/null || {
|
||||
echo "No $upstream yet - nothing to pull. Push first to create it."; return 0; }
|
||||
|
||||
behind="$(git rev-list --count HEAD.."$upstream")"
|
||||
ahead="$(git rev-list --count "$upstream"..HEAD)"
|
||||
echo " $ahead commit(s) here that origin does not have, $behind the other way."
|
||||
|
||||
if [ "$behind" -eq 0 ]; then
|
||||
echo "==> Already up to date with $upstream."
|
||||
elif [ "$ahead" -eq 0 ]; then
|
||||
# Nothing of ours to replay: a fast-forward moves the branch pointer and
|
||||
# rewrites no commit, so no link can go stale.
|
||||
echo "--- fast-forward (no local commits to replay) ---"
|
||||
git merge --ff-only "$upstream" || { echo "ERROR: fast-forward failed. Nothing changed."; return 1; }
|
||||
else
|
||||
echo "--- rebase: replaying $ahead local commit(s) onto $upstream ---"
|
||||
echo " This gives them NEW hashes, which is why the CHANGELOG links are"
|
||||
echo " repaired straight after."
|
||||
if ! git -c rebase.autoStash=true rebase "$upstream"; then
|
||||
git rebase --abort 2>/dev/null
|
||||
git stash list >/dev/null 2>&1
|
||||
echo
|
||||
echo "ERROR: the rebase hit a conflict, so it was ABORTED - this repo is"
|
||||
echo " exactly as it was before ($(git rev-parse --short "$before"))."
|
||||
echo " Resolve it by hand:"
|
||||
echo " git rebase $upstream # then fix the conflicts"
|
||||
echo " git rebase --continue # or: git rebase --abort"
|
||||
echo " and run this option again afterwards."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve; nothing to repoint."
|
||||
echo "--- git status ---"
|
||||
git status --short --branch
|
||||
return 0
|
||||
}
|
||||
|
||||
# git push: make sure what is about to be published is correct first, and turn
|
||||
# the one rejection that has an obvious answer into that answer.
|
||||
function git_push(){
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
|
||||
local branch ahead
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "== git push - branch $branch =="
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "==> NOTE: there are uncommitted changes; a push publishes commits only."
|
||||
git status --short
|
||||
fi
|
||||
|
||||
# Before publishing, not after: a stale link that reaches GitHub 404s for
|
||||
# everyone who reads the release notes.
|
||||
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve."
|
||||
|
||||
git fetch origin "$branch" >/dev/null 2>&1 || true
|
||||
if git rev-parse --verify --quiet "origin/$branch" >/dev/null; then
|
||||
ahead="$(git rev-list --count "origin/$branch"..HEAD)"
|
||||
[ "$ahead" -eq 0 ] && { echo "==> Nothing to push; origin/$branch is already at this commit."; return 0; }
|
||||
echo " $ahead commit(s) to push."
|
||||
fi
|
||||
|
||||
echo "--- git push origin $branch ---"
|
||||
if git push origin "$branch"; then
|
||||
echo "==> Pushed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# The common rejection: origin moved while this was being written. That has
|
||||
# one right answer - pull, then push again - so do it once, rather than
|
||||
# printing the advice and leaving the caller to it. Once only: a second
|
||||
# rejection is something else, and retrying a loop is not a fix.
|
||||
echo
|
||||
echo "==> Push was rejected. Pulling first, then trying once more."
|
||||
git_pull || { echo "ERROR: the pull did not finish, so nothing was pushed."; return 1; }
|
||||
echo "--- git push origin $branch (retry) ---"
|
||||
if git push origin "$branch"; then
|
||||
echo "==> Pushed."
|
||||
return 0
|
||||
fi
|
||||
echo
|
||||
echo "ERROR: still rejected. This is NOT a case to force - force-pushing a"
|
||||
echo " branch somebody has pulled rewrites their history too. Read what"
|
||||
echo " git said above; if origin has commits this clone should keep,"
|
||||
echo " run this option's pull first and look at 'git log --oneline'."
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
echo
|
||||
PS3='Please enter your choice: '
|
||||
|
||||
@@ -1775,6 +1873,7 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
|
||||
"Snap|Release the snap for one version|releases/release-snap.sh|WeKan version, e.g. 10.50||"
|
||||
"Snap|List the newest Snap Store revisions|releases/snap-store-revisions.sh|||"
|
||||
"Snap|Release one store revision to edge, beta and candidate|releases/snap-store-release-revision-to-channels.sh|Snap Store revision number||"
|
||||
"Snap|Release every snap, every architecture, to all four channels|releases/snap-release-all-channels.sh|Version, or empty for the newest||snap-release-all-channels"
|
||||
"Snap|Switch the installed snap to the edge channel|releases/snap-edge.sh|||"
|
||||
"Snap|Switch the installed snap to the stable channel|releases/snap-stable.sh|||"
|
||||
"Snap|snapcraft help topics|releases/snapcraft-help.sh|||"
|
||||
@@ -1813,6 +1912,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
|
||||
"Translations|Report English strings that regressed|releases/translations/report-english-regressions.mjs|||"
|
||||
"Translations|Prove a pull keeps human translations (no network)|releases/translations/verify-human-preference.mjs|||"
|
||||
"Translations|Merge a finished pull by hand|releases/translations/merge-translations.mjs|||"
|
||||
"Git and repo|git pull - fetch, fast-forward or rebase, repoint moved CHANGELOG links|!git_pull|||git-pull"
|
||||
"Git and repo|git push - verify the CHANGELOG links, push, pull-and-retry once if origin moved|!git_push|||git-push"
|
||||
"Git and repo|Commit with the editor open for a multi-line message|releases/commit.sh|||"
|
||||
"Git and repo|Add everything, then revert it again|!git restore --staged|Path to unstage||git-add-revert"
|
||||
"Git and repo|Delete a branch, locally and on the remote|releases/delete-branch-local-and-remote.sh|Branch name||"
|
||||
@@ -2021,7 +2122,8 @@ while [ -z "$opt" ]; do
|
||||
choose "Setup" \
|
||||
"Install dependencies|Install WeKan dependencies" \
|
||||
"Build WeKan|Build WeKan" \
|
||||
"Update git|Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status" ;;
|
||||
"git pull|git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts" \
|
||||
"git push|git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile" ;;
|
||||
"Dev server")
|
||||
choose "Dev server" \
|
||||
"localhost:3000|Run Meteor for dev on http://localhost:3000" \
|
||||
@@ -2163,8 +2265,13 @@ for _once in 1; do
|
||||
break
|
||||
;;
|
||||
|
||||
"Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status")
|
||||
update_git
|
||||
"git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts")
|
||||
git_pull
|
||||
break
|
||||
;;
|
||||
|
||||
"git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile")
|
||||
git_push
|
||||
break
|
||||
;;
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ template(name="adminReports")
|
||||
+eventStreamReport(stream="cpu")
|
||||
else if showDatabase.get
|
||||
+eventStreamReport(stream="database")
|
||||
else if showIntegrity.get
|
||||
+eventStreamReport(stream="integrity")
|
||||
else if tablePageData
|
||||
+tablePage(tablePageData)
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ Template.adminReports.onCreated(function () {
|
||||
this.showTests = new ReactiveVar(false);
|
||||
this.showCpu = new ReactiveVar(false);
|
||||
this.showDatabase = new ReactiveVar(false);
|
||||
this.showIntegrity = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
|
||||
@@ -315,6 +316,10 @@ const PROBLEMS_MENU = [
|
||||
// What the database itself said, classified: which database type, what it
|
||||
// means and what to do (server/lib/databaseProblems.js).
|
||||
{ id: 'report-database', icon: 'fa-database', labelKey: 'databaseReportTitle' },
|
||||
// What the FILESYSTEM said: a stored file that is no longer the file WeKan
|
||||
// stored, and whether this server stopped cleanly
|
||||
// (docs/Security/Remediation/WeKan.md §13).
|
||||
{ id: 'report-integrity', icon: 'fa-fingerprint', labelKey: 'integrityReportTitle' },
|
||||
];
|
||||
|
||||
Template.adminReports.helpers({
|
||||
@@ -497,6 +502,7 @@ function openReportPane(tmpl, targetID) {
|
||||
tmpl.showTests.set(false);
|
||||
tmpl.showCpu.set(false);
|
||||
tmpl.showDatabase.set(false);
|
||||
tmpl.showIntegrity.set(false);
|
||||
if (tmpl.subscription) {
|
||||
tmpl.subscription.stop();
|
||||
}
|
||||
@@ -533,6 +539,9 @@ function openReportPane(tmpl, targetID) {
|
||||
} else if ('report-database' === targetID) {
|
||||
tmpl.showDatabase.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-integrity' === targetID) {
|
||||
tmpl.showIntegrity.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-broken' === targetID) {
|
||||
// A report like the others now: same controls, same paging, same loader.
|
||||
tmpl.brokenPage.set(1);
|
||||
@@ -835,8 +844,28 @@ const EVENT_STREAM_COLUMNS = [
|
||||
{ labelKey: 'event-severity', value: r => r.severity, data: r => r.severity },
|
||||
{ labelKey: 'event-action', value: r => r.action || r.type },
|
||||
{ labelKey: 'event-source', value: r => r.source },
|
||||
// The user who triggered the event (e.g. who uploaded a sanitized file).
|
||||
{ labelKey: 'username', value: r => userName(r.userId), userId: r => r.userId },
|
||||
// WHO tried it. The stored `username` wins over looking the account up: it is
|
||||
// what the account was CALLED when the event happened, so a later rename does
|
||||
// not rewrite history and a deleted account does not erase it. The lookup
|
||||
// stays as the fallback for the older events that predate the stored field.
|
||||
{
|
||||
labelKey: 'username',
|
||||
value: r => r.username || userName(r.userId),
|
||||
userId: r => r.userId,
|
||||
},
|
||||
// FROM WHERE. Resolved with the same spoofing-safe rule as the login throttle -
|
||||
// X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - so this
|
||||
// column cannot be written by sending a header.
|
||||
{ labelKey: 'event-ip', nowrap: true, value: r => r.ip || '' },
|
||||
// HOW MANY attempts this row stands for. A canary counts repeats inside its
|
||||
// window rather than writing one row each, so "1" is an ordinary event and a
|
||||
// larger number is a burst that was deliberately not written out in full
|
||||
// (docs/Security/Remediation/WeKan.md §12).
|
||||
{
|
||||
labelKey: 'event-attempts',
|
||||
nowrap: true,
|
||||
value: r => (r.count && r.count > 1 ? String(r.count) : ''),
|
||||
},
|
||||
// The `database` stream's detail is WeKan's reading of the error - what it means
|
||||
// and what to do - and it is worth little without the sentence the database
|
||||
// itself produced, which is the one thing an admin can search for or paste into
|
||||
|
||||
@@ -77,6 +77,7 @@ The bare `/admin/settings` still resolves; it *redirects* to
|
||||
| `/admin/problems/cards` | Cards Report | `report-cards` |
|
||||
| `/admin/problems/recovery` | Recovery | `report-recovery` |
|
||||
| `/admin/problems/database` | Database problems | `report-database` |
|
||||
| `/admin/problems/integrity` | Filesystem integrity | `report-integrity` |
|
||||
|
||||
Two panes are called Security, and they are different things: the *settings*
|
||||
pane and the *report*. `security` is the settings one, beside `notifications`
|
||||
|
||||
@@ -9,9 +9,13 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
|
||||
- **New installs use FerretDB v1 (SQLite) by default on every CPU architecture.**
|
||||
FerretDB v1 is the wekan/FerretDB fork with an embedded pure-Go SQLite backend
|
||||
that speaks the MongoDB wire protocol, so WeKan talks to it exactly like MongoDB.
|
||||
- **Six architectures are built as snaps**: amd64, arm64, armhf, ppc64el, riscv64
|
||||
and s390x — exactly `snapcraft.yaml`'s `build-for:` list. i386 and armv7 are
|
||||
release bundles but **not** snaps, for reasons that are not oversights; see
|
||||
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
|
||||
- **MongoDB 7 *server* binaries (mongod) are included for amd64 and arm64 only.**
|
||||
The other architectures (s390x, riscv64, ppc64le) have no MongoDB server — they
|
||||
run FerretDB v1 only.
|
||||
The other architectures (armhf, ppc64el, riscv64, s390x) have no MongoDB server
|
||||
— they run FerretDB v1 only.
|
||||
- **Every Snap platform includes the FerretDB v1 server and the MongoDB Database
|
||||
Tools** (`mongodump`, `mongorestore`, `mongofiles`, `mongoexport`, …), on every
|
||||
architecture.
|
||||
@@ -30,13 +34,20 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
|
||||
|
||||
## Per-architecture matrix
|
||||
|
||||
| CPU arch | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|
||||
|----------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
|
||||
| amd64 | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
|
||||
| arm64 | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| s390x | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| ppc64le | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| riscv64 | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| Snap arch | Bundle | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|
||||
|-----------|--------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
|
||||
| amd64 | `amd64` | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
|
||||
| arm64 | `arm64` | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| armhf | `armhf` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| ppc64el | `ppc64le` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| riscv64 | `riscv64` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
| s390x | `s390x` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
|
||||
|
||||
These six are exactly the `build-for:` entries in `snapcraft.yaml`. The **Bundle**
|
||||
column is the release-asset name at
|
||||
[github.com/wekan/wekan/releases](https://github.com/wekan/wekan/releases), which
|
||||
is not always the Snap Store's name for the same hardware — see
|
||||
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
|
||||
|
||||
- **MongoDB 7 server (mongod)**: amd64/arm64 only (MongoDB ships no server for the others).
|
||||
- **MongoDB Database Tools**: every arch — WeKan builds them for all platforms from
|
||||
@@ -47,6 +58,60 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
|
||||
All architectures default new installs to FerretDB v1 with `DDP_TRANSPORT=sockjs`
|
||||
and polling reactivity.
|
||||
|
||||
## CPU architecture names and what they mean
|
||||
|
||||
An architecture can go by one name in the release bundles and another in the Snap
|
||||
Store, and two names that look like variants of each other can be different
|
||||
builds. Nothing warns about either: an unrecognised `--arch` is simply an
|
||||
architecture the store has never heard of, so a wrong name looks like it worked.
|
||||
The mapping is kept in one place, `models/lib/snapArchitectures.js`, and is unit
|
||||
tested (`tests/snapArchitectures.test.cjs`).
|
||||
|
||||
### ppc64le and ppc64el are the same hardware
|
||||
|
||||
64-bit little-endian POWER. The bundles name it the way Node.js and the kernel do
|
||||
(`ppc64le`); the Snap Store names it the way Debian does (`ppc64el`). It is the
|
||||
**only** architecture whose two names differ, and a test asserts that stays true.
|
||||
|
||||
### armhf and armv7 are the same CPU family, built to different baselines
|
||||
|
||||
This one is not a naming difference, and getting it wrong ships a snap that
|
||||
crashes. [wekan/node-patches](https://github.com/wekan/node-patches) builds two
|
||||
32-bit ARM Node.js binaries:
|
||||
|
||||
| Bundle | Built for | Runs on |
|
||||
|--------|-----------|---------|
|
||||
| `armhf` | hard-float, **VFPv3-D16**, no NEON assumed — the Debian armhf baseline | **any** ARMv7-A board |
|
||||
| `armv7` | hard-float, **NEON**, ARMv7-A tuned | only boards that have NEON (an ODroid-U3, say) |
|
||||
|
||||
The Snap Store has **one** 32-bit ARM architecture, `armhf`, and it serves every
|
||||
such device. It must therefore carry the **baseline** build: the NEON one would
|
||||
be an illegal instruction on any armhf board without NEON. So the armhf snap uses
|
||||
the `armhf` bundle, and **`armv7` has no snap** — it ships as a bundle only. A
|
||||
separate snap name or track would be the only way to offer the NEON build, which
|
||||
is not worth a second listing for one board family.
|
||||
|
||||
### i386 has no snap, and cannot have a new one
|
||||
|
||||
core24 is Ubuntu 24.04, which has **no i386 port**, so there is no i386 core24
|
||||
base snap. `build-on: i386` is not merely unsupported — it is a **parse error**,
|
||||
and because snapcraft parses the whole file first it failed *every* architecture's
|
||||
build, not only i386's. This is not something a patch set can fix the way
|
||||
[node-patches](https://github.com/wekan/node-patches) fixes a missing Node.js
|
||||
build: node-patches patches source so a binary *can* be built, whereas here the
|
||||
**base snap itself does not exist** for the architecture. The last base with a
|
||||
real i386 port was `core18`, which is end-of-life.
|
||||
|
||||
The Snap Store still shows an i386 column for **wekan-ondra** (revision 70,
|
||||
version `0.X-ci`) because the store keeps whatever was ever uploaded. It is years
|
||||
stale, nothing new can be built for it, and re-releasing it would only re-publish
|
||||
`0.X-ci`. **i386 users are served by the .deb and the AppImage.**
|
||||
|
||||
### loong64, win64, win32, mac-x64, mac-arm64
|
||||
|
||||
Bundle platforms with no Snap Store architecture at all. They are not snaps and
|
||||
are not expected to be.
|
||||
|
||||
## New installs
|
||||
|
||||
On a fresh install, every architecture starts on **FerretDB v1 (SQLite)** with
|
||||
@@ -151,18 +216,82 @@ bundle the snap's `wekan` part downloads, and staged to `$SNAP/ferretdb`
|
||||
`<tool>-<arch>` from [wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches/releases),
|
||||
and `mongosh-<arch>` from [wekan/mongosh](https://github.com/wekan/mongosh/releases).
|
||||
|
||||
## Snap channels
|
||||
## Snaps and channels
|
||||
|
||||
The default `snapcraft.yaml` is built on `base: core24`, which allows the
|
||||
**candidate** channel (core26 does not). The automated release workflow publishes
|
||||
it to the **candidate**, **beta**, and **edge** channels; the **stable** channel is
|
||||
published manually later, once it is proven.
|
||||
`snapcraft-core26.yaml` is the same WeKan on the newer `base: core26`
|
||||
(`build-base: devel`, `grade: devel`), kept for testing the next base; it can only
|
||||
go to Beta/Edge until core26 is a released stable base.
|
||||
There are **three snaps** of the same application, and each should reach **all
|
||||
four channels on every architecture it is built for**:
|
||||
|
||||
| Snap | What it is |
|
||||
|------|------------|
|
||||
| [`wekan`](https://snapcraft.io/wekan) | the default |
|
||||
| [`wekan-ondra`](https://snapcraft.io/wekan-ondra) | same WeKan, separate listing |
|
||||
| [`wekan-gantt-gpl`](https://snapcraft.io/wekan-gantt-gpl) | same WeKan, separate listing |
|
||||
|
||||
**All four channels — Stable, Candidate, Beta and Edge.** The default
|
||||
`snapcraft.yaml` is built on `base: core24`, a *released* base, so the snap carries
|
||||
`grade: stable` and may be published to **stable** as well as the other three.
|
||||
That is what makes the full set possible: a `grade: devel` snap is refused by
|
||||
stable and candidate outright.
|
||||
|
||||
`snapcraft-core26.yaml` is the same WeKan on `base: core26`, kept for testing the
|
||||
next base and **not** built by the release: core26 still requires
|
||||
`build-base: devel` and therefore `grade: devel`, so it can only reach beta and
|
||||
edge. That is the whole reason the release builds core24.
|
||||
|
||||
### Publishing to every channel
|
||||
|
||||
A revision is **per architecture** — the store's own listing shows `wekan` at
|
||||
revision 3601 on amd64 and 3600 on arm64 for the same version 10.76 — so one
|
||||
revision number can never be released to every architecture. Use:
|
||||
|
||||
```bash
|
||||
releases/snap-release-all-channels.sh --dry-run # see the plan first
|
||||
releases/snap-release-all-channels.sh 10.76 # pin the version
|
||||
releases/snap-release-all-channels.sh # or the newest of each
|
||||
```
|
||||
|
||||
It resolves the revision per (snap, architecture) from the store and releases it
|
||||
to `stable,candidate,beta,edge` in **one** call, so a revision reaches all four
|
||||
or none. A (snap, architecture) with no revision is reported and skipped — the
|
||||
three snaps genuinely have different architecture sets today. It reads the
|
||||
architecture table from `models/lib/snapArchitectures.js`, so there is no second
|
||||
copy of it in shell.
|
||||
|
||||
**Pinning the version matters.** Without one, the newest revision of each
|
||||
architecture is promoted — and edge is often ahead of stable, so a bare run
|
||||
publishes edge builds to stable users. Pass the version, or `--dry-run` first.
|
||||
|
||||
### What each snap currently has, and what is still to add
|
||||
|
||||
As of WeKan 10.76 the store holds:
|
||||
|
||||
| Snap | amd64 | arm64 | armhf | ppc64el | riscv64 | s390x |
|
||||
|------|:-----:|:-----:|:-----:|:-------:|:-------:|:-----:|
|
||||
| `wekan` | ✅ | ✅ | — | ✅ | — | ✅ |
|
||||
| `wekan-ondra` | ✅ | ✅ | ⚠️ | — | — | — |
|
||||
| `wekan-gantt-gpl` | ✅ | ✅ | — | — | — | — |
|
||||
|
||||
- **—** the architecture is built by `snapcraft.yaml` but that snap has no
|
||||
revision for it yet. Uploading one is all that is needed; the release script
|
||||
then puts it on all four channels.
|
||||
- **⚠️** `wekan-ondra`'s armhf is stuck at version **0.22**, and its i386 column at
|
||||
**0.X-ci**, on all four channels — fossils the store kept from years ago.
|
||||
Promoting channels cannot fix these: there is no newer revision to promote.
|
||||
armhf needs a build uploaded for that snap; i386 cannot be built at all (see
|
||||
[i386](#i386-has-no-snap-and-cannot-have-a-new-one)).
|
||||
|
||||
Publishing the **stable** channel is safe for these because the snap performs its
|
||||
**automatic migrations** on start — the schema upgrades and the one-time
|
||||
MongoDB 3 → FerretDB v1 migration on amd64 — so an existing installation moving
|
||||
from an older revision to this one is upgraded in place rather than needing a
|
||||
manual step.
|
||||
|
||||
## Related
|
||||
|
||||
- FerretDB v1 fork: https://github.com/wekan/FerretDB
|
||||
- Snap install: [Install.md](Install.md)
|
||||
- Snap settings keys: [Supported-settings-keys.md](Supported-settings-keys.md)
|
||||
- The architecture table in code: `models/lib/snapArchitectures.js`
|
||||
(tested by `tests/snapArchitectures.test.cjs`)
|
||||
- Publish every snap to every channel: `releases/snap-release-all-channels.sh`
|
||||
- Node.js builds per platform: [wekan/node-patches](https://github.com/wekan/node-patches)
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-10.72-win64.zip](https://github.com/wekan/wekan/releases/download/v10.72/wekan-10.72-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-10.78-win64.zip](https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
|
||||
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
|
||||
|
||||
@@ -22,7 +22,7 @@ Right click and download files 1-4:
|
||||
|
||||
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
|
||||
|
||||
7. Unzip `wekan-10.72-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-10.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
@@ -70,6 +70,86 @@ problem *observable* to WeKan; the recording is WeKan's job. Concretely:
|
||||
WeKan maps these to `eventlog` documents via the category catalog
|
||||
(`models/lib/securityCategories.js`) using `source:'sqlite.*'`.
|
||||
|
||||
---
|
||||
|
||||
## 3b. Canary tokens — operations the client never issues
|
||||
|
||||
The database is reached over a **local socket by one application**, whose driver
|
||||
is a Meteor 3 one. That makes a class of operations interesting **by their mere
|
||||
presence**: server-side JavaScript evaluation, an aggregation writing its result
|
||||
into a collection, dropping a database, a server-administration command. The
|
||||
driver does not send them. A request that does is either a bug or somebody who
|
||||
has reached the socket and is looking around — and both are worth telling the
|
||||
operator about.
|
||||
|
||||
`internal/util/canary` (paired with `tests` in the same package) is the FerretDB
|
||||
half of [WeKan.md §12](WeKan.md), and it keeps the same three properties:
|
||||
|
||||
- **SILENT.** A tripped canary returns the ordinary *"operation not supported by
|
||||
this build"* refusal — the same answer an unimplemented command gets. The id is
|
||||
appended as `canary:<id>`, which the client parses and the operator reads;
|
||||
nothing in it says that anything was detected or recorded, so a probe cannot
|
||||
tell a watched operation from an unimplemented one and route around the watched
|
||||
ones. A Go test asserts the message contains none of *detect*, *record*, *log*,
|
||||
*alert*.
|
||||
- **BOUNDED.** The package writes **nothing** — no file, no table, no counter. It
|
||||
is a map lookup and an error value, so a caller hammering it in a loop costs
|
||||
this process one string comparison per request and this package **no memory at
|
||||
all**. This is what §2's "FerretDB does not write to any database or file"
|
||||
means for canaries, and a Go test asserts the package is stateless.
|
||||
- **ATTRIBUTED.** The marker names *which* canary, so the operator's report says
|
||||
"tried to run server-side JavaScript" rather than "an error".
|
||||
|
||||
The canaries:
|
||||
|
||||
| Id | Operations | Why the client never sends it |
|
||||
| --- | --- | --- |
|
||||
| `db.javascript` | `eval`, `$where`, `$function`, `$accumulator`, `mapReduce` | the driver has no feature that evaluates JavaScript in the database |
|
||||
| `db.result-to-collection` | `$out`, `$merge` | aggregation results are read, never persisted by the database |
|
||||
| `db.drop-database` | `dropDatabase`, `dropAllDatabases` | the application drops collections it owns; dropping the database is an operator action taken with the database's own tools |
|
||||
| `db.server-admin` | `shutdown`, `setParameter`, `getParameter`, `profile`, `logRotate` | these manage the server, not the data |
|
||||
|
||||
`Check(op)` returns `nil` for everything else, and a Go test pins the ordinary
|
||||
vocabulary — `find`, `insert`, `update`, `aggregate`, `$match`, `$group`,
|
||||
`$lookup`, … — as **not** tripping. A canary that fires on normal traffic is
|
||||
worse than no canary: it buries the real ones.
|
||||
|
||||
### How it reaches the operator
|
||||
|
||||
Exactly as §2 describes for every other FerretDB event — the database reports,
|
||||
the client records:
|
||||
|
||||
```
|
||||
FerretDB WeKan
|
||||
Check(op) → canary:<id> in the error ──► recordDatabaseProblem()
|
||||
│ databaseCanaryId() reads the marker
|
||||
▼
|
||||
tripCanary('database.canary') ← rate-limited,
|
||||
│ aggregated,
|
||||
▼ attributed
|
||||
eventlog (stream:'security')
|
||||
│
|
||||
▼
|
||||
Admin Panel → Problems → Security
|
||||
```
|
||||
|
||||
Two things that are deliberate on the WeKan side
|
||||
(`server/lib/databaseProblems.js`):
|
||||
|
||||
1. The id is **not trusted as a category**. It is matched against a known list,
|
||||
and anything else is recorded generically. An error string is
|
||||
attacker-influenced, and a marker parsed out of one must never be able to
|
||||
choose which security category it lands in — or to inject anything, which is
|
||||
why the extractor accepts only `[a-z][a-z0-9.-]{0,60}`.
|
||||
2. A canary goes to the **security** stream and does **not** fall through to the
|
||||
`database` stream. Filing it under "the database said something" would put an
|
||||
intrusion attempt in the list of things to triage as configuration problems.
|
||||
|
||||
On **MongoDB** there is no FerretDB to mark anything, and these operations simply
|
||||
never appear — the WeKan side is inert, and the feature degrades to nothing
|
||||
rather than misbehaving. That is the same property as §2: storage and reporting
|
||||
are WeKan's job, so nothing here depends on which database is underneath.
|
||||
|
||||
## 4. Security remediation points → logger
|
||||
|
||||
| Point | Remediation (present/added) | Logged as |
|
||||
|
||||
@@ -351,7 +351,333 @@ test". It uses the same `events` schema and the same disk-space discipline, in t
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope / follow-ups
|
||||
## 12. Canary tokens — who tried it, and from where
|
||||
|
||||
§7 records what a guard **did**: a request was blocked, a filename sanitized, an
|
||||
upload refused. That answers "is WeKan defending itself". It does not answer the
|
||||
question an admin actually asks after seeing one — **who did that, and from
|
||||
where** — and it does not distinguish a browser that got confused from somebody
|
||||
working through the [hall of fame](https://wekan.fi/hall-of-fame/) one entry at
|
||||
a time.
|
||||
|
||||
A **canary token** is a tripwire placed at a point that **only a permission-override
|
||||
attempt reaches**. Ordinary use never gets there: the code path runs when a
|
||||
request asks for something the rules forbid. So a trip is not noise, and it is
|
||||
worth an admin's attention with the actor attached.
|
||||
|
||||
Three properties define one. Each is enforced by `tests/canaryTokens.test.cjs`
|
||||
and `tests/canaryCoverage.test.cjs`.
|
||||
|
||||
### 12.1 SILENT — the attacker learns nothing
|
||||
|
||||
Tripping a canary changes **nothing the caller can observe**. The refusal keeps
|
||||
its wording, its status, its timing and its shape. This is not politeness: a
|
||||
canary that announces itself is a map of which paths are watched, and a probe
|
||||
would simply avoid them.
|
||||
|
||||
So the trip function is written as a drop-in for the refusal it replaces:
|
||||
|
||||
```js
|
||||
if (somethingForbidden) return false; // before
|
||||
if (somethingForbidden) return tripCanary('card.vote-field', { userId }); // after
|
||||
```
|
||||
|
||||
`tripCanary()` **always returns `false`**, and `tripCanaryDeny()` — for a Meteor
|
||||
`deny` rule, which refuses by returning **true** — always returns `true`. The
|
||||
return value is decided **before** any reporting is attempted, and the reporting
|
||||
is wrapped, so a canary can never throw into, delay, or otherwise disturb the
|
||||
request it is watching. A REST handler that must throw re-throws **the original
|
||||
error object**, never a new one.
|
||||
|
||||
### 12.2 BOUNDED — probing must not cost more than it does now
|
||||
|
||||
A canary sits where an attacker can loop. Writing one database row per attempt
|
||||
would be a denial of service they get for free: CPU on every insert, and a
|
||||
collection that grows until the disk is full.
|
||||
|
||||
`models/lib/canaryTokens.js` (pure, no Meteor, no clock of its own — the runtime
|
||||
passes the time in, which is how the tests drive it) decides per trip:
|
||||
|
||||
- the **first** trip of a `(canary, actor)` pair is recorded immediately, so a
|
||||
probe shows up promptly;
|
||||
- further trips **inside the window** (default 60 s) write nothing and are
|
||||
**counted**;
|
||||
- the first trip **after** the window writes **one summary** carrying the total,
|
||||
which is what the `count` column shows;
|
||||
- a pair that has already written `maxEventsPerPair` summaries (default 60)
|
||||
keeps counting but **stops writing** until it goes quiet — so an endless slow
|
||||
probe cannot write one row a minute forever;
|
||||
- the map of tracked pairs is **capped** (default 5000) and evicts the **least
|
||||
recently seen** entry, so a long-running attacker is not pushed out by a
|
||||
passing one, and a botnet varying its address cannot grow it without bound;
|
||||
- idle pairs are **swept** on a timer, so a long-lived server does not hold a row
|
||||
per address seen since boot.
|
||||
|
||||
A thousand attempts in a minute therefore cost **one row**, and the decision to
|
||||
suppress costs **one map lookup** — which is what makes a canary safe to put on a
|
||||
path an attacker chooses to hammer. The username lookup is cached (60 s, capped
|
||||
at 1000 entries), so probing cannot make WeKan do a database read per attempt
|
||||
either, and neither the lookup nor the insert is awaited on the caller's path.
|
||||
|
||||
### 12.3 ATTRIBUTED — who, and from where
|
||||
|
||||
Every event carries:
|
||||
|
||||
| Field | Where it comes from |
|
||||
| --- | --- |
|
||||
| `userId` | the caller's own knowledge, else the DDP invocation |
|
||||
| `username` | looked up once and cached; **stored on the event**, not resolved later |
|
||||
| `ip` | the request, resolved with `resolveClientKey` — the SAME spoofing-safe rule as the login throttle |
|
||||
| `count` | how many attempts this one row stands for (§12.2) |
|
||||
|
||||
The username is **denormalised at write time on purpose**: it is what the account
|
||||
was *called when it tried*, so a later rename does not rewrite history and a
|
||||
deleted account does not erase it.
|
||||
|
||||
The address honours `X-Forwarded-For` **only as far as `HTTP_FORWARDED_COUNT`
|
||||
says to trust it**, exactly as `server/lib/loginAttemptThrottle.js` does.
|
||||
Otherwise an attacker would write somebody else's address into the security log
|
||||
by sending a header — turning the report into a way to frame a colleague.
|
||||
|
||||
`detail` says what was **attempted**, in the words of the feature ("tried to move
|
||||
a card into a board they cannot write to"), and **never the payload**: it is
|
||||
attacker-controlled text, and §4's truncation and control-character stripping is
|
||||
the second line of defence behind that rule.
|
||||
|
||||
### 12.4 Where the canaries are
|
||||
|
||||
Each is `canaryId → the guard that trips it → the published vulnerability whose
|
||||
ATTEMPT it watches`. The table is pinned by `tests/canaryCoverage.test.cjs`,
|
||||
which also fails on a canary in the catalog that nothing trips.
|
||||
|
||||
| Canary | Tripped in | Watches the attempt behind |
|
||||
| --- | --- | --- |
|
||||
| `card.cross-board-move` | `server/permissions/cards.js` | BoardBleed |
|
||||
| `card.invisible-parent` | `server/permissions/cards.js` | ParentBleed |
|
||||
| `card.vote-field`, `card.poker-field` | `server/permissions/cards.js` | direct field writes that must go through a method |
|
||||
| `list.cross-board-move` | `server/permissions/lists.js` | BoardBleed |
|
||||
| `swimlane.cross-board-move` | `server/permissions/swimlanes.js` | BoardBleed |
|
||||
| `checklist.cross-board-move` | `server/permissions/checklists.js` | ChecklistBleed |
|
||||
| `checklist-item.cross-board-move` | `server/permissions/checklistItems.js` | ChecklistBleed |
|
||||
| `avatar.version-path`, `avatar.restricted-field`, `avatar.not-owner` | `server/permissions/avatars.js` | PathBleed |
|
||||
| `attachment.version-path`, `attachment.restricted-field` | `server/permissions/attachments.js` | PathBleed |
|
||||
| `reaction.foreign` | `server/permissions/cardCommentReactions.js` | writing another user's reaction |
|
||||
| `comment.foreign-delete` | `server/models/cardComments.js` | CommentBleed |
|
||||
| `export.path-outside-storage` | `models/exporter.js` | PathBleed (a path poisoned some other way) |
|
||||
| `database.canary` | `server/lib/databaseProblems.js` | [FerretDB.md](FerretDB.md) — an operation WeKan never issues |
|
||||
|
||||
**No Playwright, no browser.** Every canary above fires inside a server-side
|
||||
permission check, so it works on any WeKan server with nothing installed. A
|
||||
published vulnerability whose attempt leaves no server-side trace — a fixed XSS,
|
||||
say, where the payload is refused in the browser — has no canary, and that is a
|
||||
statement of what is detectable rather than an omission.
|
||||
|
||||
### 12.5 What the admin sees
|
||||
|
||||
Admin Panel → **Problems → Security** (§8), unchanged except for three columns:
|
||||
**Username**, **IP address** and **Attempts**. Both new columns are **searchable**,
|
||||
because the thing an admin does with one security event is pivot on it: every
|
||||
other event from this address, every other event from this account.
|
||||
|
||||
A row reads:
|
||||
|
||||
```
|
||||
2026-08-09 14:02:11 · authz · PathBleed · high · detected · canary:avatar.version-path
|
||||
· mallory · 203.0.113.7 · 214
|
||||
· tried to write the on-disk path of an avatar; 214 attempts in this window
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12.6 Injection, sanitization, and the other common attacks
|
||||
|
||||
§12.4 watches **permission overrides**. Three more families are watched the same
|
||||
way, and each has the same problem in common: the defence already existed and was
|
||||
**silent**, so nobody ever learned that it had fired.
|
||||
|
||||
**NoSQL injection** (`models/lib/injectionDetect.js`, pure and unit-tested). Two
|
||||
shapes, and they are different problems:
|
||||
|
||||
- an **execution operator** in a client-supplied selector — `$where`,
|
||||
`$function`, `$accumulator`, `$out`, `$merge`. A selector is data; these turn
|
||||
it into code the database runs. → `injection.nosql-selector`
|
||||
- an **operator object where a scalar was expected** — `{"$ne": null}`,
|
||||
`{"$gt": ""}`, `{"$regex": ".*"}` in a field that should hold the string a
|
||||
user typed. This is how "match every row" is spelled in a document database
|
||||
and it needs no JavaScript at all. → `injection.nosql-operator`
|
||||
|
||||
The detector is deliberately narrow about the second: **every** key must be an
|
||||
operator. A plain object with ordinary keys is somebody sending the wrong type,
|
||||
which is a validation error, and calling it an attack would fill the report with
|
||||
noise. A `$` inside a *string* is text — a card titled `$100 refund` puts nobody
|
||||
in the security report.
|
||||
|
||||
**SQL injection.** WeKan builds no SQL, so it cannot detect this itself. The
|
||||
database does: FerretDB's `internal/util/sqlguard` refuses a statement carrying
|
||||
what only injection produces, and now **marks the refusal** so the attempt
|
||||
reaches the admin instead of dying in a log file
|
||||
([FerretDB.md §3b](FerretDB.md)). → `injection.sql-statement`
|
||||
|
||||
**Sanitization.** Sanitizing is routine — a filename gets trimmed, a pasted
|
||||
`<b>` gets stripped from a comment — and routine is not worth an admin's
|
||||
attention. What is worth it is sanitization that removed something that would
|
||||
have **done** something:
|
||||
|
||||
| Canary | Fires when |
|
||||
| --- | --- |
|
||||
| `sanitize.dangerous-filename` | an uploaded name carried an exploit pattern, invisible characters or URL encoding |
|
||||
| `sanitize.path-traversal` | a file path tried to leave its directory |
|
||||
| `sanitize.dangerous-content` | active markup was removed from an uploaded file (a script or `javascript:` URI inside an SVG) |
|
||||
| `sanitize.dangerous-text` | active markup was removed from submitted text (a comment carrying `<script>`, an `onerror=`, a `javascript:` URI) |
|
||||
|
||||
`removedActiveMarkup()` decides the last two by comparing what the sanitizer took
|
||||
**out**: text that never had any is not interesting, and text that still has it
|
||||
was not sanitized at all — which would be a different bug.
|
||||
|
||||
**Other common attacks.**
|
||||
|
||||
| Canary | Fires when |
|
||||
| --- | --- |
|
||||
| `spoof.forwarded-header` | `/metrics` denied a request that carried `X-Forwarded-For` — nothing legitimate sends one to an endpoint that does not trust it |
|
||||
| `brute.login-lockout` | a REST login was refused while the address was locked out, i.e. it had already failed the configured number of attempts in a row |
|
||||
|
||||
Both replaced a **direct `securityLog.record()`**. That matters for more than
|
||||
tidiness: those two paths are **unauthenticated**, so a bare `record()` was one
|
||||
database insert per request an attacker chose to send. Going through the canary
|
||||
gives them the §12.2 rate limit and the §12.3 attribution, and the responses —
|
||||
the 401 and the 429 — are untouched.
|
||||
|
||||
## 13. Filesystem storage integrity, crashes and downtime
|
||||
|
||||
§12 watches what somebody **tries to do through WeKan**. This section is about
|
||||
what happened to WeKan's own files and process **when nobody was asking it
|
||||
anything**.
|
||||
|
||||
WeKan's attachments and avatars are files under `WRITABLE_PATH`, and the database
|
||||
holds one document per file. **Nothing checked that the two still agree.** A file
|
||||
can be replaced, truncated, back-dated or deleted by anything that reaches the
|
||||
filesystem — a bad restore, a sync tool, a container rebuild, a shell on the
|
||||
volume — and WeKan would keep serving whatever is there now, silently.
|
||||
|
||||
### 13.1 The baseline, and the four hashes
|
||||
|
||||
One document per file in the existing WeKan database (`fileIntegrity`; **no new
|
||||
files under `WRITABLE_PATH`**, per §3): the path, the size, the modification
|
||||
time, and **md5, sha256 and sha512**.
|
||||
|
||||
Three digests, not one, because they answer different questions. **md5** is fast
|
||||
and is what most other tools print, so an admin can compare against a backup with
|
||||
the tool they already have — it is never alone here, because its collision
|
||||
resistance is gone. **sha256** is the working digest. **sha512** is a second,
|
||||
different-width one, and it earns its place on the day the two *disagree*: two
|
||||
digests over the same bytes cannot disagree, so when they do, the bytes were not
|
||||
read the same way twice — a failing disk, a partial write, a truncated copy —
|
||||
which is a different problem from a substitution and is reported as its own,
|
||||
**critical**, finding.
|
||||
|
||||
**ed25519 is the fourth, and it is not a hash — it is a signature**, which is the
|
||||
only thing that answers the question the three digests cannot: *who says these
|
||||
are the right hashes?* Anybody who can rewrite a file can rewrite a row of
|
||||
hashes. So each baseline entry is signed over a canonical line
|
||||
(`manifestLine()` — fixed field order, no JSON, whose key order and escaping are
|
||||
not guaranteed stable across versions), and the signature is verified on every
|
||||
scan. A record that does not match its signature is a **critical** finding, and
|
||||
it is never explained away by an ordinary edit: WeKan does not rewrite a baseline
|
||||
without re-signing it.
|
||||
|
||||
The key:
|
||||
|
||||
| Source | What it protects against |
|
||||
| --- | --- |
|
||||
| `WEKAN_INTEGRITY_PRIVATE_KEY` (PKCS#8 PEM), supplied by the operator and **never stored by WeKan** | an attacker who reaches the **database** as well as the disk |
|
||||
| otherwise, generated on first run and kept in the database | filesystem-only tampering — a restore, a sync tool, a container rebuild, a shell on the volume |
|
||||
|
||||
The second is the common case and is honest about not being more than that. A
|
||||
**malformed** supplied key is reported and the scan runs **without** signing
|
||||
rather than silently falling back to a generated one, which would look like it
|
||||
was working while checking nothing the operator meant.
|
||||
|
||||
### 13.2 The scan: once a day, when nothing else needs the machine
|
||||
|
||||
Reading every stored byte is the last thing that should compete with users, so
|
||||
the scan is paced by policy rather than by hope (`models/lib/fileIntegrity.js`,
|
||||
pure and time-injected):
|
||||
|
||||
- **once a day** (`intervalMs`), checked hourly — not on every restart;
|
||||
- **not while the machine is busy**: at or above **60% CPU** it does not start,
|
||||
and it stops if the load rises mid-run. It is never urgent;
|
||||
- **a pause between every file** — 50 ms, plus 20 ms per megabyte, so a
|
||||
directory of large files does not become a sustained read;
|
||||
- **a time budget** (15 minutes). A scan that stops and continues tomorrow is
|
||||
better than one that holds a machine down;
|
||||
- **one read per file for all three digests.** Three passes would be three times
|
||||
the disk for the same bytes.
|
||||
|
||||
A run that stopped early does **not** report the files it never reached as
|
||||
missing — it has not looked everywhere, and saying otherwise would be a lie.
|
||||
|
||||
### 13.3 The finding: changed, with no record saying why
|
||||
|
||||
Files change when people use WeKan; reporting that would be noise. So each change
|
||||
is classified against WeKan's own record of the file:
|
||||
|
||||
| Situation | Result |
|
||||
| --- | --- |
|
||||
| unchanged | nothing recorded |
|
||||
| changed, and WeKan has a record of the operation | `info` — reported once, then **re-baselined** so it does not repeat daily |
|
||||
| **changed, with nothing to account for it** | **the warning** — at least `medium`, and deliberately **not** re-baselined, so it keeps showing until somebody looks |
|
||||
| the record's signature does not verify | `critical`, never explained |
|
||||
| recorded and now missing | `high` |
|
||||
| present and never recorded | `low` — a leftover or a backup copy is not evidence of anything |
|
||||
|
||||
A modification time that moved **backwards** is called out separately: nothing
|
||||
does that by accident.
|
||||
|
||||
The event carries the **file's name and the finding, never its contents and never
|
||||
the digests** — an admin needs to know which file, not to read it here.
|
||||
|
||||
### 13.4 Crashes, downtime and errors nobody caught
|
||||
|
||||
Admin Panel → Problems answered "what did WeKan refuse" and "what did the
|
||||
database say", and not the plainest question: **did this server stop, and did it
|
||||
stop cleanly?**
|
||||
|
||||
A crash leaves nothing behind — the process is gone, so it cannot write a message
|
||||
about being gone. A **heartbeat** is the only thing that works: WeKan writes the
|
||||
time every minute, and the NEXT start reads it.
|
||||
|
||||
| What the next start finds | Recorded as |
|
||||
| --- | --- |
|
||||
| no previous run | nothing — a first run is not a problem |
|
||||
| last heartbeat with a **clean-shutdown mark** | nothing — a deliberate restart is not a problem |
|
||||
| a **short** gap, no mark | `low` — the shutdown hook may simply not have run |
|
||||
| a **long** gap, no mark | **`high`** — it stopped without shutting down cleanly, and here is how long it was down |
|
||||
|
||||
Recording every deliberate restart is how a Problems page becomes a page nobody
|
||||
opens, which is why the first two record nothing at all.
|
||||
|
||||
Two more land in the same stream, because they are the same question one level
|
||||
down: an **uncaught exception** and an **unhandled promise rejection**. This app
|
||||
turns the second into a process exit, so it is a crash *with a cause attached* —
|
||||
and the cause is worth recording before the process goes. The listeners are
|
||||
**added, never replaced**: removing whatever else is listening would change how
|
||||
the app handles its own failures.
|
||||
|
||||
### 13.5 Where it appears
|
||||
|
||||
A new event stream, `integrity`, in the same `eventlog` collection and the same
|
||||
Admin Panel → **Problems → Filesystem integrity** table as the others (§8), with
|
||||
the Username / IP address / Attempts columns §12.3 added. It counts towards the
|
||||
red **Problems** button like every other stream.
|
||||
|
||||
The `fileIntegrity` baseline itself is **never published and never client-writable**:
|
||||
it is a map of every file on the server, and the key document is a private key.
|
||||
Neither belongs on a client, admin or not — Admin Panel reads the **findings**,
|
||||
which are ordinary event rows.
|
||||
|
||||
---
|
||||
|
||||
## 14. Out of scope / follow-ups
|
||||
|
||||
- Rotating/retention: a cron trims `eventlog` to the newest N per stream (`remove` older docs).
|
||||
- Shipping logs to an external SIEM (kept local by design).
|
||||
|
||||
@@ -2339,5 +2339,8 @@
|
||||
"event-severity": "Severity",
|
||||
"event-action": "Action",
|
||||
"event-source": "Source",
|
||||
"event-detail": "Detail"
|
||||
}
|
||||
"event-detail": "Detail",
|
||||
"event-ip": "IP address",
|
||||
"event-attempts": "Attempts",
|
||||
"integrityReportTitle": "Filesystem integrity"
|
||||
}
|
||||
+33
-9
@@ -4,6 +4,7 @@ import { check, Match } from 'meteor/check';
|
||||
import { Random } from 'meteor/random';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { notHelperBoardTitle } = require('/models/lib/helperBoards');
|
||||
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
|
||||
import escapeForRegex from 'escape-string-regexp';
|
||||
import CustomFields from './customFields';
|
||||
import {
|
||||
@@ -1862,7 +1863,29 @@ Boards.helpers({
|
||||
// Upsert keyed on the deterministic _id: concurrent self-heals collide on
|
||||
// the _id unique index, so only one insert wins instead of racing the
|
||||
// check-then-insert and each inserting a new swimlane.
|
||||
Swimlanes.upsert({ _id: defaultId }, { $setOnInsert: this.defaultSwimlaneFields() });
|
||||
//
|
||||
// It must be the ASYNC upsert. Meteor 3 removed the synchronous one on the
|
||||
// server, and calling it threw
|
||||
// Error: update is not available on the server. Please use updateAsync()
|
||||
// out of every `moveSwimlane` that reached this self-heal - the failure an
|
||||
// admin kept seeing in Admin Panel / Problems / Database problems. This
|
||||
// getter is SYNCHRONOUS and cannot await, so the upsert is started and not
|
||||
// waited for: the self-heal still happens, and this call returns whatever
|
||||
// is in the cache, which the caller already had to handle (it was undefined
|
||||
// before the self-heal existed too). A caller that needs the swimlane back
|
||||
// in the same tick uses ensureDefaultSwimlaneIdAsync().
|
||||
try {
|
||||
const p = Swimlanes.upsertAsync(
|
||||
{ _id: defaultId },
|
||||
{ $setOnInsert: this.defaultSwimlaneFields() },
|
||||
);
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('default swimlane self-heal failed:', e && e.message);
|
||||
}
|
||||
}
|
||||
return ReactiveCache.getSwimlane({ _id: defaultId });
|
||||
},
|
||||
|
||||
@@ -2452,14 +2475,15 @@ Boards.userBoards = (
|
||||
if (selector.title === undefined) {
|
||||
selector.title = notHelperBoardTitle();
|
||||
}
|
||||
selector.$or = [
|
||||
...(includePublic ? [{ permission: 'public' }] : []),
|
||||
{ members: { $elemMatch: { userId, isActive: true } } },
|
||||
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
|
||||
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
|
||||
// #5850: domain-based board sharing — board shared with the user's email domain.
|
||||
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
|
||||
];
|
||||
// GHSA-gwc4-fw7p-gw58: the same builder the `board` publication uses, so
|
||||
// "which boards may this user see" has one answer and cannot drift.
|
||||
selector.$or = boardVisibilitySelectors({
|
||||
userId,
|
||||
orgIds: user.orgIds(),
|
||||
teamIds: user.teamIds(),
|
||||
emailDomains: user.emailDomains(),
|
||||
includePublic,
|
||||
});
|
||||
return selector;
|
||||
};
|
||||
|
||||
|
||||
+56
-26
@@ -17,6 +17,23 @@ function sanitizeText(text) {
|
||||
previous = sanitized;
|
||||
sanitized = sanitized.replace(/<[^>]*>/g, '');
|
||||
} while (sanitized !== previous);
|
||||
|
||||
// A comment losing a `<b>` is an everyday thing and says nothing. A comment
|
||||
// losing a SCRIPT TAG, an event handler or a javascript: URI is a stored-XSS
|
||||
// attempt that this line defused, and an admin should be told who wrote it
|
||||
// (docs/Security/Remediation/WeKan.md §12.6). The comment is stored sanitized
|
||||
// either way; the author is told nothing.
|
||||
if (Meteor.isServer && sanitized !== text) {
|
||||
try {
|
||||
const { removedActiveMarkup, CANARY_IDS } = require('/models/lib/injectionDetect');
|
||||
if (removedActiveMarkup(text, sanitized)) {
|
||||
require('/server/lib/canary').tripCanary(CANARY_IDS.text, {
|
||||
detail: 'active markup removed from a comment',
|
||||
});
|
||||
}
|
||||
} catch (e) { /* a canary must never break a comment */ }
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -201,34 +218,47 @@ CardComments.helpers({
|
||||
|
||||
CardComments.hookOptions.after.update = { fetchPrevious: false };
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Server-side enforcement of comment edit/delete permissions (issue #5906).
|
||||
// Server-side enforcement of comment edit/delete permissions (issue #5906).
|
||||
//
|
||||
// The DDP `allow` rule in server/permissions/cardComments.js is the first gate,
|
||||
// but the per-board `restrictCommentEditing` setting is enforced here so the
|
||||
// rule cannot be bypassed and the decision lives next to the data. Exported so
|
||||
// the REST handlers can enforce the SAME rule (GHSA-pqr4-rxgp-hv2m) rather than
|
||||
// relying on collection hooks, which cannot see an HTTP caller's identity.
|
||||
export async function assertCanMutateComment(userId, doc) {
|
||||
// Server-internal operations (board copy, cleanup, migrations, etc.) run
|
||||
// without an authenticated user; do not block those here. User-initiated
|
||||
// DDP calls always carry a userId and are still gated by the allow rule.
|
||||
//
|
||||
// The DDP `allow` rule in server/permissions/cardComments.js is the first
|
||||
// gate, but the per-board `restrictCommentEditing` setting is enforced here
|
||||
// so the rule cannot be bypassed and the decision lives next to the data.
|
||||
const assertCanMutateComment = async (userId, doc) => {
|
||||
// Server-internal operations (board copy, cleanup, migrations, etc.) run
|
||||
// without an authenticated user; do not block those here. User-initiated
|
||||
// DDP calls always carry a userId and are still gated by the allow rule.
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const isAuthor = userId === doc.userId;
|
||||
if (isAuthor) {
|
||||
return; // Authors may always edit/delete their own comments.
|
||||
}
|
||||
const board = await Boards.findOneAsync(doc.boardId);
|
||||
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
|
||||
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
|
||||
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
|
||||
throw new Meteor.Error(
|
||||
'error-comment-edit-not-allowed',
|
||||
"You are not allowed to edit or delete another user's comment on this board.",
|
||||
);
|
||||
}
|
||||
};
|
||||
// GHSA-pqr4-rxgp-hv2m: an HTTP handler is NOT a server-internal operation,
|
||||
// but it reached the collection with no Meteor userId in the invocation
|
||||
// context, so it landed on this trusted path — and every board member could
|
||||
// delete anyone's comment through the REST API, restrictCommentEditing or
|
||||
// not. The REST handlers now call this function themselves, with req.userId,
|
||||
// BEFORE touching the collection (server/models/cardComments.js). The trusted
|
||||
// path stays for the genuine internal callers it was written for.
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const isAuthor = userId === doc.userId;
|
||||
if (isAuthor) {
|
||||
return; // Authors may always edit/delete their own comments.
|
||||
}
|
||||
const board = await Boards.findOneAsync(doc.boardId);
|
||||
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
|
||||
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
|
||||
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
|
||||
const error = new Meteor.Error(
|
||||
'error-comment-edit-not-allowed',
|
||||
"You are not allowed to edit or delete another user's comment on this board.",
|
||||
);
|
||||
// So the REST layer answers 403 rather than 500 (httpStatusForError).
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
CardComments.before.update(async (userId, doc) => {
|
||||
await assertCanMutateComment(userId, doc);
|
||||
});
|
||||
|
||||
+22
-1
@@ -31,6 +31,19 @@ EventLog.attachSchema(
|
||||
source: { type: String, optional: true }, // guard/module/test name (wekan… or sqlite…/ferretdb…)
|
||||
cwe: { type: String, optional: true },
|
||||
userId: { type: String, optional: true },
|
||||
// WHO and FROM WHERE. A security event that says only "something was
|
||||
// blocked" cannot be acted on: the admin needs the account and the address
|
||||
// to decide whether to lock it, and to recognise the same actor across
|
||||
// several events. Denormalised at write time on purpose - the username is
|
||||
// what the account was CALLED when it tried, which a later rename must not
|
||||
// rewrite, and a deleted account must not erase.
|
||||
username: { type: String, optional: true },
|
||||
ip: { type: String, optional: true },
|
||||
// How many attempts this one row stands for. A canary sits on a path an
|
||||
// attacker controls in a loop, so repeats inside a window are counted rather
|
||||
// than each written (models/lib/canaryTokens.js): 1 for an ordinary event, N
|
||||
// for a summary flush.
|
||||
count: { type: Number, optional: true },
|
||||
detail: { type: String, optional: true },
|
||||
// The 'database' stream's own four fields (server/lib/databaseProblems.js).
|
||||
// They MUST be declared here: collection2 cleans every insert against this
|
||||
@@ -62,7 +75,10 @@ EventLogAcks.attachSchema(
|
||||
// (MongoDB, or FerretDB over SQLite/PostgreSQL/MySQL/MariaDB/SAP HANA), what the
|
||||
// message means, and what an admin should do about it. See
|
||||
// models/lib/databaseErrors.js and server/lib/databaseProblems.js.
|
||||
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database'];
|
||||
// 'integrity' is what the FILESYSTEM said: a stored file that is not the file
|
||||
// WeKan stored (server/lib/fileIntegrityScan.js), and whether this server
|
||||
// stopped cleanly last time (server/lib/uptimeWatch.js).
|
||||
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database', 'integrity'];
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// The Security/Speed/Tests report pages filter by `stream` and sort by `at`
|
||||
@@ -97,6 +113,11 @@ if (Meteor.isServer) {
|
||||
selector.$or = [
|
||||
{ category: rx }, { bleed: rx }, { source: rx }, { detail: rx },
|
||||
{ db: rx }, { kind: rx }, { type: rx }, { message: rx },
|
||||
// WHO and FROM WHERE are the two things an admin looking at a security
|
||||
// event actually wants to pivot on: every other event from this address,
|
||||
// every other event from this account. Searching the table for either
|
||||
// has to find the rows that DISPLAY it, so both columns are searched.
|
||||
{ username: rx }, { ip: rx },
|
||||
];
|
||||
}
|
||||
return selector;
|
||||
|
||||
+59
-4
@@ -61,6 +61,29 @@ export function writeWithBackpressure(res, str) {
|
||||
});
|
||||
}
|
||||
|
||||
// GHSA-4mxf-m8pq-xc9p: an avatar's `versions.original.path` comes out of the
|
||||
// DATABASE, and the export below reads it off disk and embeds the bytes as
|
||||
// base64. The avatar allow rule now refuses to let a client write that field
|
||||
// (server/permissions/avatars.js), and this is the second lock: a path is only
|
||||
// read when it resolves to somewhere inside WeKan's own storage, so a path
|
||||
// poisoned any other way - an old document written before the fix, a restored
|
||||
// backup, a bad migration - still cannot turn an export into a file read.
|
||||
//
|
||||
// The roots are computed the same way models/avatars.js and models/attachments.js
|
||||
// compute their storagePath, via the shared helper both of them are kept in sync
|
||||
// with.
|
||||
export function avatarStorageRoots() {
|
||||
const { computeStoragePaths } = require('./lib/attachmentStoragePath');
|
||||
const paths = computeStoragePaths(process.env.WRITABLE_PATH);
|
||||
return [paths.avatars, paths.attachments];
|
||||
}
|
||||
|
||||
// True when `storedPath` may be read. Exported so the guard itself is testable.
|
||||
export function isReadableStoredFilePath(storedPath, roots) {
|
||||
const { isPathInsideAny } = require('./lib/storagePathContainment');
|
||||
return isPathInsideAny(storedPath, roots || avatarStorageRoots());
|
||||
}
|
||||
|
||||
// exporter maybe is broken since Gridfs introduced, add fs and path
|
||||
export class Exporter {
|
||||
constructor(boardId, attachmentId, options = {}) {
|
||||
@@ -117,12 +140,33 @@ export class Exporter {
|
||||
buffer.fill(0);
|
||||
|
||||
// callback has the form function (err, res) {}
|
||||
|
||||
// GHSA-4mxf-m8pq-xc9p: this is the ONE place the export turns a stored
|
||||
// path into bytes, for attachments and avatars alike, so the containment
|
||||
// check belongs here as well as at the call sites. A path that does not
|
||||
// resolve inside WeKan's storage is not a file this export may read.
|
||||
const storedPath = doc?.versions?.original?.path;
|
||||
if (!isReadableStoredFilePath(storedPath)) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Refused to export a file stored outside the storage root:', storedPath);
|
||||
}
|
||||
// A canary, not a guess: every path WeKan writes is inside the storage
|
||||
// root, so one that is not was put there by something that should not
|
||||
// have. The export continues without the file, exactly as before.
|
||||
try {
|
||||
const { tripCanary } = require('/server/lib/canary');
|
||||
tripCanary('export.path-outside-storage', { detail: 'during board export' });
|
||||
} catch (e) { /* never break an export to report on it */ }
|
||||
callback(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpFile = path.join(
|
||||
os.tmpdir(),
|
||||
`tmpexport${process.pid}${Math.random()}`,
|
||||
);
|
||||
const tmpWriteable = fs.createWriteStream(tmpFile);
|
||||
const readStream = fs.createReadStream(doc.versions.original.path);
|
||||
const readStream = fs.createReadStream(storedPath);
|
||||
readStream.on('data', function (chunk) {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
});
|
||||
@@ -295,7 +339,10 @@ export class Exporter {
|
||||
const m = localUrl.match(/\/(?:cdn\/storage\/avatars|cfs\/files\/avatars)\/([^/?#]+)/);
|
||||
if (m && m[1]) {
|
||||
const avatar = await ReactiveCache.getAvatar(m[1]);
|
||||
if (avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path) {
|
||||
const avatarPath = avatar?.versions?.original?.path;
|
||||
// GHSA-4mxf-m8pq-xc9p: only read it when it is really inside WeKan's
|
||||
// own storage. A path pointing anywhere else is not an avatar.
|
||||
if (avatarPath && isReadableStoredFilePath(avatarPath)) {
|
||||
const file = await getBase64DataAsync(avatar);
|
||||
if (file) {
|
||||
user.profile = user.profile || {};
|
||||
@@ -434,7 +481,13 @@ export class Exporter {
|
||||
if (!this._excludeAttachments) {
|
||||
await w(',"file":"');
|
||||
const filePath = att.versions && att.versions.original && att.versions.original.path;
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
// GHSA-4mxf-m8pq-xc9p class: the ATTACHMENT half of the same read. The
|
||||
// attachment allow rule refuses a client-supplied path, so this is not
|
||||
// the reported hole - but a path is only as trustworthy as every way it
|
||||
// could have been written, and this streaming export is the one
|
||||
// remaining place that read a stored path with nothing but an
|
||||
// existsSync. Same containment check as the avatars above.
|
||||
if (filePath && isReadableStoredFilePath(filePath) && fs.existsSync(filePath)) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const rs = fs.createReadStream(filePath);
|
||||
let leftover = Buffer.alloc(0);
|
||||
@@ -512,7 +565,9 @@ export class Exporter {
|
||||
if (m && m[1]) {
|
||||
const avatar = await avatarsRaw.findOne({ _id: m[1] });
|
||||
const p = avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path;
|
||||
if (p) {
|
||||
// GHSA-4mxf-m8pq-xc9p: same containment check as the non-streaming
|
||||
// export above - never read a stored path from outside the storage.
|
||||
if (p && isReadableStoredFilePath(p)) {
|
||||
try {
|
||||
const buf = fs.readFileSync(p);
|
||||
user.profile = user.profile || {};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
// ============================================================================
|
||||
// The filesystem storage BASELINE (design: docs/Security/Remediation/WeKan.md §13).
|
||||
//
|
||||
// One document per stored file: what it was called, how big it was, when it was
|
||||
// last modified, its md5/sha256/sha512, and an ed25519 signature over all of
|
||||
// that. server/lib/fileIntegrityScan.js writes it and re-checks it once a day.
|
||||
//
|
||||
// In the EXISTING WeKan database, like every other part of this subsystem - no
|
||||
// new files under WRITABLE_PATH, nothing extra to back up or to forget to back
|
||||
// up, and it works the same on FerretDB and MongoDB.
|
||||
//
|
||||
// Deliberately NOT schema-attached: `digests` is a small open map, and the point
|
||||
// of this collection is to be a faithful record of what was observed rather than
|
||||
// something a schema might quietly clean fields out of - which is exactly the
|
||||
// bug that once emptied the database-problems rows (models/eventLog.js).
|
||||
// ============================================================================
|
||||
|
||||
const FileIntegrity = new Mongo.Collection('fileIntegrity');
|
||||
|
||||
// The signing key, and the scan's own bookkeeping (when it last ran). Separate
|
||||
// from the baseline so a full re-baseline never touches the key.
|
||||
export const IntegrityKeys = new Mongo.Collection('fileIntegrityKeys');
|
||||
|
||||
if (Meteor.isServer) {
|
||||
const { ensureIndex } = require('/server/lib/mongoStartup');
|
||||
Meteor.startup(async () => {
|
||||
// The scan reads by _id (the path) and sweeps by `at`.
|
||||
await ensureIndex(FileIntegrity, { at: -1 });
|
||||
});
|
||||
|
||||
// NEVER published. The baseline is a map of every file on the server and the
|
||||
// key document is a private key; neither has any business on a client, not
|
||||
// even an admin's. Admin Panel reads the FINDINGS, which are ordinary event
|
||||
// log rows, not this.
|
||||
FileIntegrity.deny({
|
||||
insert: () => true,
|
||||
update: () => true,
|
||||
remove: () => true,
|
||||
});
|
||||
IntegrityKeys.deny({
|
||||
insert: () => true,
|
||||
update: () => true,
|
||||
remove: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
export default FileIntegrity;
|
||||
@@ -101,6 +101,7 @@ const ADMIN_PAGES = {
|
||||
cards: 'report-cards',
|
||||
recovery: 'report-recovery',
|
||||
database: 'report-database',
|
||||
integrity: 'report-integrity',
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
@@ -173,6 +174,7 @@ const ADMIN_PANE_TITLES = {
|
||||
cards: { titleKey: 'cardsReportTitle' },
|
||||
recovery: { titleKey: 'recoveryReportTitle' },
|
||||
database: { titleKey: 'databaseReportTitle' },
|
||||
integrity: { titleKey: 'integrityReportTitle' },
|
||||
},
|
||||
attachments: {
|
||||
backup: { titleKey: 'backup' },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Pure, dependency-free construction of the Mongo `$or` that answers "which
|
||||
// boards may this user see". No Meteor imports, so it is unit tested directly
|
||||
// with plain Node (tests/boardVisibilitySelectors.test.cjs).
|
||||
//
|
||||
// GHSA-gwc4-fw7p-gw58: this was written out TWICE, and the two copies disagreed.
|
||||
// Boards.userBoards() - the All Boards list - matched org/team/domain shares
|
||||
// with `$elemMatch: { ..., isActive: true }`, but the `board` publication (the
|
||||
// one that sends the board and all its cards) matched them with a dotted
|
||||
// `'orgs.orgId': { $in: [...] }`, which ignores isActive entirely. isActive is
|
||||
// the REVOKE switch: setting a share to `isActive: false` made the board vanish
|
||||
// from All Boards, as it should, while a user who still knew the boardId could
|
||||
// subscribe to it and receive the whole private board. A revoke that the primary
|
||||
// data publication does not honour is not a revoke.
|
||||
//
|
||||
// So there is one builder now, and both callers use it. A share entry counts
|
||||
// only while it is active, everywhere.
|
||||
|
||||
/**
|
||||
* @param {object} args
|
||||
* @param {string} [args.userId] the subscriber; omitted/falsy means anonymous
|
||||
* @param {string[]} [args.orgIds] organisations the user belongs to
|
||||
* @param {string[]} [args.teamIds] teams the user belongs to
|
||||
* @param {string[]} [args.emailDomains] the user's verified email domains (#5850)
|
||||
* @param {boolean} [args.includePublic=true] include `{ permission: 'public' }`,
|
||||
* which is not a relationship to the user at all but "anybody may open
|
||||
* this" - wanted in a board LIST, not in a search over all boards.
|
||||
* @return {object[]} the `$or` clauses, in a stable order
|
||||
*/
|
||||
function boardVisibilitySelectors({
|
||||
userId,
|
||||
orgIds = [],
|
||||
teamIds = [],
|
||||
emailDomains = [],
|
||||
includePublic = true,
|
||||
} = {}) {
|
||||
const selectors = [];
|
||||
|
||||
if (includePublic) {
|
||||
selectors.push({ permission: 'public' });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return selectors;
|
||||
}
|
||||
|
||||
const clean = (values) =>
|
||||
Array.isArray(values) ? values.filter((v) => typeof v === 'string' && v) : [];
|
||||
|
||||
selectors.push({ members: { $elemMatch: { userId, isActive: true } } });
|
||||
selectors.push({
|
||||
orgs: { $elemMatch: { orgId: { $in: clean(orgIds) }, isActive: true } },
|
||||
});
|
||||
selectors.push({
|
||||
teams: { $elemMatch: { teamId: { $in: clean(teamIds) }, isActive: true } },
|
||||
});
|
||||
// #5850: domain-based board sharing — board shared with the user's email domain.
|
||||
selectors.push({
|
||||
domains: { $elemMatch: { domain: { $in: clean(emailDomains) }, isActive: true } },
|
||||
});
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
module.exports = { boardVisibilitySelectors };
|
||||
@@ -0,0 +1,346 @@
|
||||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Canary tokens — the pure half
|
||||
// (design: docs/Security/Remediation/WeKan.md §12)
|
||||
// ----------------------------------------------------------------------------
|
||||
// A CANARY is a tripwire placed at a point where somebody could TRY to override
|
||||
// permissions. Ordinary users never reach it: the code path only runs when a
|
||||
// request asks for something the rules forbid. So a canary trip is not noise -
|
||||
// it is somebody probing, and it is worth an admin's attention with WHO and
|
||||
// FROM WHERE attached.
|
||||
//
|
||||
// Three properties, and they are the whole point:
|
||||
//
|
||||
// 1. SILENT. Tripping a canary changes nothing the caller can observe. The
|
||||
// trip function returns `false` so a guard can `return tripCanary(...)` in
|
||||
// place of `return false`, and the attacker gets the same refusal, with the
|
||||
// same timing and the same message, as before the canary existed. Nothing
|
||||
// is ever returned, thrown, delayed or logged back to them.
|
||||
// 2. BOUNDED. A canary sits on a path an attacker controls and can hit in a
|
||||
// loop. Writing one database row per attempt is a denial-of-service the
|
||||
// attacker gets for free - CPU on every insert, and a table that grows
|
||||
// until the disk is full. So the FIRST trip in a window is recorded and the
|
||||
// rest are COUNTED, then flushed as a single summary event when the window
|
||||
// closes. The counter map itself is capped, so memory is bounded too.
|
||||
// 3. ATTRIBUTED. Every trip carries the actor - userId, username and client IP
|
||||
// address - so Admin Panel → Problems → Security answers "who tried this,
|
||||
// and from where", not just "something happened".
|
||||
//
|
||||
// This file is pure and dependency-free (no Meteor, no database, no clock of its
|
||||
// own), so it is unit tested with plain Node: tests/canaryTokens.test.cjs. The
|
||||
// runtime half - resolving the actor, applying this limiter, writing the event -
|
||||
// is server/lib/canary.js.
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------- the catalog
|
||||
//
|
||||
// One entry per tripwire. `key` is the existing securityCategories key, so a
|
||||
// canary's category, *Bleed name, severity and CWE come from the SAME catalog
|
||||
// the other guards use and cannot drift from it (models/lib/securityCategories.js).
|
||||
//
|
||||
// `what` is the sentence an admin reads. It says what was ATTEMPTED, in the
|
||||
// terms of the feature, because "authz.board blocked" is not something anyone
|
||||
// can act on.
|
||||
const CANARIES = {
|
||||
'card.cross-board-move': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to move a card into a board they cannot write to',
|
||||
},
|
||||
'card.invisible-parent': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to set a card parent on a board they cannot see',
|
||||
},
|
||||
'list.cross-board-move': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to move a list into a board they cannot write to',
|
||||
},
|
||||
'swimlane.cross-board-move': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to move a swimlane into a board they cannot write to',
|
||||
},
|
||||
'checklist.cross-board-move': {
|
||||
key: 'authz.checklist',
|
||||
what: 'tried to move a checklist into a board they cannot write to',
|
||||
},
|
||||
'checklist-item.cross-board-move': {
|
||||
key: 'authz.checklist',
|
||||
what: 'tried to move a checklist item into a board they cannot write to',
|
||||
},
|
||||
'attachment.version-path': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write the on-disk path of an attachment',
|
||||
},
|
||||
'avatar.version-path': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write the on-disk path of an avatar',
|
||||
},
|
||||
'attachment.restricted-field': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write a restricted attachment field',
|
||||
},
|
||||
'avatar.restricted-field': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write a restricted avatar field',
|
||||
},
|
||||
'avatar.not-owner': {
|
||||
key: 'authz.file-path',
|
||||
what: "tried to change another user's avatar",
|
||||
},
|
||||
'comment.foreign-delete': {
|
||||
key: 'authz.comment',
|
||||
what: "tried to delete another user's comment over the REST API",
|
||||
},
|
||||
'reaction.foreign': {
|
||||
key: 'authz.comment',
|
||||
what: "tried to add or remove another user's comment reaction",
|
||||
},
|
||||
'export.path-outside-storage': {
|
||||
key: 'authz.file-path',
|
||||
what: 'a stored file path pointed outside the storage root and was not read',
|
||||
},
|
||||
'card.vote-field': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to write a vote field directly instead of through the method',
|
||||
},
|
||||
'card.poker-field': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to write a poker field directly instead of through the method',
|
||||
},
|
||||
'database.canary': {
|
||||
key: 'authz.database',
|
||||
what: 'the database refused an operation that WeKan never issues',
|
||||
},
|
||||
|
||||
// --- Injection ------------------------------------------------------------
|
||||
// WeKan builds no SQL, so a SQL canary can only come from the database's own
|
||||
// last-look guard (docs/Security/Remediation/FerretDB.md §3b). NoSQL is the
|
||||
// one WeKan can see for itself: a selector that carries an execution
|
||||
// operator, or an operator object where the value a user typed belongs.
|
||||
'injection.nosql-selector': {
|
||||
key: 'injection.nosql',
|
||||
what: 'sent a query carrying a database-side execution operator',
|
||||
},
|
||||
'injection.nosql-operator': {
|
||||
key: 'injection.nosql',
|
||||
what: 'sent a query operator where a plain value was expected',
|
||||
},
|
||||
'injection.sql-statement': {
|
||||
key: 'injection.sql',
|
||||
what: 'the database refused a statement that looked like injected SQL',
|
||||
},
|
||||
|
||||
// --- Sanitization ---------------------------------------------------------
|
||||
// Sanitizing is routine - a filename gets trimmed, a document gets its
|
||||
// formatting normalised - and routine is not worth an admin's attention. What
|
||||
// IS worth it is sanitization that removed something DANGEROUS: markup that
|
||||
// would have executed, a path that would have escaped its directory, a type
|
||||
// that lied about itself. Only those trip.
|
||||
'sanitize.dangerous-filename': {
|
||||
key: 'file.name',
|
||||
what: 'uploaded a filename carrying an exploit pattern',
|
||||
},
|
||||
'sanitize.path-traversal': {
|
||||
key: 'file.name',
|
||||
what: 'used a file path that tried to escape its directory',
|
||||
},
|
||||
'sanitize.dangerous-content': {
|
||||
key: 'file.content',
|
||||
what: 'uploaded a file whose content had to have active markup removed',
|
||||
},
|
||||
'sanitize.dangerous-text': {
|
||||
key: 'xss.input',
|
||||
what: 'submitted text that had to have active markup removed',
|
||||
},
|
||||
|
||||
// --- Other common attacks -------------------------------------------------
|
||||
'spoof.forwarded-header': {
|
||||
key: 'spoofing.xff',
|
||||
what: 'sent a forwarded-for header that is not trusted here',
|
||||
},
|
||||
'brute.login-lockout': {
|
||||
key: 'brute.login',
|
||||
what: 'was locked out after repeated failed logins',
|
||||
},
|
||||
};
|
||||
|
||||
// The id used when a caller passes something not in the catalog. A canary with
|
||||
// an unknown id must still be RECORDED - losing the event would be the worst
|
||||
// possible failure mode for a tripwire - it is simply recorded generically.
|
||||
const UNKNOWN_CANARY = {
|
||||
key: 'authz.canary',
|
||||
what: 'tripped a canary',
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a canary. Never throws and never returns undefined.
|
||||
* @param {string} id
|
||||
* @return {{key: string, what: string, id: string, known: boolean}}
|
||||
*/
|
||||
function canaryFor(id) {
|
||||
const entry = (typeof id === 'string' && CANARIES[id]) || null;
|
||||
return {
|
||||
id: typeof id === 'string' && id ? id : 'unknown',
|
||||
key: (entry || UNKNOWN_CANARY).key,
|
||||
what: (entry || UNKNOWN_CANARY).what,
|
||||
known: !!entry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every catalogued canary id, for the tests and the docs. */
|
||||
function canaryIds() {
|
||||
return Object.keys(CANARIES).sort();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the rate limit
|
||||
|
||||
const DEFAULTS = {
|
||||
// How long one (canary, actor) pair is aggregated for. The first trip in a
|
||||
// window is recorded immediately - an admin should see a probe promptly - and
|
||||
// everything else in the window is counted.
|
||||
windowMs: 60 * 1000,
|
||||
// How many (canary, actor) pairs are tracked at once. An attacker can vary the
|
||||
// actor (a fresh IP per request over a botnet), so this map is the thing that
|
||||
// would grow without a cap. At the cap the OLDEST entry is dropped: losing the
|
||||
// count of an old, already-reported probe is much cheaper than unbounded
|
||||
// memory, and the first trip of every pair was recorded regardless.
|
||||
maxTracked: 5000,
|
||||
// A hard ceiling on how many events one pair can produce, however long the
|
||||
// attack runs: after this many summaries the pair keeps counting but stops
|
||||
// writing until it goes quiet for a window. Prevents a slow-but-endless probe
|
||||
// from writing one row a minute forever.
|
||||
maxEventsPerPair: 60,
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides, for each trip, whether to WRITE an event now and what count it
|
||||
* carries. Pure: the clock is passed in, so the tests drive time directly.
|
||||
*
|
||||
* The contract:
|
||||
* - the first trip of a (canary, actor) pair writes immediately, count 1;
|
||||
* - further trips inside the window write nothing and are counted;
|
||||
* - the first trip AFTER the window closes writes a summary carrying the
|
||||
* suppressed count, and opens a new window;
|
||||
* - a pair that has already written `maxEventsPerPair` summaries stops
|
||||
* writing until it goes quiet for a whole window;
|
||||
* - the tracking map never exceeds `maxTracked` entries.
|
||||
*/
|
||||
class CanaryRateLimiter {
|
||||
constructor(options = {}) {
|
||||
this.windowMs = options.windowMs || DEFAULTS.windowMs;
|
||||
this.maxTracked = options.maxTracked || DEFAULTS.maxTracked;
|
||||
this.maxEventsPerPair = options.maxEventsPerPair || DEFAULTS.maxEventsPerPair;
|
||||
// Insertion-ordered, which is what makes the oldest entry cheap to evict.
|
||||
this._pairs = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pairKey identifies (canary, actor)
|
||||
* @param {number} now epoch ms
|
||||
* @return {{write: boolean, count: number, suppressed: number}}
|
||||
* `write` - record an event now. `count` - how many trips it stands for.
|
||||
* `suppressed` - how many of those were not individually recorded.
|
||||
*/
|
||||
consider(pairKey, now) {
|
||||
const key = typeof pairKey === 'string' && pairKey ? pairKey : 'unknown';
|
||||
const at = typeof now === 'number' && isFinite(now) ? now : 0;
|
||||
const existing = this._pairs.get(key);
|
||||
|
||||
if (!existing) {
|
||||
this._evictIfFull();
|
||||
this._pairs.set(key, { windowStart: at, seen: 1, written: 1, lastSeen: at });
|
||||
return { write: true, count: 1, suppressed: 0 };
|
||||
}
|
||||
|
||||
// Re-insert so the map stays ordered by RECENCY, not by first sighting;
|
||||
// otherwise a long-running attacker would be evicted before a one-off probe.
|
||||
this._pairs.delete(key);
|
||||
this._pairs.set(key, existing);
|
||||
existing.lastSeen = at;
|
||||
|
||||
const windowOpen = at - existing.windowStart < this.windowMs;
|
||||
if (windowOpen) {
|
||||
existing.seen += 1;
|
||||
return { write: false, count: existing.seen, suppressed: existing.seen - 1 };
|
||||
}
|
||||
|
||||
// The window closed. Flush what it accumulated, then start a new one.
|
||||
const suppressed = Math.max(0, existing.seen - 1);
|
||||
existing.windowStart = at;
|
||||
const total = existing.seen + 1;
|
||||
existing.seen = 1;
|
||||
|
||||
if (existing.written >= this.maxEventsPerPair) {
|
||||
// Still counting, no longer writing: an endless probe must not be able to
|
||||
// write forever, and by now the admin has 60 events saying so.
|
||||
return { write: false, count: total, suppressed };
|
||||
}
|
||||
|
||||
existing.written += 1;
|
||||
return { write: true, count: total, suppressed };
|
||||
}
|
||||
|
||||
/** Drop pairs whose window closed long ago; called by the runtime's sweeper. */
|
||||
sweep(now, idleMs) {
|
||||
const cutoff = (typeof now === 'number' ? now : 0) - (idleMs || this.windowMs * 10);
|
||||
let dropped = 0;
|
||||
for (const [key, entry] of this._pairs) {
|
||||
if (entry.lastSeen < cutoff) {
|
||||
this._pairs.delete(key);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this._pairs.size;
|
||||
}
|
||||
|
||||
_evictIfFull() {
|
||||
while (this._pairs.size >= this.maxTracked) {
|
||||
const oldest = this._pairs.keys().next();
|
||||
if (oldest.done) return;
|
||||
this._pairs.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key one canary + one actor are aggregated under. An unauthenticated actor
|
||||
* is keyed by IP alone; a logged-in one by userId, so switching IP mid-attack
|
||||
* does not reset their counter.
|
||||
* @param {string} canaryId
|
||||
* @param {{userId?: string, ip?: string}} actor
|
||||
* @return {string}
|
||||
*/
|
||||
function canaryPairKey(canaryId, actor = {}) {
|
||||
const id = typeof canaryId === 'string' && canaryId ? canaryId : 'unknown';
|
||||
const userId = actor && typeof actor.userId === 'string' && actor.userId ? actor.userId : '';
|
||||
const ip = actor && typeof actor.ip === 'string' && actor.ip ? actor.ip : 'unknown-ip';
|
||||
return userId ? `${id} | ||||