Compare commits

...
382 Commits
Author SHA1 Message Date
github-actions[bot] f602b9459f Bump versions for v9.95 2026-07-15 12:58:19 +00:00
Lauri Ojansivu b3a9b90f22 Updated ChangeLog. 2026-07-15 15:57:04 +03:00
Lauri Ojansivu 14e6851f01 release-all.yml: publish the snap to the stable channel too
Both snap jobs (snap-native and snap-launchpad) pushed only to candidate, beta
and edge, and stable had to be released by hand afterwards. Publish to
stable,candidate,beta,edge instead. base: core24 allows stable and candidate.

Thanks to xet7.
2026-07-15 15:57:04 +03:00
Lauri Ojansivu ab1c636e71 Snap: resume an interrupted MongoDB to FerretDB migration instead of redoing it
A big migration can run for 5 hours, so being interrupted by a snap refresh,
`snap stop` or a reboot is normal - but it was treated as a failure, and every
interruption threw away all of the work.

Distinguish interrupted from failed. snapd's SIGTERM killed the importer, bash
resumed at the line after the importer call, read the signal exit code (143) as
"the migration failed", and fail_and_run_mongodb deleted the partial FerretDB
SQLite and set migrate=off. Now a SIGTERM/SIGINT trap (and an importer exit code
>= 128, since snapd signals every process in the service cgroup) keeps the
partial database and the checkpoint, leaves auto-migration on, and hands back to
mongodb-control so WeKan keeps serving from MongoDB until the next start resumes.
Both importers flush their checkpoint on the signal and exit 128+signo.

Checkpoint files, not just collections. Only fully-copied collections were
checkpointed; the file phase started over. Worse, the destination path was picked
with an "append _1, _2, ... while the file exists" loop, so every already-
extracted attachment was written a second time under a new name, orphaning the
first copy - a resumed migration silently needed twice the disk the up-front
space check budgeted for. Files are now checkpointed individually (recorded only
once the bytes are on disk AND the record points at them, and re-verified by size
on resume, so a half-written file is never mistaken for a finished one) and
skipped when resuming. The path is now deterministic: idHex is the record's _id,
so two records never collide and an existing file there can only be this record's
own partial extraction, which is meant to be overwritten.

Discard the checkpoint together with the SQLite it describes. The checkpoint
lives in $SNAP_COMMON, not in the SQLite dir that discard_partial_ferretdb wipes,
so it survived. The next migration then trusted it, skipped every "already
migrated" collection, copied only the rest into the now-empty database, and
reported success - leaving the snap serving a FerretDB missing most of its data.

The MongoDB 3 importer had no checkpoint at all and now resumes collections and
files the same way. The source MongoDB data is never touched on any of these
paths.

Thanks to markusst1982 and xet7.
2026-07-15 15:57:04 +03:00
Lauri Ojansivu 4e1e661d99 Fix WeKan not working after FerretDB migration: resume login used a positional projection
Meteor's accounts-base "resume" login handler projects a single array element
with the positional operator:

  Meteor.users.findOneAsync(
    { 'services.resume.loginTokens.hashedToken': hashedToken },
    { fields: { 'services.resume.loginTokens.$': 1 } })

FerretDB rejects that find with "Executor error during find command :: caused
by :: positional operator '.$' couldn't find a matching element in the array"
(code 51246).

Resume is how every already-logged-in browser re-authenticates on page load and
on every DDP reconnect, so the throw logged users out, the client retried the
connection, and the retry loop pinned the FerretDB CPU - the board looked broken
right after a migration that had just taken hours.

Replace the handler with one that projects the whole loginTokens array and picks
the matching token in JS. Upstream already does exactly this in its own $or
fallback query ("Cannot use ...loginTokens.$ positional operator with $or
query"), so the array is known to be small enough to fetch, and behaviour is
otherwise unchanged - including looking up the hashed token on its own first so
the unhashed token is not sent to the database unless it has to be.

Closes #6457

Thanks to markusst1982 and xet7.
2026-07-15 15:57:04 +03:00
github-actions[bot] a84a88794f Bump versions for v9.94 2026-07-15 06:09:00 +00:00
Lauri Ojansivu a5d206e23d Updated ChangeLog. 2026-07-15 09:07:36 +03:00
Lauri Ojansivu aadd653b15 CHANGELOG: new Upcoming section above v9.93 with the post-9.93 fixes
9.93 is released, so move the two commits made after it into a fresh
"# Upcoming WeKan ® release" section above v9.93, organized like the previous releases:

  Features: database setting authoritative + snap run wekan.database mongodb|ferretdb +
            no-downtime handling of a failed migration.
  Bugs:     migration skips dotted (GridFS/CollectionFS/system) collections that FerretDB
            rejects, and cleans up a partial SQLite from a failed migration.

No entry text changed; only relocated.
2026-07-15 09:01:37 +03:00
Lauri Ojansivu 7d271413bf Snap: make database setting authoritative + wekan.database command + no-downtime on failure
Problem: the control scripts force-switched to FerretDB whenever a *.sqlite file existed, so
`snap set wekan database=mongodb` did not stick and a failed migration caused downtime with no
way to keep WeKan running on MongoDB while fixing it.

- The `database` snap setting is now AUTHORITATIVE. Removed the "FerretDB SQLite present ->
  force ferretdb / disable mongodb" overrides from wekan-control, ferretdb-control and
  mongodb-control. wekan-control keeps only the safety guard (never start an EMPTY FerretDB
  while MongoDB has data).

- New `snap run wekan.database mongodb|ferretdb` command (bin/wekan-database): switch which
  database WeKan runs on. `mongodb` also pauses auto-migration (migrate=off) so it does not
  switch back; `ferretdb` requires a non-empty SQLite first.

- Auto-migration can be paused: migration-pending now also returns "not pending" when
  `snap get migrate` == off. A migration that FAILS now runs fail_and_run_mongodb: delete the
  partial SQLite, set migrate=off, and exec mongodb-control so WeKan keeps working on MongoDB
  (no downtime, no retry loop). fall_back_to_mongodb likewise sets migrate=off instead of the
  done marker. `snap run wekan.migrate` re-enables migrate and retries.

- finish_success now restarts wekan.wekan so it reconnects to FerretDB after a successful
  migration instead of waiting for the now-stopped MongoDB.

Registered the `database` app in snapcraft.yaml and snapcraft-core26.yaml (also added the
previously-missing migrate/maintenance apps to core26).

Thanks to xet7.
2026-07-15 08:58:36 +03:00
Lauri Ojansivu 73ad55f696 Snap migration: skip dotted collections (FerretDB rejects them) + clean partial SQLite
Two bugs surfaced when the MongoDB 7 -> FerretDB migration finally connected (after the
db-eval fix):

1. FerretDB v1 rejects collection names containing a dot:
     invalid key: "attachments.chunks" (key must not contain '.' sign)
   Both importers tried to copy GridFS internals (attachments.chunks/.files, avatars.*,
   cfs_gridfs.*) and CollectionFS cfs.<bucket>.filerecord collections as TEXT, aborting the
   migration. Now the text phase skips ANY dotted collection: none of WeKan's real data
   collections contain a dot, and the dotted ones are GridFS internals (extracted in the file
   phase), CollectionFS filerecords (now turned into bare attachments/avatars records in the
   file phase via replaceOne upsert:true) or system.* collections.

2. The failed migration had already copied some text collections, leaving a NON-EMPTY but
   incomplete files/db/wekan.sqlite. ferretdb-has-data then saw it as "migrated", so
   mongodb-control disabled MongoDB and the snap tried to serve a half-migrated FerretDB. On
   any failure migration-control now runs discard_partial_ferretdb: stop the temp target
   FerretDB and delete the partial SQLite, so the snap cleanly keeps MongoDB and retries. The
   finish_success switch is also gated on it (`rc -eq 0 && finish_success`).

Source MongoDB data is never modified or deleted.

Thanks to xet7.
2026-07-15 08:58:36 +03:00
github-actions[bot] 1a4283e598 Bump versions for v9.93 2026-07-15 04:47:18 +00:00
Lauri Ojansivu 8ec4855d6c Updated ChangeLog. 2026-07-15 07:46:12 +03:00
Lauri Ojansivu 1088f31a0d Updated translations 2026-07-15 07:44:47 +03:00
Lauri Ojansivu c032c24065 CHANGELOG: new Upcoming section above v9.92 with the post-9.92 fixes
9.92 is released, so move everything committed after it into a fresh
"# Upcoming WeKan ® release" section above v9.92, organized by topic like the
previous releases (features, then bugs):

  Features: wekan.migrate command; maintenance mode + maintenance page.
  Bugs: db-eval driver resolution (ESM ignores NODE_PATH); decide FerretDB vs
        MongoDB from a non-empty SQLite database (not the marker); mongod-7
        readiness probe fast-fail.

v9.92 is restored to what it shipped, including its "migration-success marker is
now authoritative" bug entry (which the Upcoming "presence of a FerretDB SQLite
database" entry supersedes). No entry text changed; only relocated.
2026-07-15 07:41:11 +03:00
Lauri Ojansivu 88e692ef43 Snap migration: fast-fail the mongod-7 readiness probe when the temp mongod has died
ready_via_node waits up to 45s for the temporary source mongod to accept connections, to
allow a large MongoDB 6/7 database time to replay its journal (#6454). Add a liveness check:
if the mongod process (SRC_PIDFILE) has already exited - e.g. mongod 7 cannot open old
MongoDB 3.x data - stop waiting immediately and drop to the MongoDB 3.2 reader, instead of
pinging a dead process for the full timeout. Matters across many unattended 6.09 -> 9.x
upgrades. (With --fork, start_mongod usually already returns non-zero when mongod cannot open
the data; this also covers a mongod that started then died mid-probe.)

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu b9a9bf1593 Snap: require a NON-EMPTY FerretDB SQLite before treating the migration as done
For unattended upgrades at scale (6.09/MongoDB 3.2 -> 9.x/FerretDB across many servers), the
"did the migration succeed?" test must not be fooled by a 0-byte stub file.

- New shared helper snap-src/bin/ferretdb-has-data: exit 0 only if a *.sqlite file bigger than
  0 bytes exists in files/db. Used by mongodb-control, ferretdb-control and wekan-control in
  place of the previous "*.sqlite exists" glob, so the DB decision is based on a real,
  non-empty FerretDB database. (Deliberately does NOT require the -wal file, which SQLite may
  checkpoint away when FerretDB is stopped - that would cause false negatives at startup.)

- migration-control finish_success now verifies the non-empty SQLite database exists (and logs
  the WAL/shm sidecars) BEFORE switching the snap to FerretDB. If the importer returns success
  but wrote no data, it does not switch or set the done marker - MongoDB data is kept and the
  migration retries on the next start.

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu 8db071f94c Snap: fix db-eval driver resolution (ESM ignores NODE_PATH) - the root cause
db-eval is the Node helper the snap uses instead of mongosh to check whether MongoDB/FerretDB
is up, elect the replica-set primary, and probe mongod-7 readiness during migration. It did
`import { MongoClient } from 'mongodb'` while the wrapper set NODE_PATH to the bundled driver
-- but Node's ESM loader IGNORES NODE_PATH (only the CommonJS loader honors it). So the import
resolved nothing and db-eval exited BEFORE opening any connection, making every
ping/primary/rs-* check fail.

This single bug explains the whole family of failures seen this week:
  - WeKan looping "MongoDB not ready yet, retrying..." / "FerretDB not ready yet..." forever
    even though the database was running and listening;
  - replica-set initialisation failing;
  - the MongoDB -> FerretDB migration falling back to MongoDB 3.2 because the mongod-7
    readiness probe never connected (mongod 7 opened the data fine, but db-eval couldn't reach
    it), and 3.2 cannot read WiredTiger-7 files -> no FerretDB SQLite produced.

Proof from the migration source mongod log: mongod 7 reached "Waiting for connections" with
ZERO "Connection accepted" before the readiness timeout killed it; the only connection in the
file was the native `mongo` shell to mongod 3.2.

Fix: resolve the driver with createRequire (CommonJS resolution honors NODE_PATH and the
bundle layout), anchored inside the modern bundle (npm-mongo v6 first). Applied the same fix
to the standalone migrate-schema-v843.mjs and migrate-gridfs-to-fs.mjs tools.

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu 0ebf34aedc Snap: add wekan.migrate + wekan.maintenance commands and a maintenance page
wekan.migrate (bin/wekan-force-migrate): force a fresh MongoDB -> FerretDB migration,
ignoring the database setting and the migration marker. Removes any partial FerretDB SQLite +
resume checkpoint, sets database=mongodb, and starts wekan.mongodb so migration-control
re-reads the existing MongoDB (3 or 7) data and migrates again. Source MongoDB data untouched.

wekan.maintenance on|off (bin/wekan-maintenance): toggles a $SNAP_COMMON/.wekan-maintenance
marker. While present, mongodb-control / ferretdb-control do NOT auto-migrate or auto-disable,
so `snap start wekan.mongodb` or `snap start wekan.ferretdb` stays up and you can reach the
data over the MongoDB wire protocol on port 27019 (one at a time - they share the port).

Maintenance page (bin/wekan-maintenance-page.mjs): in maintenance mode wekan-control execs
this instead of WeKan; it serves an "under maintenance" page (HTTP 503) on the web port for
ALL URLs. It shows the Admin Panel product name if set (not "WeKan"), read from
$SNAP_COMMON/.productname.txt, which is cached while a database is running by wekan-control
(new db-eval `product-name` command) and by both migration importers - so it is available even
when both databases are stopped.

Registered `migrate` and `maintenance` apps in snapcraft.yaml.

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu 3f42d4d3f0 Snap: decide FerretDB vs MongoDB from SQLite data, not the migration marker
Regression fix. An earlier change made mongodb-control/ferretdb-control/wekan-control key
the "use FerretDB / disable MongoDB" decision off the .migration-to-ferretdb-done marker.
But migration-control ALSO writes that marker when the migration falls back (data
unreadable, tools missing) or when there is nothing to migrate - producing NO FerretDB
data. So on a server whose MongoDB 7 -> FerretDB migration failed:
  - mongodb-control saw the marker -> "migration already finished; disabling mongodb
    service" -> disabled MongoDB;
  - FerretDB SQLite (files/db) was EMPTY;
  - database=mongodb, so wekan-control waited on MongoDB -> "MongoDB not ready yet,
    retrying in 5 seconds..." forever, and wekan.mongodb refused to stay up.

Now all three scripts decide from ACTUAL DATA ON DISK: MongoDB is disabled / FerretDB is
forced ONLY when a *.sqlite database exists in files/db. An empty files/db means the
migration did not succeed, so mongod starts normally and WeKan keeps working (and the
migration can be retried). The marker still prevents re-running a finished migration.

Thanks to xet7.
2026-07-15 07:32:57 +03:00
github-actions[bot] 3e6a11d7f0 Bump versions for v9.92 2026-07-15 02:51:17 +00:00
Lauri Ojansivu 3bd9da70d4 Updated ChangeLog. 2026-07-15 05:50:09 +03:00
Lauri Ojansivu 8e47eca72e CHANGELOG: note disk-space guard is verified to work in snap confinement
Upcoming section: document that statfs is in snapd's default seccomp allow-list, so the
MongoDB -> FerretDB migration's free-space guard is active on Snap; quotactl is not used and
its absence is covered by the ENOSPC/EDQUOT write-failure fallback.
2026-07-15 05:46:05 +03:00
Lauri Ojansivu 2c895107a0 Snap migration: cite snapd seccomp allow-list in the disk-space guard comments
Document that statfs (and statfs64/fstatfs/fstatfs64/statvfs/fstatvfs) are in snapd's
default seccomp allow-list (snapcore/snapd interfaces/seccomp/template.go), so free-space
checks WORK inside a snap (which also has no per-snap disk quota by default, so statfs shows
real usable space). quotactl is NOT in the allow-list, but it is never called; a snapd
storage-quota group is still caught by the ENOSPC/EDQUOT write-failure path. Only Sandstorm
grains genuinely lack statfs (FUSE unimplemented, quotactl blocked). No behaviour change.
2026-07-15 05:44:32 +03:00
Lauri Ojansivu 22a4f419ce Snap: harden MongoDB 6/7 -> FerretDB importer + never start an empty FerretDB
migrate-mongodb-to-ferretdb.mjs (the modern MongoDB 6/7 driver importer that
migration-control runs, packaged from releases/):
  - Resolve the mongodb driver by probing the WeKan bundle with createRequire anchors and
    USE WHAT WORKS: prefer a v6+ driver that exposes GridFSBucket and speaks OP_MSG, since
    an older bundled v2 driver's OP_QUERY is rejected by FerretDB ("Unsupported OP_QUERY
    command: update"). A plain `import from 'mongodb'` could pick up that v2 driver. Fail
    fast with a clear message if no usable driver is found.
  - Bring it to parity with migrate-mongo3-to-ferretdb.mjs's data-safety and UX: measure
    total attachment/avatar bytes up front and STOP before extracting if they will not fit;
    if space runs out mid-run, stop + delete the partially migrated files + report how much
    more disk is needed (a full disk can corrupt the still-running source MongoDB);
    detect ENOSPC/EDQUOT where free space is not measurable (Sandstorm); show a live
    per-file progress bar; and translate the dashboard into the browser's language.
    Existing checkpoint/resume, schema upgrade, orphan fixing, compaction and index
    creation are unchanged.

wekan-control:
  - On startup, decide the database from what data actually exists on disk (FerretDB SQLite
    at $SNAP_COMMON/files/db, MongoDB WiredTiger at $SNAP_COMMON), not the `database` setting
    alone. If ferretdb is selected but its SQLite is empty while MongoDB still has data,
    revert to database=mongodb instead of starting an EMPTY FerretDB that would look like all
    data was lost, and tell the user to run the migration first.

Thanks to xet7.
2026-07-15 05:35:33 +03:00
Lauri Ojansivu f2a417047b Updated ChangeLog. 2026-07-15 05:30:08 +03:00
Lauri Ojansivu 48e47e6687 Snap: RAM-aware WiredTiger cache so MongoDB starts on < 34 GB RAM boxes (#6454)
Symptom: WeKan never started, looping "MongoDB not ready yet, retrying in 5 seconds..."
forever, even though mongod appeared to be running.

Cause: mongodb-control started mongod with a HARDCODED --wiredTigerCacheSizeGB 32. On any
server with less than ~34 GB RAM, mongod was OOM-killed seconds after startup. That
included the temporary mongod started to initialise the replica set: it died before
db-eval could reach it (log showed "child process started successfully" then 30 s of
failed pings), so the replica set was never initiated. The final mongod then ran with
--replSet rs0 but no config, no PRIMARY was elected, and wekan-control (which needs the
replica-set primary for Meteor change streams) waited forever.

Fixes in mongodb-control:
  - WiredTiger cache is now RAM-aware: ~50% of (MemTotal - 1 GB), the same formula mongod
    uses for its own default, capped at 32 GB, min 1 GB. Override with
    MONGODB_WIREDTIGER_CACHE_GB. Applied to both the temp replica-set-check mongod and the
    final mongod.
  - Temporary-mongod readiness wait raised 30 s -> 90 s so a large database that needs
    longer to replay its journal still gets its replica set initialised/reconfigured.

Fix in migration-control:
  - The temporary source mongod started to READ the data during a MongoDB -> FerretDB
    migration now uses a conservative RAM-aware cache (1/4 of (MemTotal - 1 GB), cap 8 GB,
    override MIGRATION_MONGOD_CACHE_GB), leaving room for the target FerretDB and the Node
    importer that run alongside it. Applied only to the mongod 7 (WiredTiger-only) read so
    the flag can never clash with a legacy MMAPv1 MongoDB 3.x dbpath.

Thanks to xet7.
2026-07-15 05:23:16 +03:00
Lauri Ojansivu 22804fdf38 Snap: make the MongoDB->FerretDB migration-done marker authoritative
Symptom: on an already-migrated install, `sudo snap start --enable wekan.ferretdb`
started FerretDB and then it immediately stopped itself, logging:
  "database is 'mongodb', not 'ferretdb'. Disabling ferretdb service."

Cause: every DB service keyed its behaviour off the `database` snap setting alone. If
that setting was never flipped to ferretdb (e.g. the migration's INTERNAL `snapctl set`
did not re-run the configure hook), ferretdb-control read `database=mongodb` and disabled
itself, so FerretDB could not be started by hand without first running
`snap set wekan database=ferretdb`.

Fix: the marker $SNAP_COMMON/.migration-to-ferretdb-done is now authoritative.
  - ferretdb-control: if the marker exists, repair database=ferretdb and keep running
    instead of self-disabling.
  - mongodb-control: if the marker exists, repair database=ferretdb and disable mongodb,
    BEFORE the migration-pending check so a finished migration is never re-attempted.
  - wekan-control: already forces ferretdb on the marker (previous commit).

Immediate recovery on an already-migrated install running an older release:
  sudo snap set wekan database=ferretdb

Thanks to xet7.
2026-07-15 04:58:44 +03:00
Lauri Ojansivu 87783b6f15 Snap: on startup, start the configured database instead of waiting forever
wekan-control (the wekan.wekan service) only WAITED for the database DATABASE points at
and never started a DB service, so if both wekan.ferretdb and wekan.mongodb were
stopped/disabled WeKan hung on "MongoDB not ready yet, retrying..." forever.

Now, on start, wekan-control:
  1. If the migration-success marker $SNAP_COMMON/.migration-to-ferretdb-done exists,
     forces database=ferretdb, then enables + starts wekan.ferretdb and stops + disables
     wekan.mongodb. This self-heals the case where the migration's INTERNAL `snapctl set`
     never re-ran the configure hook that flips the services.
  2. Before waiting, enables + starts whichever DB service is configured
     (snapctl start --enable = snap enable + snap start), so a merely-stopped DB is
     brought back up instead of WeKan waiting forever.

Uses the snap instance name so parallel installs target their own services.

Thanks to xet7.
2026-07-15 04:49:16 +03:00
Lauri Ojansivu 856743baef Updated ChangeLog. 2026-07-15 04:40:55 +03:00
Lauri Ojansivu 8a958650be CHANGELOG: new Upcoming section above v9.91 with the FerretDB post-migration fix
v9.91 was cut as a release, so move the Snap FerretDB service-flip fix out of it into a
fresh "# Upcoming WeKan ® release" section above v9.91. No entry text changed; only
relocated.
2026-07-15 04:39:29 +03:00
Lauri Ojansivu 3cc9dccb3c Snap: start FerretDB after migration (internal snapctl set skips the configure hook)
After a MongoDB -> FerretDB migration, WeKan hung on "MongoDB not ready yet, retrying..."
because no database was running: wekan.ferretdb stayed disabled and wekan.mongodb stayed
enabled-but-stopped.

Root cause: the wekan.ferretdb service disables itself while database=mongodb, and the
service flip that re-enables it lives in snap/hooks/configure. migration-control switches
the backend with an INTERNAL `snapctl set database=ferretdb`, but an internal snapctl set
does not re-run the configure hook, so the flip never happened.

Fix: finish_success now does the flip itself (mirroring the configure hook's ferretdb
branch): enable+start+restart wekan.ferretdb, then stop+disable wekan.mongodb, using the
snap instance name so parallel installs target their own services. FerretDB is started
before MongoDB is disabled so a DB is up first.

Immediate recovery on an already-migrated install:
  sudo snap start --enable wekan.ferretdb && sudo snap stop --disable wekan.mongodb
2026-07-15 04:38:52 +03:00
github-actions[bot] 6744fca2b3 Bump versions for v9.91 2026-07-15 00:25:43 +00:00
Lauri Ojansivu b9b6a55be6 Updated translations. 2026-07-15 03:24:36 +03:00
Lauri Ojansivu e7ec28b7a6 Updated ChangeLog. 2026-07-15 03:23:21 +03:00
Lauri Ojansivu db41dad70f CHANGELOG: add Upcoming section above v9.90; move post-release entries into it
v9.90 (2026-07-15) was already cut, but later work had been appended to it. Create a new
"# Upcoming WeKan ® release" section above v9.90 and move into it everything committed
after the v9.90 cut:
  - new features: Snap/Sandstorm migration dashboard (per-file progress + disk guard)
  - updates: i18n 10 more languages, native-name fixes, pull English-regression report
  - tests: dev server also frees the MongoDB port
  - bugs: MongoDB 3->FerretDB EJSON + stay-on-page; "Always show all code as plain text"
          reactivity race; i18n browser-language -> file mapping fixes
v9.90 is restored to its cut contents (MimeBleed CRITICAL + the original features/updates/
tests/bugs). No entry text changed; only relocated.
2026-07-15 03:20:51 +03:00
Lauri Ojansivu dc6839aef6 CHANGELOG: record the i18n language-detection fixes and additions
Document the Upcoming i18n work: the ja_JP->ja clobber fix, case/underscore-insensitive
language matching, Chinese subtag fallback + zh alias, the cmn tag typo fix (bugs), and
the 10 new selectable languages, native-name corrections, and the pull-translations
English-regression report (updates).
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 2e39d80cf5 i18n: add 10 distinct language picker entries; fix English names to native
Add languages.js entries for translation files that had none (so they were pulled from
Transifex but never selectable): ca-valencia (Català (Valencià)), en_ID/en_SG/en_TR
(English (Indonesia/Singapore/Turkey)), es_CO (Español (Colombia)), fr-FR (Français
(France)), pt_PT (Português (Portugal)), ru-UA (Русский (Украина)), tk_TM (Türkmençe
(Türkmenistan)), wuu-Hans (吴语(简体)). Skipped km_KH and ru_RU (duplicate the existing
km-KH / ru-RU entries) and zh (already aliased to zh-Hans).

Fix language names that used the English name instead of the native one:
  ace  "Acehnese"            -> "Bahsa Acèh"
  cy   "Welsh"               -> "Cymraeg"
  cy-GB"Welsh (UK)"          -> "Cymraeg (Y Deyrnas Unedig)"
  af_ZA"Afrikaans (South Africa)" -> "Afrikaans (Suid-Afrika)"
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 220d04e50d i18n: robust browser-language -> file matching (case/underscore-insensitive, subtag fallback)
Fix several browser-language -> translation-file mapping gaps:

- isLanguageSupported is now case-insensitive (requested) via a new TAPi18n.resolveTag()
  that maps any input to the canonical languages.js tag case-insensitively AND
  underscore<->hyphen-insensitively (so 'zh-hant'->'zh-Hant', 'JA-JP'->'ja-JP', browser
  'af-ZA'->legacy key 'af_ZA'). loadLanguage/setLanguage/ensureLanguageLoaded resolve
  through it, so the loaded file, the i18next code and the stored current tag all agree.
- Bare base codes with no plain-code file resolve via an alias map: 'zh' -> 'zh-Hans'
  (Chinese has only script/region files), so plain 'zh' gives Simplified, not English.
- Browser detection (client/lib/i18n.js) now strips trailing subtags progressively
  instead of jumping to the first segment: 'zh-Hans-CN' -> 'zh-Hans', 'zh-Hant-TW' ->
  'zh-Hant' (Chrome sends these), rather than -> 'zh' -> English.
- languages.js: fix the 'cmn' entry whose tag was the typo 'cnm' (and code 'cn'), which
  mismatched supportedLngs and broke selecting Mandarin; now tag/code = 'cmn'.

Adds i18n.test.js coverage for resolveTag, case-insensitive isLanguageSupported, the zh
alias, the underscore/hyphen match, and each Japanese variant loading its own file.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 4ab6b8561f i18n: give Japanese ja_JP its own file; report English regressions after pull
Two Transifex languages were writing to the same local file: .tx/config lang_map had
`ja_JP: ja`, which sent Transifex's ja_JP INTO imports/i18n/data/ja.i18n.json — the same
file the real Japanese (`ja`) uses. On `tx pull -a -f`, the emptier/untranslated ja_JP
overwrote ja.i18n.json, reverting Japanese strings to the English source. Map it to its
own file instead: `ja_JP: ja-JP` (-> ja-JP.i18n.json), matching languages.js which already
has three separate Japanese entries (ja -> ja.i18n.json, ja-JP -> ja-JP.i18n.json,
ja-Hira -> ja-HI.i18n.json). No other lang_map target collides. languages.js already loads
each to its own file, so no change needed there.

Also add releases/translations/report-english-regressions.mjs and call it from
pull-translations.sh: after pulling, it lists the language files where a previously
translated string reverted to exactly the English source (i.e. untranslated on Transifex),
so the regression is visible instead of being committed silently.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 97911f3e3e CHANGELOG: split migration dashboard features into "new features"
The MongoDB 3 -> FerretDB migration work had grown into one large bug bullet that
actually contained major features (live per-file progress bar, chunk-by-chunk streaming,
disk-space safety guard + rollback, Snap/Sandstorm/availability detection). Move those to
the "new features" section as their own bullet, and keep the EJSON-resolution fix +
stay-on-page hand-off as the bug entry (with a pointer to the feature bullet).
2026-07-15 03:14:08 +03:00
Lauri Ojansivu c3e36d4a2e Snap mongo3->FerretDB: disk guard keys off free-space availability, not platform
Previously the statfs free-space guards were gated on IS_SNAP. But a Snap container may
also be unable to report free space (statfs fails). Drive the behaviour off whether free
space is actually MEASURABLE instead:

- updateDiskFree: skip statfs only on Sandstorm (where it is known-unreliable — reports the
  host disk, not the grain quota); everywhere else ATTEMPT statfs, and on failure leave
  state.diskFree < 0 (unknown).
- ensureDiskForFile / the up-front total-size pre-check: run only when state.diskFree >= 0
  (measurable); otherwise skip and rely on an ENOSPC/EDQUOT write failure.
- Dashboard already hides the free-space figure and switches to "free space required for
  all files" whenever diskFree < 0, so a Snap-with-no-statfs degrades exactly like
  Sandstorm: try, and stop + delete + report on a write failure.

IS_SNAP is now only used for the platform log line. CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 2676e8d434 Snap mongo3->FerretDB: Snap/Sandstorm-aware disk-space guard
The statfs free-space checks only make sense on the Snap (a real filesystem whose size we
can read). A Sandstorm grain cannot see its free space or quota — its FUSE layer does not
implement statfs and quotactl is blocked (confirmed in the Sandstorm source), and statfs
on the writable /var reports the host disk, not the grain quota. So:

- Detect Snap via $SNAP (IS_SNAP) and Sandstorm via SANDSTORM / SANDSTORM_RAW_MONGO_PATH /
  METEOR_SETTINGS.public.sandstorm (IS_SANDSTORM).
- Run the statfs up-front + mid-run free-space guards ONLY on Snap.
- On Sandstorm (and anything else), just try to migrate and detect "out of space" from an
  actual ENOSPC/EDQUOT write failure (in the streaming writer and in the file-loop catches),
  which triggers the same stop + delete-migrated-files + report rollback (onDiskAbort).
- Dashboard shows the free-space figure only when known (Snap); on Sandstorm it says free
  space is unknown and phrases the requirement as "free space required for all files".

Sandstorm source used only for research (grain cannot query disk/quota); not committed.
CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu e5e9f83650 CHANGELOG: document the "Always show all code as plain text" reactivity fix
Add the missing Upcoming bug entry for the alwaysShowCodeAsText fix (the viewer read a
stale ReactiveVar and never re-rendered, so the toggle did nothing; now set inside the
mentions helper from the same reactive setting it already reads). The MongoDB 3 ->
FerretDB migration progress/disk-guard improvements were already recorded in the mongo3
migration entry.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 96428115f4 Snap mongo3->FerretDB: per-file progress, streaming, and disk-space guard
Migration dashboard + migrator improvements for the MongoDB 3 -> FerretDB path:

- Live per-file progress: the file currently being extracted shows its name, size,
  "file N of TOTAL", a percent progress bar, and whether it is an attachment or an
  avatar. File-type words use WeKan's existing translations in the viewer's browser
  language (Accept-Language), read best-effort from the bundle with an English fallback.

- RAM-safe streaming: each GridFS file is streamed chunk-by-chunk (sorted by n) straight
  to disk via a spawned mongoexport + readline, with natural back-pressure, so a multi-GB
  attachment never lands in memory. The old approach buffered the whole file through
  mongoexport's 512 MB stdout maxBuffer and failed on big files.

- Disk-space guard (a full disk can corrupt the still-running source MongoDB): the
  dashboard shows remaining free space; the migrator measures the total size of all
  attachments+avatars up front and stops immediately if the volume cannot hold them; it
  also stops mid-run if free space drops below a safety margin (default 1 GB,
  MIGRATION_MIN_FREE_BYTES). On such a stop it deletes the partially-migrated files to
  free the space back, reports how many files were migrated before stopping, and shows how
  much more disk space is required to migrate all files.

Cannot run the snap here, so this is reviewed but not executed; needs a snap migration run
to verify. CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu c6a75de076 Snap mongo3->FerretDB: fix "EJSON.parse unavailable" (wrong anchor roots)
The MongoDB 3 migrator resolves WeKan's modern bson (with EJSON) and mongodb v6 by
anchoring require() inside the Meteor bundle. It anchored the sub-paths relative to the
script, but in the snap the script runs from $SNAP/bin/ while the bundle is at
$SNAP/programs/server/... (one level up) — so every anchor resolved
$SNAP/bin/programs/server/... (nonexistent) and fell through to the ancient meteor-spk
base bson 1.x with no EJSON, giving "FATAL: EJSON.parse unavailable" and aborting the
migration.

Build candidate bundle roots from $SNAP (env) and the script's parent dirs, then try the
known modern-bundle sub-paths under each (npm-mongo's nested v6 driver first, so the
ancient v2 that FerretDB rejects with OP_QUERY is never chosen). Node's module resolution
walks up from the '_.cjs' anchor to programs/server/npm/node_modules/bson.

Also: on successful hand-off the dashboard now reloads the SAME page the user was on
(the board URL) instead of forcing All Boards, so a reload during migration keeps you in
place. The dashboard already serves the progress page for every URL. CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 1a9263326c Fix "Always show all code as plain text" not taking effect (reactivity race)
The viewer (mentions -> markdown) applies alwaysShowCodeAsText via a ReactiveVar
(Markdown.alwaysShowCodeAsText) that only a separate startup Tracker.autorun kept in
sync with the setting. But the mentions helper re-renders whenever the settings doc
changes (it reads getCurrentSetting for renderLinksAsPlainText/stripLinks), and that
re-render often runs BEFORE the bridge autorun updated the ReactiveVar — so the inner
markdown helper read the stale flag and rendered links/markdown normally (clickable),
and since mentions does not depend on that ReactiveVar it never re-rendered again.
Net effect: toggling "Always show all code as plain text" did nothing (links stayed
clickable, code stayed rendered), even after reload (same race on load).

Fix: set Markdown.alwaysShowCodeAsText inside the mentions helper, from the same
reactive getCurrentSetting() it already reads, BEFORE rendering the inner markdown
via Blaze.toHTML. The markdown helper now always reads the up-to-date flag in the same
synchronous render, so the toggle takes effect immediately in every rich-text field
(card titles, descriptions, comments, checklists). The startup autorun stays for any
direct +markdown use outside a viewer.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu d923166a96 rebuild-wekan.sh: free the MongoDB port too when stopping a dev server
kill_meteor_on_port freed the app port (3000) and rspack dev-server port (8080) but not
Meteor's bundled MongoDB on app-port+1 (3001). When the meteor parent is SIGKILLed its
mongo child is often orphaned and keeps holding 3001, so the next `meteor run` failed
with "Unexpected mongo exit code 48 ... port was closed, or was already taken". Also
free app-port+1 (which additionally clears a leftover standalone test mongod on :3001),
include it in the readiness guard/wait, and in the SIGKILL escalation. CHANGELOG noted.
2026-07-15 03:14:08 +03:00
github-actions[bot] 0b8564d8c8 Bump versions for v9.90 2026-07-14 21:10:32 +00:00
Lauri Ojansivu a1e868384e Updated ChangeLog. 2026-07-15 00:09:04 +03:00
Lauri Ojansivu 1483c271ff rebuild-wekan.bat: parity — run :3000 test server from precompiled .build\bundle
Bring the Windows batch runner to parity with rebuild-wekan.sh: the :3000 server for
Node E2E + Playwright now runs from the precompiled .build\bundle instead of
`meteor run`, so no recompile. New :start_bundle_server subroutine (used by both the
parallel and sequential "Run ALL tests" flows):

- Builds .build\bundle once if missing (meteor build .build --directory).
- Resolves Meteor's bundled node via `meteor node -e process.execPath` and the sibling
  dev_bundle mongod; runs the bundle with that node (native-module ABI match).
- Installs the bundle's programs\server npm deps once.
- Starts Meteor's mongod on :3001 (db "meteor", matching what the tests seed), or reuses
  a MongoDB already listening there; stops it on exit only if this run started it.
- Starts `node .build\bundle\main.js` with MONGO_URL/ROOT_URL/PORT/WRITABLE_PATH set in
  the parent scope (child inherits them) then unset so Mocha/E2E do not inherit MONGO_URL.

Mocha still uses `meteor test` (:3100, its own build) — it cannot run from a production
bundle. Intro text and cleanup updated; CHANGELOG notes the parity. Untested on Windows
here (no cmd available); needs one Windows verification run.
2026-07-15 00:03:48 +03:00
Lauri Ojansivu 53c6d20342 CHANGELOG: reorganize Upcoming into features / updates / tests / bugs
Group the Upcoming section under the standard headers: CRITICAL SECURITY ISSUE
(MimeBleed), new features (import/export privacy, always-show-code, render-links),
updates (webhook card description, Sandstorm docs), tests (yargs mocha crash, live
progress, .build/bundle reuse), bugs (Sandstorm migrated-grain Admin Panel, Version
storage engine). Also fold the bundle-server "meteor" vs "wekan" database fix into
the .build/bundle test entry.
2026-07-14 23:50:27 +03:00
Lauri Ojansivu 434e7dd1e2 rebuild-wekan.sh: bundle test server must use the "meteor" DB, not "wekan"
The bundle-based :3000 server pointed at mongodb://127.0.0.1:3001/wekan, but the
Playwright and Node E2E tests seed their users/boards straight into
mongodb://127.0.0.1:3001/meteor (tests/playwright/helpers/db.js,
tests/e2e/list-regressions.js) — the DB name Meteor's built-in mongo used under the
old `meteor run`. Same mongod, different database: the app read an empty DB while the
tests seeded another, so every test needing seeded data failed (only the ~9 per browser
that need no seed passed). Point the bundle server at /meteor so app and tests share
one database again.
2026-07-14 23:15:35 +03:00
Lauri Ojansivu a4505f9553 CHANGELOG: record the test-run readiness-hang fix and .build/bundle gate
Complete the Upcoming test-infrastructure notes: the readiness poll that hung on
Meteor's :3000 proxy (progress froze at [0s]) is now fixed with a curl timeout and a
wall-clock-bounded wait, and "Run ALL tests" checks for .build/bundle specifically and
builds it once if missing. Also note the .sh (bundle) vs .bat (meteor run) split in the
server-start wait item.
2026-07-14 22:48:04 +03:00
Lauri Ojansivu aa568e8087 rebuild-wekan.sh: gate tests on .build/bundle existence, not just .build
Before running ALL tests, check for the .build/bundle directory specifically (the
:3000 test server runs it). If it is missing (never built, or a previous build left
.build without a bundle), build it with `meteor build .build --directory` first.
Previously it only checked the .build parent, so a missing/partial bundle was not
rebuilt here (only caught later by the main.js check).
2026-07-14 22:46:22 +03:00
Lauri Ojansivu 5894b000a2 rebuild-wekan.sh: run :3000 test server from precompiled .build/bundle; live scrolling
"Run ALL tests" started the :3000 server with `meteor run`, which recompiles into
.meteor/local even after you already built .build/bundle. Node E2E + Playwright only
need a live WeKan over HTTP, so start that server from the precompiled bundle instead:

- `node .build/bundle/main.js` with Meteor's bundled node (matching native-module ABI),
  MONGO_URL to Meteor's bundled mongod on :3001 (or reuse a MongoDB already listening
  there). Boots in seconds, no recompile. One-time `programs/server` npm install runs
  if missing. mongod is stopped on exit only if this run started it.
- The server boot log and the npm install now STREAM LIVE (scrolling: tail -n 0 -f /
  tee) so you see exactly what is happening instead of a single frozen summary line.
- Sequential mode runs suites strictly one at a time, each streaming its own output;
  the parallel-only combined refreshing table is now skipped in sequential mode.

Mocha still uses `meteor test` (its own .meteor/local-test build) — its in-process
server-side tests cannot run from a production bundle, so that one build is unavoidable
and copying the bundle would not help it. The script warns the bundle is run as-is, so
rebuild it after source changes. CHANGELOG updated.
2026-07-14 22:43:04 +03:00
Lauri Ojansivu a7374ff8a4 rebuild-wekan.sh: stream each test live in sequential mode
In sequential mode only one suite runs at a time, so there is no reason to hide its
output in a log and show only a counter. Refactor the job runner: extract run_job_body
(the actual suite command) and, in sequential mode, pipe it through tee straight to the
console — you now see every test tick by one-by-one as the reporter prints it, then a
final PASS/FAIL + count line. Parallel mode still runs jobs in the background to logs
(the combined table reads them). tee is on the right of the pipe so the suite's exit
code is captured in STATDIR, not tee's.
2026-07-14 22:31:15 +03:00
Lauri Ojansivu 9db870a087 rebuild-wekan: clarify that the test server "build" is Meteor recompiling
The "Building WeKan" message during Run ALL tests suggested a fresh full build even
though the user had already built WeKan. Reword it: the tests start a live
`meteor run --port 3000`, and Meteor recompiles into .meteor/local on start only for
inputs that changed since the last build (sources / node_modules), then caches it.
No behavior change, message only.
2026-07-14 22:24:15 +03:00
Lauri Ojansivu 88b181c18e rebuild-wekan: fix readiness poll hanging on Meteor's :3000 proxy
The "Run ALL tests" server-readiness wait used `curl http://127.0.0.1:3000/sign-in`
with no timeout. Meteor binds the :3000 proxy early and accepts the TCP connection
while the app is still building, but does not send an HTTP response until the build
finishes. So curl blocked on that first connection for the whole build: the loop
never advanced and the new progress line froze at [0s] with only the log header,
even though the build was progressing in the log.

Give curl --connect-timeout 2 --max-time 4 so each poll returns quickly (not-ready
during the build, ready once /sign-in actually answers). The elapsed seconds and the
newest build step now refresh every few seconds as intended. Bound the wait by
wall-clock time (.sh: 1200s; .bat: ~240 polls) instead of a fixed count, since polls
now take a few seconds each. Also add the timeout to the Playwright-ALL precheck.
2026-07-14 22:10:45 +03:00
Lauri Ojansivu cc2a317236 rebuild-wekan: show live test progress in every path (.sh and .bat)
Audited both runner scripts and closed every place that used to sit silent for
minutes during test runs:

- Server first-build wait (Run ALL tests, both modes): .sh shows elapsed seconds +
  newest build step from the server log in place; .bat shows elapsed seconds every
  ~10s and points at the live build log (echoing arbitrary build lines is unsafe on
  cmd because they contain > < | &).

- Sequential per-job (option 2): .sh prints a live elapsed + pass/fail counter per
  job and a final PASS/FAIL line; .bat now runs each job in its own minimized window
  (reusing the proven parallel start-commands + .done-<key> flags) and polls a live
  pass counter one job at a time, instead of redirecting silently to a log.

- Playwright ALL browsers single menu item (.sh option 10 / run_playwright_parallel):
  streams each browser's list reporter live via tee while still saving the log;
  PIPESTATUS[0] keeps per-browser pass/fail accurate through the pipe.

Single-suite items (Mocha, import, E2E, single Playwright) and the parallel combined
progress table already showed progress and are unchanged. CHANGELOG updated.
2026-07-14 21:35:40 +03:00
Lauri Ojansivu 9f5e459dda CHANGELOG: document live test-run progress display in rebuild-wekan.sh
Record the Upcoming test-infrastructure change: "Run ALL tests" now shows elapsed
time + newest build step during the server build, and a live per-job pass/fail
counter in sequential mode, instead of sitting silent for minutes.
2026-07-14 21:23:07 +03:00
Lauri Ojansivu 02b3bed5ce rebuild-wekan.sh: show live progress during test build and sequential jobs
Running ALL tests (menu options 1/2) was silent for minutes in two places:

- While the :3000 WeKan server did its first rspack build, the readiness wait only
  printed dots. Replace that with an in-place line showing elapsed seconds and the
  newest build step from the server log (=> Compiled Rspack..., => Started MongoDB,
  => Started your app), so the build is visibly progressing.

- In sequential mode each job blocks until it finishes, so nothing showed while a
  job built and ran. Add a live in-place counter per job (elapsed seconds + tests
  passed/failed, from count_pass/count_fail on the job log) and a final PASS/FAIL
  line when it completes.

The parallel-mode combined progress table is unchanged.
2026-07-14 21:22:46 +03:00
Lauri Ojansivu ac69e0e171 CHANGELOG: record the confirmed yargs test-build fix and outcome
Update the "Test infrastructure" Upcoming entry with the confirmed root cause and
resolution: the second-stage boot crash was node_modules/yargs-parser/build/lib/index.js
using the guarded ternary `const require = createRequire ? … : undefined`, fixed by
generalizing the createRequire removal to a line-wise match (run before import.meta.url
expansion) and broadening the scan from node_modules/yargs to yargs and its ESM deps.
Also note the three server test files that were present but missing from the curated
server/lib/tests/index.js loader and are now registered. Verified: 450 passing, 0 failing
(411 before the three files were wired in).
2026-07-14 21:07:43 +03:00
Lauri Ojansivu f4d05feb0a patch-yargs-dirname: also patch yargs-parser; register new server tests
The Mocha (server-side) test bundle crashed at boot with 0 tests run because
yargs and its ESM dependencies are dragged into the Meteor test server bundle,
where Meteor 3 wraps every module in a CommonJS function and their native-ESM
constructs (import.meta, `const __dirname`, `const require = createRequire(...)`)
are illegal and crash the bundle before any test runs.

Two gaps remained in scripts/patch-yargs-dirname.cjs:

- The `const require = createRequire(...)` removal (and its trigger) only matched
  the direct-call form. yargs-parser/build/lib/index.js uses the guarded ternary
  `const require = createRequire ? createRequire(import.meta.url) : undefined;`,
  which slipped through and crashed at boot with "Identifier 'require' has already
  been declared". Generalized the match to line-wise `const require = ...createRequire...;`.

- The scan only walked node_modules/yargs, so yargs-parser (the actual offender)
  was never patched. Broadened the scan to yargs and its ESM dependency packages.

Also register the three server test files that were present but missing from the
curated server/lib/tests/index.js (they never ran): the MimeBleed file-validation
bypass regression, the import/export privacy settings, and the impersonation report
query. Server suite now boots and runs (411 passing).

CHANGELOG Upcoming documents the bug and the two-stage debugging journey.
2026-07-14 21:02:26 +03:00
Lauri Ojansivu c21f3d86ef patch-yargs-dirname: fix createRequire mangling that broke the Mocha test build
The yargs postinstall patch expanded import.meta.url -> require('url').pathToFileURL(...)
BEFORE removing the const require = createRequire(...) call. The paren-naive removal
regex createRequire([^)]*) then stopped at the first ) of the injected require('url'),
replacing only part of the statement and leaving a dangling
  /* Meteor: use wrapper require */.pathToFileURL(__filename).href));
in yargs' esm.mjs. That is invalid JS (a leading .pathToFileURL after a comment), so
Meteor's reify parser threw 'Unexpected token (19:33)' when the test server bundle pulled
yargs in -- meteor test reported 0 tests, even though every repo source file was fine.

Fix: remove the createRequire call BEFORE expanding import.meta.url (so the injected
parens can't break the match), and add a repair rule + trigger so re-running the script
collapses the dangling leftover in already-corrupted node_modules/yargs files back to the
marker. Fresh and already-broken files both end valid.
2026-07-14 20:43:01 +03:00
Lauri Ojansivu 5b697f983b Fix test failures from this session's changes
- models/fileValidation.js: a stray BOM (U+FEFF) inside the looksLikeDangerousMarkup
  regex character class broke the server test build with a Babel 'Unexpected token'
  syntax error, so Mocha ran 0 tests. JS \s already matches U+FEFF, so the explicit
  BOM was redundant; use plain \s*.
- models/import.js: importBoard awaited assertImportEnabled() BEFORE the switch that
  check()s the "board" argument. Meteor's audit-argument-checks tracks checks on the
  async context, so awaiting first made the later check(board) not count, throwing
  'Did not check() all arguments during call to importBoard' -- which broke the WeKan
  JSON import (#21) and Excel import (#25) Playwright tests. Check "board" up front
  (Match.OneOf(Object, Array)) before the await; the per-source switch still does its
  specific check.
- server/publications/activities.js: move the disableActivities gate to AFTER the
  check() calls so the argument-checks audit is satisfied when the toggle is on
  (latent: it would have thrown 'did not check all arguments' with activities off).
2026-07-14 19:00:35 +03:00
Lauri Ojansivu faf7ff6efa Snap: migrate ANY MongoDB (3/7/other) to FerretDB SQLite, then shut down MongoDB (#6454)
Supersede the earlier 'keep MongoDB 7' approach: the WeKan snap now moves EVERY
existing MongoDB database onto FerretDB (SQLite) on first boot after an upgrade,
then shuts MongoDB down so only WeKan (Node.js) + FerretDB (SQLite) run. Text data
goes into SQLite; CollectionFS + Meteor-Files GridFS attachments/avatars are
extracted to files/attachments and files/avatars.

migration-control now routes by how the source can be READ, not by keep-vs-migrate:
- mongod 7 can open it (modern MongoDB 6/7, incl. a DB earlier migrated up from 3.x)
  -> keep that mongod as source, run the modern driver importer
  (migrate-mongodb-to-ferretdb.mjs) into a temp FerretDB SQLite target.
- else mongod 3.2 can open it (old MongoDB 3.x) -> run migrate-mongo3-to-ferretdb.mjs
  (legacy mongoexport CLI).
- else (unreadable/corrupt, or tools missing) -> fall back to a normal MongoDB start
  so the service is never left dead, and retry next boot.

On success: mark done + snapctl set database=ferretdb; mongodb-control then stops and
disables the MongoDB service. This also fixes the reported failure where a healthy
MongoDB 7 database was misdetected as MongoDB 3.x, the 3.x path failed, and — because
mongodb-control exec's this script — mongod never started and the service looped.
Probe readiness lengthened 20s -> 45s; the temp source mongod is tracked by pidfile
and force-stopped so it never leaves the dbpath locked. MongoDB data is never
modified or deleted; the snap only switches to FerretDB once migration succeeds.
2026-07-14 18:09:03 +03:00
Lauri Ojansivu 3a4f3c896e Migration dashboard: use Admin Panel product name instead of WeKan
Both migration dashboards (Snap MongoDB-3 importer and the Sandstorm/general
MongoDB importer) hard-coded 'WeKan'. If the migrated database has a product name
set in Admin Panel (settings.productName), the dashboard now shows that name and
does not mention WeKan; otherwise it defaults to WeKan.

- snap-src/bin/migrate-mongo3-to-ferretdb.mjs: read settings.productName via
  mongoexport early in run() into state.product; title/h1/complete message use it.
  The importer->app hand-off poll no longer keys off the product-dependent title
  string; it checks a stable '<!--migration-dashboard-->' marker instead.
- releases/migrate-mongodb-to-ferretdb.mjs: read settings.productName via the driver
  right after the source connects into state.product; the HTML __PRODUCT__ placeholder
  is substituted in buildHtml(). Header clarified to FerretDB (SQLite).
2026-07-14 18:02:43 +03:00
Lauri Ojansivu 0514a2fa5d Snap: don't misdetect MongoDB 7 as MongoDB 3.x on upgrade (#6454)
The one-time MongoDB 3 -> FerretDB migration check probed whether mongod 7 could
open the existing data and, if that short probe failed for ANY reason (journal
recovery, stale mongod.lock, slow disk, large oplog), assumed the data was
MongoDB 3.x. A healthy MongoDB 7 database that failed the probe was then sent down
the 3.x path, where mongod 3.2 also could not open it, logging 'could not start
migratemongo mongod on the MongoDB 3.x data'. Because mongodb-control exec's the
migration script, mongod never started -- the MongoDB service failed to activate
and looped on every restart.

Fix: only run the 3.x migration when the data is POSITIVELY confirmed as MongoDB
3.x (mongod 3.2 can actually open it). In every other case -- MongoDB 6/7, data the
probe could not read, or missing tools -- fall back to a normal MongoDB 7 start in
the same service activation (set the marker + exec mongodb-control) instead of
exiting with nothing running. Also: lengthen the mongod-7 readiness probe 20s -> 45s
so large databases needing recovery are not misread, and track the temporary probe
mongod by pidfile + force-stop it so it never leaves the dbpath WiredTiger lock held.
The MongoDB data is never modified or deleted.
2026-07-14 17:42:44 +03:00
Lauri Ojansivu eb92574650 Updated ChangeLog. 2026-07-14 17:32:09 +03:00
Lauri Ojansivu 422daa6a0c Update Sandstorm Docs about how to install test version.
Thanks to xet7 !
2026-07-14 17:28:59 +03:00
Lauri Ojansivu 339729fd95 Tests for newest Security/Reports features (positive + negative)
- client/lib/tests/renderLinksPlainText.tests.js: the 'render links as plain text'
  toggle and XSS hardening. Config assertions (stripLinks adds <a> to FORBID_TAGS,
  default does not, KEEP_CONTENT) plus real sanitizeHTML() behavior: <a> stripped to
  its text when stripLinks, kept by default, and javascript: hrefs / event handlers /
  <script> removed regardless.
- client/lib/tests/alwaysShowCodeAsText.tests.js: escapeHtmlSource (exposed on the
  Markdown export) escapes <script>/comments/quotes/ampersand-first so code is shown
  not executed; leaves plain text and empty strings unchanged.
- server/lib/tests/impersonationReportQuery.tests.js: the Impersonation report search
  selector (extracted to models/lib/impersonationReportQuery.js so it is testable
  without re-registering the publication) builds a case-insensitive $or across the
  identity fields, escapes regex metacharacters, and is empty for a blank term.

The MimeBleed fix (fileValidationBypass.security.tests.js), SortBleed
(boards.security.tests.js) and import/export anonymization
(importExportSecurity.tests.js) already carry positive + negative tests.
2026-07-14 17:01:35 +03:00
Lauri Ojansivu f6ba472f67 MimeBleed (GHSA-jhph-whx8-wq6p): file-upload MIME bypass -> stored XSS
WeKan's upload validation (models/fileValidation.js) detects a file's real MIME
type by running the Unix 'file' command. On minimal Docker/Alpine images without
'file', detectMimeFromFile() silently returned undefined and the code fell back to
the CLIENT-supplied fileObj.type. An authenticated board member (WITH_API=true)
could upload HTML+JS while setting fileType: image/png; the spoofed type is not on
the dangerous-MIME deny-list, so the dangerous-content scan was skipped and the file
was stored, yielding stored XSS served under the WeKan origin (CWE-434).

Fix: the client-supplied type can no longer gate the safety scan. When content-based
detection via 'file' is unavailable, fall back to a dependency-free JS content sniff
(looksLikeDangerousMarkup) that inspects the real bytes for HTML/SVG/XML/<script>
signatures and forces the dangerous-content scan regardless of the claimed MIME, so a
spoofed image/png that is really HTML+JS is rejected. The sniff matches only definitive
markup, so genuine binary uploads (real PNG/JPEG/PDF, incl. large ones) are unaffected.
Also log a one-time warning when 'file' is missing (previously silent).

Regression test server/lib/tests/fileValidationBypass.security.tests.js covers the
spoofed dangerous uploads and safe binaries.

CVSS:3.1 8.3 High (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). Thanks to HNUfwj and xet7.
2026-07-14 16:49:48 +03:00
Lauri Ojansivu efbd540b48 Updated translations. 2026-07-14 16:38:39 +03:00
Lauri Ojansivu 592d803924 Notifications feature: drop GDPR-specific wording
Disabling activities/notifications/watch can serve any law or policy, not only
GDPR. Remove GDPR mentions from the code comments and the en.i18n.json toggle
descriptions (and the Admin Panel docs), keeping the neutral #5820 reference.
2026-07-14 14:22:58 +03:00
Lauri Ojansivu 86142f542c Outgoing webhooks: include card description in payload (#5143)
A card description was recorded in the activity log (a-changedDescription, #5482)
but never added to the outgoing-webhook / notification params, so integrations got
an event with no description text.

The Activities.after.insert payload now sets params.description (the card's current
description, only when non-empty) for every card event, and forwards the activity's
oldValue/value alongside the existing timeValue/timeOldValue so a description change
carries the before/after text. New fields are additive and backward-compatible.
2026-07-14 14:20:22 +03:00
Lauri Ojansivu a13ce5ef09 Admin Panel / Reports / Impersonation: paginated impersonation log report
Impersonation events are already recorded in the impersonatedUsers collection
whenever an admin impersonates a user (e.g. to export a private board). Surface
them as a new admin report, newest-first, with the same server-side search +
pagination as the other reports.

- server/publications/impersonationReport.js: impersonationReport publication
  (admin-only, sort createdAt -1, limit/skip, $or search over admin/user/board/
  attachment ids and reason) + getImpersonationReportCount method. Also publishes
  the referenced admin/impersonated user docs (same fields the People panel uses)
  so usernames resolve and the Edit user popup has its data.
- adminReports.jade/.js: new Impersonation side-menu entry, sub-template (Date,
  Admin, Impersonated user, Board, Reason), pagination + search wired through the
  shared reportConfig. Clicking a username opens the same editUser popup as
  Admin Panel / People.
- i18n: impersonationReportTitle, impersonation-admin, impersonation-user, reason.
2026-07-14 14:10:38 +03:00
Lauri Ojansivu 247522484c Tests + docs for Admin Panel / Features Security and Notifications options
- server/lib/tests/importExportSecurity.tests.js: positive and negative unit
  tests for the anonymization helpers (map building, user-doc scrubbing, @mention
  rewriting that ignores unknown handles and email addresses, requestedBy/assignedBy
  identity replacement, and safe no-ops on empty input).
- docs/Features/Admin-Panel/Admin-Panel.md: new Features tab section documenting the
  Performance, Security (rich-text hardening + import/export privacy) and
  Notifications (GDPR) toggles.
2026-07-14 14:03:32 +03:00
Lauri Ojansivu f1cc8daebf Admin Panel / Features / Notifications: GDPR activity/notification controls (#5820)
New Notifications category with three GDPR toggles (all default off), enforced
server-side:

- Disable all activities: activity-feed entries are neither recorded
  (Activities.before.insert returns false to cancel) nor shown (the activities
  publication returns nothing, hiding existing and new entries).
- Disable all notifications: the Activities.after.insert dispatch hook returns
  early, so no watch notifications are sent; activity recording is unaffected.
- Disable watch: the watch() method rejects any watch-level change, and the board
  header watch button is hidden.

A synchronously-readable server cache (models/lib/featureFlags.js, kept current by
a Settings observer) lets the sync before.insert hook read the flags without
awaiting. Adds settings disableActivities, disableNotifications, disableWatch,
published in the public setting, plus global Blaze helpers activitiesDisabled /
watchDisabled for UI hiding.
2026-07-14 14:00:49 +03:00
Lauri Ojansivu 06fdede782 Admin Panel / Features / Security: import/export privacy controls
Six new optional toggles (all default off) governing how boards and user data
cross the WeKan boundary, enforced server-side so they cannot be bypassed from
the client:

- Disable all import / Disable all export: master switches that turn off every
  import/export feature. importBoard and cloneBoard call assertImportEnabled;
  every Exporter build()/buildStream() call assertExportEnabled. The import and
  export menu options are hidden in the UI via global importDisabled/exportDisabled
  Blaze helpers.
- Disable import avatars / Disable export avatars: never carry avatars into/out of
  WeKan. Import is gated at the single localizeAvatarFromBuffer choke point (covers
  WeKan JSON import, LDAP, OIDC/OAuth2 login sync and board-open localization) plus
  Trello inlineMemberAvatars; export skips the base64 avatar embed + avatarUrl in
  both Exporter paths.
- Anonymize import users / Anonymize export users: replace username/fullname/initials
  with counter placeholders (user1, user2, ...), drop avatars, and rewrite @username
  mentions + requestedBy/assignedBy in card/comment text. The placeholder word "user"
  is translated to the current user language. Export covers both build() and the
  streaming buildStream() (which does an id-only pre-scan so mentions streamed before
  the users array resolve to matching labels); import scrubs the incoming board in
  WekanCreator before any placeholder user or card is created.

New shared server helper models/lib/importExportSecurity.js holds the settings
read, guards, anonymization map builder and text rewriter. Adds settings
disableAllImport, disableAllExport, disableImportAvatars, disableExportAvatars,
anonymizeImportUsers, anonymizeExportUsers, published in the public setting.
2026-07-14 13:53:11 +03:00
Lauri Ojansivu 3c98336445 i18n: Finnish translations for Admin Panel / Features / Security strings
Translate the new render-links-as-plain-text-description, always-show-code-as-text
and always-show-code-as-text-description strings to Finnish, matching existing
terminology (tarkistuslistat, klikattava, 'Kun käytössä...', 'Oletuksena pois
käytöstä...').
2026-07-14 13:25:50 +03:00
Lauri Ojansivu daeaa2b9d8 Updated translations. 2026-07-14 13:23:12 +03:00
Lauri Ojansivu 38ce497887 Admin Panel / Features / Security: "Always show all code as plain text"
New optional toggle under Admin Panel / Features / Security. When enabled,
rich text is never rendered as markdown or HTML -- the entire source is shown
as escaped plain text in every rich text field (board and card titles,
descriptions, comments, checklists, etc.), so hidden content is always
revealed: HTML comments (<!-- -->), the target URL inside a markdown link,
JavaScript and any other code. All code is always visible, not clickable, and
not running.

This extends the existing invisiblebleed protection (which already showed raw
source for description-less markdown links) to all content, and complements
the "Render links as plain text" toggle.

Implementation:
- New global setting alwaysShowCodeAsText (models/settings.js), published in
  the public setting publication, default off.
- Admin Panel / Features / Security gains a second materialCheckBox toggle.
- The markdown helper has the raw text, so the decision lives there. Because
  the wekan-markdown package cannot import app code, the setting is bridged via
  a ReactiveVar on the exported Markdown object; the rich text viewer
  (editor.js) keeps it in sync with the setting reactively, and the markdown
  helper reads it and renders the fully HTML-escaped source in a <pre> when on.
  Full escaping (not DOMPurify stripping) ensures tags like <script> are shown
  as text rather than removed. The description-less-link branch now uses the
  same full escaping so it too reveals all source.
2026-07-14 13:17:33 +03:00
Lauri Ojansivu 9787445fbb Updated translations. 2026-07-14 12:56:28 +03:00
Lauri Ojansivu f68c91d102 Admin Panel / Features: optional "Render links as plain text" toggle
New optional security feature under Admin Panel / Features / Security. When
enabled, all links -- both markdown links like [label](url) and raw HTML
<a href> tags -- are always shown as plain, non-clickable text in every rich
text field (board and card titles, descriptions, comments, checklists, etc.),
so a link can never be clicked and cannot present misleading anchor text.

This is a hardening option on top of the existing XSS sanitization (which
already strips javascript:/data: schemes, event-handler attributes and
dangerous tags); it addresses #6453, where a board title rendered as a
clickable link. Default off, so links stay clickable.

Implementation:
- New global setting renderLinksAsPlainText (models/settings.js), published in
  the public setting publication.
- Admin Panel / Features gains a Security category with a materialCheckBox
  toggle that saves the setting immediately.
- The shared DOMPurify sanitizer takes a { stripLinks } option that forbids the
  <a> tag while keeping its inner text (KEEP_CONTENT), so links collapse to
  plain text. The rich text viewer (mentions helper in editor.js) passes
  stripLinks based on the setting, gated reactively so toggling re-renders.
2026-07-14 12:51:44 +03:00
Lauri Ojansivu 620b9be180 Updated translations. 2026-07-14 12:09:03 +03:00
Lauri Ojansivu 0ed35e00db Sandstorm: WeKan Admin Panel always available in migrated grains
On Sandstorm the grain owner (Sandstorm 'configure' permission) maps to a
WeKan admin, which gates the Admin Panel and its Attachments / Sandstorm
cleanup tools. A new grain worked because Users.after.insert derives the role
for the freshly inserted user, but a grain migrated from an older WeKan
already contains the user, so that insert hook never runs. Sandstorm
auto-login uses connection.setUserId() (bypassing accounts-base, so
Accounts.onLogin never fires), and the remaining observeChanges hook on
services.sandstorm only fires when that field actually changes on login. When
the migrated user's stored permissions already equal the grain's current
permissions, the login $set is a no-op with no oplog entry, so isAdmin was
never derived and the owner had no Admin Panel.

Reconcile at grain startup (a migrated grain reboots once migration
completes): grant WeKan admin to every Sandstorm user whose stored
permissions include 'configure'. Promote only, never revoke here (that is the
change hook's job on a real permission change), so a stale/empty stored
permission set can never lock the owner out of the Admin Panel.
2026-07-13 20:44:17 +03:00
Lauri Ojansivu 5a90a1987a Admin Panel / Version: show MongoDB storage engine with per-db credentials
The storage engine was read only from serverStatus, which requires the
cluster-level clusterMonitor role. A per-database WeKan user (readWrite on
the wekan db only, as in docs/Platforms/FOSS/Docker/Meteor3/) is not
authorized to run it, so it was rejected and the field stayed 'unknown'.

Fall back to a $collStats aggregation, which needs only the collStats action
that read/readWrite already grant, and read the real engine (wiredTiger /
inMemory) from storageStats. The MongoDB compatible version and Database
commit were already correct: they come from buildInfo, which needs no
special privileges.
2026-07-13 20:30:16 +03:00
github-actions[bot] f1f6fecf44 Bump versions for v9.89 2026-07-13 16:25:58 +00:00
Lauri Ojansivu 6cb4ae2d64 Updated ChangeLog. 2026-07-13 19:24:47 +03:00
Lauri Ojansivu 3ecb90fc04 package-lock.json: record hasInstallScript for yargs postinstall patch
The postinstall script (scripts/patch-yargs-dirname.cjs) added in 6b409c5da
to fix the Meteor/Mocha test boot crash makes npm mark the root package with
hasInstallScript: true. Sync the lockfile to match package.json.
2026-07-13 19:19:10 +03:00
Lauri Ojansivu dc135f6e7d SortBleed (GHSA-xm8x-c8wg-jhmf): board sort allow rule privilege escalation
A low-privilege (comment-only / read-only) board member could escalate to
board admin and take over a private board via the board 'sort' collection
allow rule. To support drag-to-reorder, a second Boards.allow({ update })
rule returned true for any board member whenever the update touched the
'sort' field. Meteor applies the WHOLE modifier once any allow rule approves
and no deny rule rejects, and does not scope the approving rule to the field
that satisfied it. Because canUpdateBoardSort only checked that 'sort' was
AMONG the modified fields, a restricted member could smuggle arbitrary board
mutations (members, permission, title) into the same $set as 'sort' in one
DDP Boards.update call, making themselves admin, flipping the private board
to public, and evicting the legitimate owner. The last-admin deny rule only
inspected $pull, so a wholesale $set of the members array bypassed it.

Fix: restrict canUpdateBoardSort so the sort-reorder rule approves an update
only when 'sort' is the sole modified field. Defense in depth: the last-admin
deny rule now also rejects a $set rewrite of the members array that would
drop the last active admin. Regression test covers the multi-field smuggling
case. The legitimate All Boards drag-reorder is unaffected: it persists order
per-user in profile.boardSortIndex, not in the board document.

CVSS:3.1 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), CWE-863, CWE-269.
Thanks to 5ud0 / Tarmo Technologies and xet7.
2026-07-13 19:08:29 +03:00
Lauri Ojansivu 5b8e47649e Updated translations. 2026-07-13 18:34:58 +03:00
Lauri Ojansivu fb1df544dd Updated translations. 2026-07-13 18:29:09 +03:00
Lauri Ojansivu f031b885d7 CI: add meteor-spk.yml (build the meteor-spk bundle) and sandstorm-store.yml (publish .spk)
- .github/workflows/meteor-spk.yml: assembles WeKan's modernized meteor-spk (the
  packaging tool + its meteor-spk.deps: Node 24, FerretDB, Mongo 3.x CLIs, modern
  sandstorm-http-bridge) via sandstorm-src/build-deps.sh — reusing sandstorm.yml's
  setup — and zips it as meteor-spk-YYYY-MM-DD_HH_MM_SS.zip, the same kind of
  self-contained bundle sandstorm-io/meteor-spk releases but with WeKan's deps baked in.
  Uploaded as an artifact and to the newest GitHub Release. Needs no keyring (it does
  not pack/sign a .spk).

- .github/workflows/sandstorm-store.yml: publishes an already-built .spk to the
  Sandstorm App Market. Installs Sandstorm + assembles meteor-spk and restores
  SANDSTORM_KEYRING like sandstorm.yml, downloads the newest .spk from the WeKan release
  (built and attached by sandstorm.yml), renames it to wekan-<VERSION>.spk (VERSION from
  the release tag, e.g. 9.88, overridable via inputs), and runs 'spk publish
  wekan-<VERSION>.spk' exactly like releases/release-sandstorm.sh. Does not rebuild the
  .spk.

Thanks to xet7.
2026-07-13 14:53:57 +03:00
Lauri Ojansivu a4c1ce3d41 meteor test: patch ALL yargs files' import.meta, not just esm.mjs
The crash moved from yargs/lib/platform-shims/esm.mjs to yargs/build/lib/utils/
apply-extends.js (import.meta.resolve(config.extends)) — yargs' ESM build has more than
one file using import.meta. Rewrite the workaround to scan the whole node_modules/yargs
tree and rewrite every ESM-only construct (import.meta.url/.resolve/.dirname/.filename,
const require = createRequire(...), and yargs' own const __dirname) to the CommonJS
wrapper equivalents in one pass, so a new yargs file can't reintroduce the boot crash.
yargs is never executed at runtime here, so only valid CommonJS parsing matters.

Thanks to xet7.
2026-07-13 14:46:01 +03:00
Lauri Ojansivu 5b0ad4d334 meteor test: also rewrite yargs esm.mjs import.meta / require for the CommonJS wrapper
Renaming only __dirname was not enough: yargs' esm.mjs also uses import.meta.url (invalid
in Meteor's CommonJS module wrapper -> 'Cannot use import.meta outside a module') and
redeclares require via createRequire(import.meta.url) (collides with the wrapper's
require). reify rewrites the file's import statements but not import.meta. Rewrite all
three to use the wrapper's __filename / __dirname / require, which yargs never actually
exercises here (it is only bundled, never called). Idempotent; also patches a file left
half-rewritten by the previous version of this script.

Thanks to xet7.
2026-07-13 14:39:17 +03:00
Lauri Ojansivu 6b409c5dad meteor test: postinstall patch renames yargs esm.mjs __dirname (fixes Mocha boot crash)
.meteorignore does not apply to node_modules under Meteor 3 / rspack (verified: the
rebuilt Mocha bundle still contained node_modules/yargs), so revert that no-op rule and
fix the actual crash instead.

Root cause (pre-existing, unrelated to app code): the whole yargs package gets pulled
into the server bundle as a transitive devDependency, and its ESM shim
node_modules/yargs/lib/platform-shims/esm.mjs declares a top-level
'const __dirname = fileURLToPath(import.meta.url)'. Meteor wraps that .mjs in a CommonJS
module function that already provides __dirname, so it collides —
'SyntaxError: Identifier __dirname has already been declared' — crashing the server
bundle at boot, which is why Mocha reported 0 tests.

Add scripts/patch-yargs-dirname.cjs, run from a package.json postinstall, which renames
yargs' local __dirname (and its .substring/.lastIndexOf uses) to __dirnameYargs so it can
no longer collide with the wrapper's. Idempotent and best-effort (never fails install);
yargs is not used at runtime here so the rename is inert for the app.

Thanks to xet7.
2026-07-13 14:14:32 +03:00
Lauri Ojansivu 298ffcfd3b meteor test: exclude i18next-sprintf-postprocessor/rollup.config.js so yargs is not bundled
'meteor test' (Mocha) crashed at boot with 'Identifier __dirname has already been
declared'. Root cause is unrelated to app code: node_modules/yargs/lib/platform-shims/
esm.mjs declares a top-level 'const __dirname = fileURLToPath(import.meta.url)', which
collides with the __dirname that Meteor's CommonJS module wrapper already provides. yargs
gets pulled into the bundle only because i18next-sprintf-postprocessor (imported by
imports/i18n/tap.js) ships a stray rollup.config.js that does require('yargs'), and a
devDependency (mongodb) has since installed yargs 18 into node_modules so that require now
resolves. That build config is not part of the package's runtime entry
(index.js -> dist/commonjs), so exclude it from Meteor's bundle via .meteorignore.

Pre-existing (the same boot crash happened before this branch's test work) and independent
of the avatar/import-export feature.

Thanks to xet7.
2026-07-13 09:16:33 +03:00
Lauri Ojansivu cf217784d1 Fix Mocha boot crash (test __dirname) and load the new avatar/import-export server files
Two build/load fixes surfaced by running the full test suite:

- server/imports.js: import the new server-only files so they actually load. In
  mainModule mode a file not imported here never loads, so Accounts.onLogin avatar
  localization (server/avatarLocalizationOnLogin.js) and the imported-user reconciliation
  Meteor methods (server/importedUserReconciliation.js) would silently never register.

- Test __dirname collision: 'meteor test' crashed at boot with 'Identifier __dirname has
  already been declared'. The client test index (client/lib/tests/index.js) loads
  boardTriggersClass.tests.js, which referenced the bare __dirname global; under
  meteor test that makes Meteor inject a  colliding with its CommonJS
  wrapper's, aborting the whole server bundle (the same failure already fixed once in
  dependencies.openapi.tests.js — this was the next file to trip it). Replace __dirname
  with a process.cwd()/PWD walk-up in boardTriggersClass.tests.js, and defensively in the
  node-run tests/rtl.test.js, tests/stickers.test.js and tests/stickers.packs.test.js too.

Thanks to xet7.
2026-07-13 09:16:33 +03:00
Lauri Ojansivu bb7fe88bd5 Tests: unit + negative tests for the avatar/import-export feature; extract pure logic
Split the testable, security- and correctness-critical logic of the new avatars +
original-members feature into pure Node modules (module.exports, no Meteor) and cover them
with plain-Node tests/*.test.cjs (positive AND negative), matching the repo's existing
unit-test style. Wired into the test:unit:node script.

- models/lib/avatarUrlSafety.js  (from server/lib/localizeAvatar.js): isExternalAvatarUrl,
  isBlockedIp (SSRF ranges), decodeDataUri, checkUrlHostSync. Tests assert external vs
  local/relative/non-http URLs, that private/loopback/link-local/CGNAT/metadata/multicast
  IPv4+IPv6 (incl. IPv4-mapped) are blocked while genuine public addresses and near-miss
  ranges are allowed, data: decoding, and that non-http(s) schemes, localhost and
  metadata hosts are rejected.
- models/lib/importedUserReconciliationPlan.js (from server/importedUserReconciliation.js):
  planReconciliation. Tests: matches merge, non-matches deactivate, never match another
  placeholder or self or a username-less placeholder.
- packages/wekan-ldap/server/ldapPhoto.js (from sync.js): getLdapPhotoBuffer. Tests the
  Buffer / array / {type:'Buffer'} / base64 shapes and jpegPhoto>thumbnailPhoto, and that
  missing/empty/short values yield null (never throw).
- models/lib/importedBoardMemberPlan.js (from wekanCreator.js): planImportedBoardMember.
  Tests: unmapped member kept by original id but inactive+non-admin, mapped role preserved,
  importer and id-less members dropped.

The extractions are behaviour-preserving; the app modules now import these helpers.

Thanks to xet7.
2026-07-13 09:16:33 +03:00
Lauri Ojansivu ffc6652be0 Changelog: avatars + original members travel with a board across export/import and identity sources
Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu db90fb9e61 Members: distinguish imported placeholders and inactive members on the avatar + sidebar (Increment 5)
Make it obvious which board members are backed by a real account and which are not,
both on card avatars and in the right sidebar member list.

- userAvatar (js/jade/css): the avatar gets an 'imported-member' class (dashed grey ring
  + amber '?' corner badge) for un-reconciled board-import placeholders
  (authenticationMethod:'imported'), and an 'inactive-member' class (greyscale + dim +
  grey badge) for members deactivated on the board (e.g. a person not in LDAP after
  reconciliation). A placeholder is usually both; a real active account gets neither. The
  tooltip explains the state.
- boards.js membersForSidebar(): active members first (as before) then the inactive /
  imported ones appended, and sidebar.jade uses it, so the member list shows EVERYONE and
  which are active vs not instead of hiding inactive members.
- i18n: imported-member-no-account, inactive-member.

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu 6784726bac Imported users: reconcile placeholders with valid accounts, admin merge (Increment 4)
server/importedUserReconciliation.js turns the inert import placeholders into the right
real members deliberately, after import — never auto-mapped during import.

- reconcileImportedUsers(): for each authenticationMethod:'imported' placeholder, find a
  real account with the same username (not another placeholder) — the valid accounts are
  whatever real WeKan accounts exist, typically provisioned by LDAP/OIDC at login — and
  merge the placeholder into it; if none exists (e.g. the person is not in LDAP) leave the
  placeholder and its board memberships inactive (isActive:false).
- mergeImportedUserInto(placeholderId, targetId): reassign every reference (board.members,
  card.members, card.assignees, activities.userId, cardComments.userId) from the
  placeholder to the target, reusing the target's membership if it already is a member,
  then remove the placeholder.
- Admin-only Meteor methods reconcileImportedUsers and mergeImportedUser(placeholderId,
  targetId) for the automatic sweep and for one-off deliberate mapping.

Because reconciliation matches against existing real accounts (which LDAP populates on
login) it needs no direct LDAP query and is provider-agnostic. Merges reassign by the
preserved original _id, so nothing is lost.

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu 0c4b8f4d93 Board import: preserve original members as inert placeholder users, no mapping (Increment 3)
Importing a WeKan board no longer collapses unmapped members onto the importing user (a
wrong mapping attaches the wrong person and can leak board permissions). Instead each
original member carried in board.users becomes an inert placeholder user:

- createPlaceholderUsers() reuses the ORIGINAL _id, so every card/comment/activity
  member reference resolves to the right person with no remapping, and populates
  this.members as an identity map (id -> id) so _user() keeps the original member.
- Placeholders are created with Users.direct.insertAsync (bypassing the after.insert
  hooks — Sandstorm identity sync and the disableRegistration/invitation-code gate that
  would otherwise reject a non-account user), marked authenticationMethod:'imported',
  loginDisabled:true, isActive:false, and carry NO password or email. Username collisions
  suffix the placeholder's name so an existing account is never renamed/stolen.
- The member's avatar (embedded as base64 by the exporter) is restored into files/avatars
  via the localizer, so the original picture shows and is exportable again (Sandstorm too).
- Board membership adds imported members inactive and non-admin, so they are visible but
  hold no permissions until deliberately reconciled later; the importer stays sole admin.

Also guard the Sandstorm Users.after.insert hook to no-op on users without
services.sandstorm (placeholders), which otherwise threw reading preferredHandle.
Mapping/merging to real accounts and LDAP reconciliation are a later, deliberate step.

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu b98abe7b00 Board export: embed each member's local avatar file as base64 (Increment 2)
Board export already carried the original members' username, fullname and initials (the
field whitelist excludes passwords, emails and services). Now, for each member whose
avatar is a local WeKan file, embed the file itself as base64 (profile.avatarFile +
avatarFileName + avatarFileType) alongside the existing avatarUrl reference, in both the
in-memory build() and the streaming buildStream() paths.

Because this reads a local avatars file (no outbound fetch), it works inside a Sandstorm
grain too: a board that already has many stored avatars can be exported with every
member's picture intact, ready to re-import elsewhere. External, not-yet-localized
avatars are still exported as their URL only (Increment 1 localizes those into files
first, on board open / at login).

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu e0c76099f0 Avatars: import LDAP jpegPhoto and OIDC picture at login, localize on every login
Second set of triggers so external avatars from identity providers become local WeKan
avatars (files/avatars), shown in WeKan and carried by board export/import.

- LDAP (packages/wekan-ldap/server/sync.js): syncUserData now reads the binary
  jpegPhoto / thumbnailPhoto (tolerating Buffer / array / {type:'Buffer',data} / base64
  shapes) and, unless the user already has a locally-stored avatar, stores it as a
  self-contained data: URI in profile.avatarUrl. That displays immediately and needs no
  coupling from this package to app code; the app then materialises it to a real file.

- OIDC (packages/wekan-oidc/oidc_server.js): capture the standard  claim (with
  an OAUTH2_AVATAR_MAP override) into serviceData and set profile.avatarUrl for new users.

- App (server/avatarLocalizationOnLogin.js): Accounts.onLogin localizes the logging-in
  user's external avatar immediately (provider-agnostic), complementing the board-open
  trigger. Fire-and-forget, idempotent, never blocks login. (Inside a Sandstorm grain
  outbound fetch is sandboxed, so a Sandstorm profile picture is a best-effort no-op here
  and needs client-assisted capture — a later step.)

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu b4bfc75947 Avatars: localize board members' external avatars when a board is opened
First trigger for external-avatar localization: the single-board publication
(publishComposite('board')) now fires a best-effort, fire-and-forget
localizeBoardMemberAvatars(boardId). For every member whose profile.avatarUrl points
outside WeKan, it copies the image into WeKan's own files/avatars (via the SSRF-guarded
helper) and repoints avatarUrl at the local copy. Idempotent (already-local avatars are
skipped) and it never blocks or delays the publication.

This is the universal catch-all: no matter how a member's avatar became external
(OAuth2/OIDC picture URL, gravatar, a pasted URL), opening the board makes it a local,
exportable WeKan avatar. (Note: inside a Sandstorm grain outbound fetch is sandboxed, so
Sandstorm profile pictures need the provider-side capture added next; LDAP jpegPhoto is
binary and is imported at login, also next.)

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu dcd5f8fdd5 Avatars: add SSRF-guarded external-avatar localization helper (foundation)
First piece of making external avatars (Sandstorm profile pictures, LDAP jpegPhoto,
OAuth2/OIDC picture claims, gravatar, any URL/data-URI) into local WeKan avatar files
under WRITABLE_PATH/files/avatars, so they display without the identity provider and are
carried by board export/import.

server/lib/localizeAvatar.js:
- isExternalAvatarUrl(url): true unless already a local /cdn/storage/avatars/ (or legacy
  /cfs/files/avatars/) URL.
- localizeAvatarFromBuffer(userId, buffer, {type,name}): write bytes into the Avatars
  FilesCollection (onAfterUpload sets the user's local profile.avatarUrl).
- localizeAvatarFromUrl(userId, url): fetch (or decode a data: URI) then store.
- localizeUserAvatarIfExternal(user): idempotent no-op when already local.

Every network fetch is SSRF-guarded: http/https only; the host is resolved and any
private, loopback, link-local, CGNAT, multicast or cloud-metadata (169.254.169.254)
address is refused; 8s timeout; 5 MB cap; image content-type only. Not wired to any
trigger yet (board-open / export / login flows come next).

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu 7273220780 Updated ChangeLog. 2026-07-13 06:05:29 +03:00
Lauri Ojansivu 0d90011aff Update Sandstorm WeKan info.
Thanks to xet7 !
2026-07-13 06:04:50 +03:00
Lauri Ojansivu 2df6a77072 Updated ChangeLog. 2026-07-13 05:58:50 +03:00
Lauri Ojansivu ade9f16db4 Update Sandstorm WeKan info.
Thanks to xet7 !
2026-07-13 05:57:00 +03:00
Lauri Ojansivu 8cbfe3daee Updated ChangeLog. 2026-07-13 05:47:57 +03:00
Lauri Ojansivu 1bf68f54b2 Update Sandstorm WeKan info.
Thanks to xet7 !
2026-07-13 05:46:24 +03:00
github-actions[bot] 619eda0a9d Bump versions for v9.88 2026-07-13 02:12:35 +00:00
Lauri Ojansivu 3d2ab08294 Updated ChangeLog. 2026-07-13 05:10:47 +03:00
Lauri Ojansivu 6a12d968e0 Updated ChangeLog. 2026-07-13 05:05:53 +03:00
Lauri Ojansivu 9f23e6384b Added docs about new Sandstorm WeKan.
Thanks to xet7 !
2026-07-13 05:03:36 +03:00
Lauri Ojansivu 5d44e0b98b Sandstorm: remove the temporary MongoDB-3->FerretDB debugging diagnostics
Now that the grain boots, logs in, lists/opens boards, uploads/downloads attachments,
and migrates an old MongoDB 3 grain end to end, drop the two temporary diagnostics:

- models/attachments.js: revert the Meteor-Files debug flag to false (it was enabled on
  Sandstorm to trace the upload 'session expired [408]' issue and was spamming the grain
  log with the full FilesCollection dump and every find()).
- server/ferretdbQueryDebug.js: remove the mongodb Collection.find wrapper that logged
  FerretDB-rejected selectors (used to find the $elemMatch 'unknown operator: userId'
  issue, since fixed in wekan/FerretDB).

Thanks to xet7.
2026-07-13 04:11:22 +03:00
Lauri Ojansivu e65e06865a Changelog: Sandstorm/Snap migration modern driver, Meteor-Files GridFS + v1 binary, auto-open All Boards
Thanks to xet7.
2026-07-13 03:57:47 +03:00
Lauri Ojansivu b8bdbcd2d7 Sandstorm/Snap migration: auto-open All Boards after migration; WeKan-themed dashboard
Two UX requests once the migration itself works end to end:

- Auto-open All Boards. Previously the dashboard sat on the grain URL for 60s after
  completion and the admin had to reload to reach WeKan. On success the importer now
  exits after ~5s (long enough for the browser to fetch the done page), and the done
  page runs a JS poller that fetches '/' until WeKan's app shell answers (tolerating the
  brief importer->WeKan port hand-off gap) and then location.replace('/') opens All
  Boards. The spinner keeps spinning through the hand-off so it is clear the grain is
  still working; no meta-refresh on the done page (a refresh mid-hand-off would hit a
  dead port). On failure it still stays up a minute with the errors.

- WeKan colours. Recolour the dashboard from the dark scheme to WeKan's blue/white/grey
  (belize blue #2980b9 headings and spinner, white card on light-grey background, grey
  borders, system font).

Thanks to xet7.
2026-07-13 03:57:11 +03:00
Lauri Ojansivu 3476cd17c7 Sandstorm/Snap migration: extract Meteor-Files GridFS attachments + parse legacy v1 binary
With text collections migrating, the 3 attachments still produced 0 extracted files and
errors like 'parse attachments.chunks: Unexpected Binary Extended JSON format
{"$binary":"iVBOR..."}'. Two causes, both fixed:

1. Legacy Extended JSON v1 binary. mongo 3.x mongoexport writes binary as
   {"$binary":"<b64>","$type":"00"} (v1), but modern bson EJSON.parse only
   accepts v2 {"$binary":{"base64":...,"subType":...}}. Rewrite v1 -> v2 per line
   before parsing, so chunk data (and any binary in text collections) parses.

2. Meteor-Files native GridFS buckets were unhandled. The grain stores attachments in
   attachments.files + attachments.chunks (FilesCollection record in the attachments
   collection), not CollectionFS's cfs_gridfs.*. Reassemble those buckets to disk,
   linking each GridFS file to its record via metadata.fileId/versionName, then repoint
   the migrated record's versions.<v> at the file and DROP versions.<v>.meta.gridFsFileId
   — otherwise WeKan's getFileStrategy keeps choosing the (now-empty) GridFS backend and
   the image 404s. Also exclude <bucket>.files/.chunks (and tempstores) from the text
   migration so binary chunks are no longer run through EJSON as documents.

Thanks to xet7.
2026-07-13 03:28:13 +03:00
Lauri Ojansivu cab231ee3a Sandstorm/Snap migration: use the modern mongodb driver (OP_MSG) for FerretDB inserts
Text collections exported and 'inserted N/N' was logged, but every document actually
failed with 'Unsupported OP_QUERY command: update' — nothing reached FerretDB. Cause:
requireAny('mongodb') resolved the ANCIENT meteor-spk base driver (v2.x, at the deps
root) because the modern driver is not directly under programs/server/npm/node_modules
— Meteor nests it at .../meteor/npm-mongo/node_modules/mongodb (v6.16). The 2.x driver
speaks legacy OP_QUERY, which FerretDB rejects; the 6.x driver speaks OP_MSG (the same
driver the WeKan app uses against FerretDB). Add the npm-mongo path as the first
resolver anchor, and log the resolved driver version.

(Separately, GridFS attachment/avatar extraction still finds 0 files for this grain —
its attachments appear to be in Meteor-Files buckets (attachments.files/.chunks) rather
than the CollectionFS cfs_gridfs.* the importer currently handles; to be addressed once
the modern driver confirms text migration and the real attachment collection counts.)

Thanks to xet7.
2026-07-13 02:56:35 +03:00
Lauri Ojansivu d693ae8d7d Changelog: add individual commit links for the folded migration sub-fixes
Link the mongodb default-import, mongoexport --quiet removal, diagnostic/dashboard, and
createRequire commits inline within their parent migration bullets.

Thanks to xet7.
2026-07-13 02:44:17 +03:00
Lauri Ojansivu 5054dab8b6 Changelog: link the wiredTigerCacheSizeGB integer-value fix commit
Thanks to xet7.
2026-07-13 02:41:38 +03:00
Lauri Ojansivu 9c06bd5772 Changelog: update the migration EJSON bullet to the modern-bundle resolution fix
Thanks to xet7.
2026-07-13 02:41:01 +03:00
Lauri Ojansivu beefe441d7 Sandstorm/Snap migration: resolve bson/mongodb from the modern server bundle, not the old base
The EJSON diagnostic was conclusive: require('bson') returned an object with no EJSON,
mongodb.EJSON was undefined, and mongodb/lib/bson* were MODULE_NOT_FOUND. Cause: the
importer sits at the deps root next to the OLD meteor-spk 0.6.0 base node_modules
(bson 1.x with no EJSON at all, plus an ancient mongodb that has MongoClient but no
EJSON re-export), kept there only for the niscu->3.0 stage — so a bare require resolved
those adjacent copies. WeKan's current bson/mongodb (with EJSON) live under
programs/server/npm/node_modules. Anchor createRequire inside that modern bundle first
(falling back to the deps root), and load both mongodb and bson through it. This also
switches the importer to the modern mongodb driver — the same one the WeKan app uses
against FerretDB.

Thanks to xet7.
2026-07-13 02:36:09 +03:00
Lauri Ojansivu 0e0a8bbb33 Sandstorm/Snap migration: load mongodb/bson via createRequire; resolve EJSON robustly
The mongodb default import exposed MongoClient but NOT EJSON (mongodbPkg.EJSON was
undefined), so the previous 'take EJSON from mongodb' attempt still hit the FATAL
guard. Node 24's ESM default-interop for these CommonJS packages drops the
getter/re-export used for EJSON. Switch to createRequire (CJS require returns the real
module.exports with getters intact and honors NODE_PATH), and resolve EJSON by trying,
in order: mongodb.EJSON, mongodb/lib/bson.js, mongodb/lib/bson, bare bson's .EJSON,
and bare bson itself — falling back with a diagnostic list of what each attempt
returned so a remaining failure names the cause instead of just 'undefined'.

Thanks to xet7.
2026-07-13 02:20:23 +03:00
Lauri Ojansivu 5f40f612f5 Changelog: Sandstorm/Snap migration EJSON-from-mongodb-driver fix
Thanks to xet7.
2026-07-13 02:07:45 +03:00
Lauri Ojansivu b0703befd7 Sandstorm/Snap migration: get EJSON from the mongodb driver, not a bare bson import
With mongoexport connecting, every collection then failed with 'parse <coll>: Cannot
read properties of undefined (reading parse)' — EJSON was undefined. The importer runs
with NODE_PATH=programs/server/node_modules, but the EJSON-bearing bson (7.3.0) lives
under programs/server/npm/node_modules, so a bare 'import bson' did not resolve to it
and EJSON came out undefined. The mongodb driver (which resolves fine — MongoClient
works) bundles bson and re-exports EJSON from lib/bson.js, so take EJSON from the same
default import and drop the separate bson dependency. Add an upfront guard that fails
loudly if EJSON.parse is ever missing instead of spamming one error per collection.

Thanks to xet7.
2026-07-13 02:07:24 +03:00
Lauri Ojansivu 7f42db4670 Changelog: Sandstorm/Snap migration mongoexport IPv4 host fix + live dashboard
Thanks to xet7.
2026-07-13 01:49:45 +03:00
Lauri Ojansivu 6142396ae9 Sandstorm/Snap migration: connect mongoexport to 127.0.0.1 (IPv6 localhost broke it)
The real reason every mongoexport failed (revealed once --quiet was dropped):
'error connecting to db server: no reachable servers'. mongoexport is a Go tool
whose --host defaults to 'localhost', which resolves to ::1 (IPv6) first, but the
migration mongod listens only on --bind_ip 127.0.0.1 (IPv4) — so mongoexport could
never reach it, while the mongo shell (whose host defaults to 127.0.0.1) connected
fine and listed all 42 collections. Pass --host 127.0.0.1 explicitly.

Also fill the dashboard Activity panel from the first moment (mongoexport-ready line,
and one line per collection including empty ones) so progress is visible live instead
of the panel sitting on '(waiting…)' while only red connection errors accumulated.

Thanks to xet7.
2026-07-13 01:49:21 +03:00
Lauri Ojansivu 3076f2317e Sandstorm/Snap migration: drop mongoexport --quiet (it hid the error) + live activity log
The --version probe confirmed the bundled mongoexport runs fine (r3.2.22), yet every
per-collection export exited status=1 with empty stderr AND stdout. The empty stderr
was mongoexport's own --quiet flag suppressing its failure message. Drop --quiet so
the real reason (e.g. 'Failed: <reason>') prints to stderr; mongoexport's normal
progress lines also go to stderr, so stdout stays clean Extended JSON either way.

Also surface progress on the dashboard as the user asked: a rolling Activity log
(last ~15 lines: collections found, per-collection export/insert counts, GridFS file
extraction counts) rendered in a panel and mirrored to the grain log, so it is clear
what the migration is doing rather than only red errors appearing later.

Thanks to xet7.
2026-07-13 01:23:38 +03:00
Lauri Ojansivu 62df991532 Sandstorm/Snap migration: diagnose mongoexport failures + live progress dashboard
Every per-collection mongoexport failed with 'failed: undefined' because the error
only logged (r.stderr || r.error): an empty stderr with a non-zero/killed status
(e.g. a SIGSEGV, where spawnSync reports status=null, signal='SIGSEGV', stderr='')
printed as 'undefined', hiding the real cause. Log a full describe(r) instead
(status, signal, error code, stderr AND stdout snippets), and probe
'mongoexport --version' / '--help' once at startup so the next run shows whether the
bundled mongoexport binary itself is broken (dies on --version too → an ABI/bundling
problem) or only the export invocation is. The mongo shell on the same libs works
(listCollections succeeds), so this narrows it precisely.

Also make the progress dashboard obviously live: a CSS spinner while running (✅/❌
when done), a server 'updated' timestamp that changes on every 2s auto-refresh, and
an error counter — the previous page refreshed but showed no sign of activity.

Thanks to xet7.
2026-07-13 00:58:54 +03:00
Lauri Ojansivu 50abd8f95a Sandstorm/Snap migration: import mongodb as default (CommonJS) under Node 24 too
Same CommonJS/ESM interop issue as the bson import: with { EJSON } fixed, the
importer next threw 'Named export MongoClient not found. The requested module
mongodb is a CommonJS module'. The bundled mongodb driver is CommonJS under Node
24's ESM loader, so import the default and destructure MongoClient. These are the
only two third-party imports; the rest are node: builtins.

Thanks to xet7.
2026-07-13 00:33:32 +03:00
Lauri Ojansivu cafbb1761b Changelog: Sandstorm/Snap migration bson default-import fix for Node 24 ESM
Thanks to xet7.
2026-07-13 00:18:50 +03:00
Lauri Ojansivu a5bcb2e27c Sandstorm/Snap migration: import bson as default (CommonJS) under Node 24 ESM
With the WiredTiger cache fixed, mongod 3.0 now starts and the migration importer
runs, but it crashed immediately: 'import { EJSON } from "bson"' threw
"SyntaxError: Named export 'EJSON' not found. The requested module 'bson' is a
CommonJS module". bson (as bundled in WeKan's server node_modules) is CommonJS, so
Node 24's ESM loader exposes no named exports on it. Import the default and
destructure EJSON from it, as Node's own error message advises. Shared by the Snap
migration too (same Node 24).

Thanks to xet7.
2026-07-13 00:18:33 +03:00
Lauri Ojansivu 0804cd19e8 Sandstorm .spk: mongod 3.0 wiredTigerCacheSizeGB must be an integer (1, not 0.25)
mongod 3.0.7 parses --wiredTigerCacheSizeGB as an integer number of GB, so the
0.25 from the previous commit failed at startup with 'Error parsing option
wiredTigerCacheSizeGB as int: Bad digit "." while parsing 0.25' and the migration
still crash-looped. Use 1 (GB). It is a cache cap, not a preallocation, so a short
migration of a normal-sized grain never actually uses that much.

Thanks to xet7.
2026-07-12 23:57:43 +03:00
Lauri Ojansivu 3b109d14e8 Changelog: Sandstorm MongoDB 3 -> FerretDB migration fix (WiredTiger cache + source DB)
Thanks to xet7.
2026-07-12 23:39:09 +03:00
Lauri Ojansivu 3f5c70e643 Sandstorm .spk: fix MongoDB 3 -> FerretDB migration (WiredTiger cache=0G, wrong source DB)
Importing an existing WeKan grain crash-looped in the one-time MongoDB 3 -> FerretDB
migration: mongod 3.0 forked and its child aborted with exit code 14. Two bugs:

1. WiredTiger cache_size=0G. mongod 3.0 sizes the WiredTiger cache from detected
   RAM (RAM/2 - 1GB), but inside a Sandstorm grain sandbox RAM detection returns 0,
   so it computes cache_size=0G and WiredTiger refuses to open (minimum is 1MB),
   logging 'Value too small for key cache_size' and 'Fatal Assertion 28561' to
   /var/migration-mongod.log before aborting. Pass an explicit
   --wiredTigerCacheSizeGB 0.25 (256MB) to both mongod invocations (the niscu ->
   3.0 stage, which writes new WiredTiger data, and the 3.0 -> FerretDB stage) so
   the cache size never depends on RAM detection. The data itself is fine (turtle
   file: WiredTiger 2.5.3, i.e. written by mongod 3.0; the 'unclean shutdown'
   notice is harmless, WiredTiger recovers from the last checkpoint).

2. Wrong source database. Sandstorm WeKan grains store their data in the
   Meteor-default database 'meteor' (the niscu stage reads/writes 'meteor', and a
   real grain's WiredTiger catalog shows meteor.* collections), but the importer
   was told SRC_DB 'wekan', so even once mongod started it would have exported zero
   collections. Only the FerretDB target database is 'wekan'.

Thanks to xet7.
2026-07-12 23:38:52 +03:00
Lauri Ojansivu 455e498482 Changelog: Sandstorm open All Boards + grain URL sync fix
Thanks to xet7.
2026-07-12 23:12:45 +03:00
Lauri Ojansivu 3ab2b9dccb Sandstorm .spk: open All Boards (not sign-in) and keep the grain URL in sync
Two grain-navigation regressions vs the last working Sandstorm build (v6.15):

1. Opening a grain showed 'Must be logged in' instead of the All Boards page.
   On Sandstorm the platform authenticates the user asynchronously over DDP via
   connection.setUserId(), which (unlike a password login) does not set
   Meteor.loggingIn(), so Meteor.userId() is null for the first moments after the
   grain opens. useraccounts' ensureSignedIn route trigger and renderBoardList()
   both bounced that brief null window to the atSignIn route — a sign-in page that
   does not exist inside a grain. config/router.js now uses a Sandstorm-aware
   ensureSignedInUnlessSandstorm wrapper (a no-op on Sandstorm) and renderBoardList()
   no longer redirects on Sandstorm; the All Boards list renders and reactively
   fills in once the platform login lands.

2. The Sandstorm shell's outer grain URL did not update when switching boards. The
   shell rewrites /grain/<id><path> when the app posts a { setPath } message
   (sandstorm shell/imports/client/grain-client.js). The sync was a bare top-level
   Tracker.autorun that could run before flow-router-extra's reactive path tracking
   was initialised and then never re-run. Restore v6.15's event-driven
   FlowRouter.triggers.enter (fresh entering path, no dependency on init order) and
   additionally keep a watchPathChange() autorun wrapped in Meteor.startup as a
   backup. setTitle now also re-runs on route change.

Thanks to xet7.
2026-07-12 23:12:07 +03:00
Lauri Ojansivu d79f90fdfe Changelog: Sandstorm attachment/avatar download authorization via X-Sandstorm-User-Id
Thanks to xet7.
2026-07-12 22:55:11 +03:00
Lauri Ojansivu 1ca7bd3bb6 Sandstorm .spk: authorize attachment/avatar downloads via X-Sandstorm-User-Id
On Sandstorm the grain download route returned 403 for private boards, so
attachment thumbnails, minicard covers and the slideshow all showed broken
images even though the upload succeeded and the file was on disk. The download
handler (server/routes/universalFileServer.js) authorizes private-board files
via a Meteor login token (Authorization / X-Auth-Token / authToken query /
meteor_login_token cookie), but Sandstorm has none of these: authentication is
via connection.setUserId() and the sandstorm-http-bridge X-Sandstorm-* request
headers, so extractLoginToken() returned null and isAuthorizedForBoard() denied
every request. Sandstorm already gates grain access at the platform level, so any
request reaching WeKan is an authenticated grain user; trust it. Both
isAuthorizedForBoard() and isAuthorizedForAvatar() now short-circuit to allow
when Meteor.settings.public.sandstorm is set and the request carries the
bridge-injected X-Sandstorm-User-Id header. Gating on the setting means a spoofed
header cannot bypass auth on non-Sandstorm deployments.

Thanks to xet7.
2026-07-12 22:54:52 +03:00
Lauri Ojansivu 525cd07eee Changelog: add the Sandstorm DDP attachment-upload fix to Upcoming
Link the fix that switches attachment uploads to DDP transport on Sandstorm, since
the bridge strips Meteor-Files' custom x-* HTTP upload headers.

Thanks to xet7.
2026-07-12 22:29:06 +03:00
Lauri Ojansivu dc8ff6e6ca Sandstorm .spk: upload attachments over DDP (bridge strips Meteor-Files' HTTP headers)
Adding a file to a card in the grain failed with HTTP 400 "Can't continue upload,
session expired [408]" and the file silently disappeared. The Meteor-Files HTTP
upload protocol signals the first chunk with an x-start header and tracks the
session with x-mtok / x-chunkid / x-fileid / x-eof, but Sandstorm's request-header
whitelist (web-session.capnp) does not include those, so the sandstorm-http-bridge
strips them: the server never sees x-start, treats every request as a chunk
continuation, cannot find the per-fileId session and returns 408. Use
transport: 'ddp' on Sandstorm (detected via Meteor.settings.public.sandstorm) so
uploads go over DDP method calls instead of custom HTTP headers; HTTP transport is
kept everywhere else (it avoids the Safari DDP-reconnect stall).

Thanks to xet7.
2026-07-12 22:28:28 +03:00
Lauri Ojansivu c671a7fc8c Sandstorm .spk: temporarily enable Meteor-Files debug logging on Sandstorm
Attachment uploads in the grain fail with HTTP 400 "Can't continue upload, session
expired [408]": the upload start succeeds but the per-fileId session is gone by the
time a chunk arrives (both the in-memory _currentUploads entry and the
__pre_attachments pre-collection doc are missing), which points at the polling
observe (FerretDB has no oplog) firing a spurious removed event and aborting the
upload. The Attachments FilesCollection had debug: false, so nothing about the
upload path is logged. Enable Meteor-Files debug on Sandstorm only (gated on
Meteor.settings.public.sandstorm) so the grain debug log shows the start /
createStream / observer / continueUpload steps and pinpoints where the session is
lost. TEMPORARY: revert to debug: false once the upload is fixed.
2026-07-12 22:09:57 +03:00
Lauri Ojansivu d5092c9b2d Changelog: add the Sandstorm grain-URL sync fix to Upcoming
Link the fix that keeps the Sandstorm shell's grain URL in sync with the in-app
route via a reactive autorun.

Thanks to xet7.
2026-07-12 21:38:07 +03:00
Lauri Ojansivu 68e59ab66f Sandstorm .spk: keep the grain URL in sync with the in-app route
Navigating between boards worked but the Sandstorm shell's grain URL never updated
(it stayed on the grain root), unlike standalone WeKan. The path was synced via a
global FlowRouter.triggers.enter callback, which does not fire reliably on client
navigation in this flow-router-extra / Meteor 3 setup. Sync it from a reactive
Tracker.autorun on FlowRouter.watchPathChange() instead (the same mechanism the
setTitle sync already uses), so every route change posts setPath to the shell and
the grain URL becomes /grain/<id><path>.

Thanks to xet7.
2026-07-12 21:37:37 +03:00
Lauri Ojansivu 11f87c2115 Sandstorm .spk: temporary diagnostic to log the exact FerretDB-rejected query
Opening a board in the grain fails with "MongoServerError: unknown operator: userId"
(BadValue) - FerretDB rejecting a query MongoDB accepts - but the driver stack trace
has no WeKan call site, so the offending selector is unknown. This wraps the raw
mongodb Collection.find() to log the collection name and exact selector whenever a
cursor iteration throws, to pinpoint the query. TEMPORARY: to be removed once the
query is rewritten FerretDB-compatibly.

Thanks to xet7.
2026-07-12 19:57:16 +03:00
Lauri Ojansivu 21644652e2 Changelog: add the Sandstorm sign-in bounce fix to Upcoming
Link the fix that redirects from the sign-in page to the boards list once the
asynchronous Sandstorm auto-login sets Meteor.userId().

Thanks to xet7.
2026-07-12 18:47:30 +03:00
Lauri Ojansivu 6f3174b23d Sandstorm .spk: bounce from sign-in to the boards list once auto-login lands
The Sandstorm login was actually succeeding - Meteor.userId() gets set (confirmed:
Meteor.connection._userId held a valid id, Meteor.loggingIn() false) - but the grain
stayed on the sign-in page. WeKan's home route (renderBoardList) checks
Meteor.userId() once and, because the Sandstorm header login is asynchronous and
uses connection.setUserId() (bypassing accounts-base, so no Accounts.onLogin fires),
finds it still null on first render and redirects to 'atSignIn' - where the user is
stranded even after login completes moments later. Add a Sandstorm-only reactive
autorun that sends the user from 'atSignIn' back to 'home' as soon as
Meteor.userId() is set, so the grain lands on the All Boards page.

Thanks to xet7.
2026-07-12 18:47:01 +03:00
Lauri Ojansivu fb0c7f7f60 Changelog: add the ROOT_URL-per-request Sandstorm login/CORS fix to Upcoming
Link the fix that rewrites ROOT_URL to the grain's real URL so DDP, dynamic
imports and the Sandstorm auto-login stop failing cross-origin.

Thanks to xet7.
2026-07-12 18:15:37 +03:00
Lauri Ojansivu d5f707fd37 Sandstorm .spk: rewrite ROOT_URL per request to the grain URL (fixes login + CORS)
WeKan loaded but stayed on "Must be logged in", and the console showed
"Cross-Origin Request Blocked ... http://127.0.0.1:4000/__meteor__/dynamic-import/fetch".
The launcher sets a fixed ROOT_URL (http://127.0.0.1:4000, the internal bridge
target), but Sandstorm serves each grain at a per-session host (ui-<hash>.<host>)
via sandstorm-http-bridge, so the client sent its DDP connection and dynamic-import
fetches to 127.0.0.1:4000 - cross-origin and unreachable from the browser - and the
header-based login handshake (a DDP method call) never completed. Add a
WebApp.addRuntimeConfigHook that rewrites ROOT_URL to the grain's real base URL
(X-Sandstorm-Base-Path) per request, and re-asserts the SANDSTORM flag, so the
client talks to the same origin it loaded from. DDP, dynamic imports and the
Sandstorm auto-login now work.

Thanks to xet7.
2026-07-12 18:15:15 +03:00
Lauri Ojansivu be8976ed8c Changelog: add the SANDSTORM=1 auto-login fix to Upcoming
Link the fix that sets process.env.SANDSTORM=1 in the launcher so the
Sandstorm header-based auto-login runs and pages stop showing "Must be logged in".

Thanks to xet7.
2026-07-12 17:57:43 +03:00
Lauri Ojansivu b0712e6656 Sandstorm .spk: set SANDSTORM=1 so the header-based auto-login runs
WeKan loads in the grain but every page showed "Must be logged in". The
wekan-accounts-sandstorm client only starts the automatic X-Sandstorm-* header
login when __meteor_runtime_config__.SANDSTORM is set, and the package only sets
that when process.env.SANDSTORM is present - which nothing did (start.js set
MONGO_URL/ROOT_URL/PORT/WRITABLE_PATH but not SANDSTORM, and the pkgdef env didn't
either). The launcher now sets process.env.SANDSTORM = '1' before loading the WeKan
bundle, so the package enables the runtime flag and the client auto-logs-in the
Sandstorm user from the X-Sandstorm-* headers. (App code detects Sandstorm via a
separate Meteor.settings.public.sandstorm flag from the pkgdef METEOR_SETTINGS,
which already worked.)

Thanks to xet7.
2026-07-12 17:57:21 +03:00
Lauri Ojansivu ad46cb6102 Changelog: add the no-auto-board + global-role Sandstorm change to Upcoming
Link the change that stops auto-creating a board on a new grain and maps the
grain's Sandstorm permissions to the user's global WeKan role.

Thanks to xet7.
2026-07-12 17:40:33 +03:00
Lauri Ojansivu 8f11e8ac66 Sandstorm .spk: don't auto-create a board; map grain permissions to global role
WeKan on Sandstorm is multi-board now, so a new grain/user no longer gets a
hard-coded 'sandstorm'/libreboard board auto-created - the user creates their own
boards from the All Boards page. The Users.after.insert hook no longer inserts that
board + Default swimlane. updateUserPermissions previously added the user as a
member of that single board (which would now crash, since the board no longer
exists); it now maps the grain's Sandstorm permissions to the user's GLOBAL WeKan
role instead - 'configure' (grain owner) becomes a WeKan admin, everyone else is a
regular user who can create and manage their own boards. Dropped the now-unused
Boards/Swimlanes imports.

Thanks to xet7.
2026-07-12 17:40:09 +03:00
Lauri Ojansivu bb8233655c Changelog: add the "open on All Boards" Sandstorm change to Upcoming
Link the change that removes the hard-coded single-board redirect so the grain
opens on the All Boards page.

Thanks to xet7.
2026-07-12 17:37:36 +03:00
Lauri Ojansivu b5945cf596 Sandstorm .spk: open the grain on the All Boards page, not a hard-coded board
WeKan on Sandstorm originally opened a single hard-coded board
(/b/sandstorm/libreboard) and redirected "/" to it. It now supports many boards and
that libreboard is no longer the right destination, so redirecting to it is wrong.
Remove the WebApp.handlers.get("/") redirect entirely: "/" now falls through to
WeKan's normal serving, whose client "home" route (config/router.js) renders the
All Boards list - the right landing page for a multi-board grain. This also removes
the server-side redirect that browsers rejected as a Corrupted Content Error
(FlowRouter.path does not resolve on the server + Content-Length mismatch).

Thanks to xet7.
2026-07-12 17:36:16 +03:00
Lauri Ojansivu 6e60644bdb Changelog: add the "/" redirect (corrupted content) fix to Upcoming
Link the fix that builds the Sandstorm board redirect path directly and sends a
body with a matching Content-Length, so the grain's "/" stops failing with a
browser Corrupted Content Error.

Thanks to xet7.
2026-07-12 17:33:21 +03:00
Lauri Ojansivu ccdd1a0846 Sandstorm .spk: fix the "/" redirect (malformed Location + Content-Length mismatch)
The grain's "/" served a broken 301: Location was ".../:6080board" because
FlowRouter.path() does not resolve on the server in Meteor 3.x (it returned the
bare route name "board"), and the response advertised Content-Length: 90 while
sending an empty body, which the browser rejected as a "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT). Build the board path directly (/b/:id/:slug, the
'board' route) and send a real HTML body (meta-refresh + link) with a matching
Content-Length, so the redirect to the hard-coded Sandstorm board works. Together
with the Accept-Encoding strip (keeps Content-Length valid) and the modern bridge.

Thanks to xet7.
2026-07-12 17:32:54 +03:00
Lauri Ojansivu 04737bd76e Changelog: add the modern sandstorm-http-bridge fix to Upcoming
Link the fix that bundles a modern sandstorm-http-bridge (from the Sandstorm
install) so the grain stops serving Corrupted Content on WeKan's "/" redirect.

Thanks to xet7.
2026-07-12 04:28:04 +03:00
Lauri Ojansivu 7f0706e45f Sandstorm .spk: bundle a modern sandstorm-http-bridge to fix "Corrupted Content"
The grain boots, but the page failed with a browser "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT) on WeKan's "/" redirect (0 bytes, ~8.5s, then
failed). The meteor-spk 0.6.0 base bundles an ancient (~2016) sandstorm-http-bridge
that mangles responses - it always advertises Accept-Encoding: gzip to the app and
mishandles redirect/encoding. build-deps.sh now overwrites the bundled
/sandstorm-http-bridge with the modern one from a Sandstorm install
(/opt/sandstorm/latest/bin/sandstorm-http-bridge; override with SANDSTORM_HTTP_BRIDGE).
Sandstorm is amd64-only so one binary covers it. The sandstorm.yml workflow now
installs Sandstorm before assembling the deps so the bridge is available on the CI
runner. Complements the app-side Accept-Encoding strip.

Thanks to xet7.
2026-07-12 04:27:40 +03:00
Lauri Ojansivu ab8da931cf Changelog: add the Sandstorm Accept-Encoding (corrupted content) fix to Upcoming
Link the fix that strips Accept-Encoding in the Sandstorm grain so Meteor serves
uncompressed responses and the browser stops showing a Corrupted Content Error.

Thanks to xet7.
2026-07-12 03:56:02 +03:00
Lauri Ojansivu 013b6b5266 Sandstorm .spk: strip Accept-Encoding so the grain doesn't serve corrupted (gzip) content
The grain now boots, but the page failed to load with a browser "Corrupted Content
Error" (NS_ERROR_NET_CORRUPTED_CONTENT). sandstorm-http-bridge advertises
"Accept-Encoding: gzip" to the app regardless of what the browser actually sent, so
Meteor served gzip/brotli-encoded responses the browser could not decode. Strip
Accept-Encoding in a WebApp.rawHandlers middleware (which runs before Meteor's
static/boilerplate serving) so responses go out uncompressed; bandwidth is a
non-issue behind the local bridge.

Thanks to xet7.
2026-07-12 03:55:37 +03:00
Lauri Ojansivu 0115622a9a Changelog: add the FerretDB boolean-index-option fix to Upcoming
Link the fix that makes the services.sandstorm.id unique index use boolean
options so FerretDB accepts it and the Sandstorm grain boots past index creation.

Thanks to xet7.
2026-07-12 03:39:35 +03:00
Lauri Ojansivu 975316f5cd Sandstorm .spk: use boolean index options so FerretDB accepts the users index
wekan-accounts-sandstorm created the unique index on services.sandstorm.id with
{unique: 1, sparse: 1}. Real MongoDB accepts the truthy 1, but FerretDB (used by
the Sandstorm .spk) is strict and rejects it with "The field 'unique' has value
unique: 1, which is not convertible to bool", crashing the grain at boot during
index creation. Use real booleans (unique: true, sparse: true).

Thanks to xet7.
2026-07-12 03:39:17 +03:00
Lauri Ojansivu 54aad554aa Changelog: add the sandstorm.js stale-globals boot fix to Upcoming
Link the fix that imports the Users/Boards/Swimlanes/Activities collections and
drops the removed-HTTP-package monkey patch, so the Sandstorm grain boots past
the capnp guard.

Thanks to xet7.
2026-07-12 03:27:25 +03:00
Lauri Ojansivu 60e40199c9 Sandstorm .spk: fix server-boot crashes from stale globals (Users, HTTP) in sandstorm.js
Once the capnp load was made non-fatal, the grain reached the rest of sandstorm.js
and hit two latent Meteor-2.x-isms the Meteor 3.x migration missed (this file only
runs on Sandstorm, so it was not exercised): (1) it referenced the Users, Boards,
Swimlanes and Activities collections as implicit globals, but those are now ES
module default exports, so `Users.after.insert` threw "Users is not defined" at
boot; and (2) it monkey-patched `HTTP.methods` from the meteor/http package, which
was removed in the Meteor 3.x migration, so `HTTP` was undefined. Import the
collections (and Accounts) explicitly, and remove the obsolete HTTP.methods patch
(WeKan's REST API no longer uses it - it uses WebApp/JsonRoutes with header auth).

Thanks to xet7.
2026-07-12 03:27:02 +03:00
Lauri Ojansivu ff3f35ac43 Changelog: add the split-snap-build (native amd64/arm64 + non-blocking Launchpad) to Upcoming
Link the release-workflow change that builds amd64/arm64 natively on GitHub and
moves the exotic arches to a non-blocking per-arch Launchpad matrix, so the
release no longer hangs hours on riscv64.

Thanks to xet7.
2026-07-12 03:21:30 +03:00
Lauri Ojansivu 8549a62c85 Snap release: build amd64+arm64 natively on GitHub, exotic arches non-blocking on Launchpad
The single snap job ran `snapcraft remote-build` for all 5 platforms on Launchpad
and blocked until the slowest resolved, so the release hung ~3.5h on riscv64's
Launchpad queue even though amd64+arm64 finished in minutes. Split it in two:
snap-native builds amd64 (ubuntu-24.04) and arm64 (ubuntu-24.04-arm) NATIVELY on
GitHub runners via snapcore/action-build; snap-launchpad builds s390x/ppc64el/
riscv64 on Launchpad in a NON-BLOCKING (continue-on-error), per-arch matrix
(remote-build --build-for <arch>) so a slow or failed exotic arch never delays the
release or the other arches. Each arch publishes to the Snap Store
(candidate,beta,edge) and attaches to the GitHub Release the moment it finishes.
All 5 arches still ship.

Thanks to xet7.
2026-07-12 03:21:03 +03:00
Lauri Ojansivu 8b9202d677 Changelog: add the Sandstorm capnp grain-boot fix to Upcoming
Link the fix that stops capnp.node's Node 24 ABI mismatch from crash-looping
the Sandstorm grain; the grain now boots and core WeKan works via the
sandstorm-http-bridge headers.

Thanks to xet7.
2026-07-12 03:08:53 +03:00
Lauri Ojansivu f4f22bbe24 Sandstorm .spk: don't crash the grain when capnp.node can't load on Node 24
WeKan on Sandstorm bundles Node 24, but capnp.node (node-capnp) is built for an
older Node ABI (NODE_MODULE_VERSION 83 = Node 14), so `Npm.require('capnp')` in
sandstorm.js failed with ERR_DLOPEN_FAILED and crash-looped the whole grain on
boot. Load Cap'n Proto lazily in a try/catch and degrade gracefully: the grain
boots and core WeKan works, because login and user identity come from the
sandstorm-http-bridge X-Sandstorm-* HTTP headers (wekan-accounts-sandstorm) and
need no Cap'n Proto. Only the two capnp-only features are skipped when the addon
cannot load - the Powerbox identity-claim method and the Sandstorm activity
notifications - and they log a clear warning. These can be restored by rebuilding
node-capnp for Node 24, or reimplemented over the bridge's HTTP/JSON API (see the
Sandstorm CHANGELOG: use Cap'n Proto APIs without Cap'n Proto).

Thanks to xet7.
2026-07-12 03:08:23 +03:00
Lauri Ojansivu 32b561cfbf Changelog: add the FerretDB state-dir Sandstorm fix to Upcoming
Link the fix that points FerretDB's --state-dir to writable /var so the
Sandstorm grain starts instead of crash-looping on a read-only /state.json.

Thanks to xet7.
2026-07-12 02:25:07 +03:00
Lauri Ojansivu 1c28b40fd4 Sandstorm .spk: point FerretDB state dir to writable /var so the grain starts
FerretDB persists a state.json (version/UUID) via its state provider even
when telemetry is disabled, and its --state-dir defaults to "." — which in a
Sandstorm grain is "/", read-only. So FerretDB failed with "Failed to create
state provider: failed to persist state: open /state.json: read-only file
system", exited (code 1), and the grain crash-looped. sandstorm-src/start.js
now creates /var/ferretdb and passes --state-dir=/var/ferretdb (plus the
FERRETDB_STATE_DIR env) in startFerret(), covering both the migration and the
steady-state FerretDB launches.

Thanks to xet7.
2026-07-12 02:24:27 +03:00
Lauri Ojansivu 7f5cc03ea9 Changelog: add the Sandstorm .spk and Meteor unit tests fixes to Upcoming
List the glibc-loader grain crash-loop fix, the Sandstorm build-workflow
userns/ref fix, and the Meteor unit tests __dirname server-boot fix in the
Upcoming release section, each linking to its commit.

Thanks to xet7.
2026-07-12 01:58:12 +03:00
Lauri Ojansivu 60116bc72f Meteor unit tests: fix server-boot crash from __dirname in an ESM test file
server/lib/tests/dependencies.openapi.tests.js is an ES module (top-level
import) that referenced the bare __dirname global. Under Node 24 / Meteor 3.5
the compiler injects "const __dirname = fileURLToPath(import.meta.url)" for
ESM files that use __dirname, which collides with the __dirname the CommonJS
module wrapper already provides, so the whole server bundle failed to boot
with "Identifier '__dirname' has already been declared" and the "Meteor unit
tests" CI job died before any test ran. Drop the __dirname seed
(process.env.PWD already reaches the repo root under `meteor test`, and the
suite skips gracefully otherwise).

Thanks to xet7.
2026-07-12 01:57:23 +03:00
Lauri Ojansivu 66d8706c4d Sandstorm build workflow: enable unprivileged user namespaces and build the dispatched branch
Ubuntu 24.04 defaults kernel.apparmor_restrict_unprivileged_userns=1, which
blocks the unprivileged user namespaces the Sandstorm install and the spk
supervisor rely on, so sandstorm.yml now relaxes it on the runner. Also added
a "ref" workflow_dispatch input and check out that ref instead of a hardcoded
main, so a fix branch can be built and tested before it is merged to main.

Thanks to xet7.
2026-07-12 01:57:23 +03:00
Lauri Ojansivu 143607ef22 Sandstorm .spk: bundle the matching glibc dynamic loader so grains start
The .spk bundled the host's new glibc libc.so.6 (Ubuntu 24.04, glibc 2.39)
but kept the old glibc 2.31 ld-linux from the meteor-spk 0.6.0 base, so node
failed at startup with "libc.so.6: undefined symbol: _dl_audit_symbind_alt,
version GLIBC_PRIVATE" (HTTP-BRIDGE exit 127) and the grain crash-looped.
sandstorm-src/build-deps.sh now also copies the host's ld-linux-x86-64.so.2
into the deps so the loader and libc are the same glibc, and adds a [verify]
gate that fails the build if the bundled libc and loader glibc versions
differ or the bundled node cannot run under them - turning a silent grain
crash-loop into a loud build failure.

Thanks to xet7.
2026-07-12 01:57:23 +03:00
Lauri Ojansivu 0f52b6a787 Merge pull request #6447 from wekan/dependabot/npm_and_yarn/mongodb-7.5.0
Bump mongodb from 7.3.0 to 7.5.0
2026-07-12 00:23:37 +03:00
Lauri Ojansivu 87f4536e9d Bump @aws-sdk/client-s3 to ^3.1085.0 to match @aws-sdk/lib-storage 3.1085.0
Dependabot #6451 bumped lib-storage to ~3.1085.0, whose peer requires
client-s3 ^3.1085.0, but client-s3 was left at ^3.1071.0 — breaking
meteor npm install / npm ci with an ERESOLVE peer conflict.
2026-07-12 00:21:55 +03:00
Lauri Ojansivu cdde52392b sandstorm: trim .spk toward Cloudflare's 100 MB upload limit
- Drop the ~300 MB of modern MongoDB Database Tools (wekan/mongo-tools) from the
  Sandstorm build: they are unused there (Sandstorm backs up/restores grains
  itself; the MongoDB 3 -> FerretDB migration uses the legacy migratemongo CLIs +
  the .mjs importer). They stay in the .zip bundle / Docker / Snap, which use them.
- Set PUPPETEER_SKIP_DOWNLOAD=1 for the Meteor build (puppeteer is a test-only
  devDependency; its ~150 MB Chromium must never end up in the .spk).
- Pack step prints the .spk size and warns when it exceeds 100 MB.
2026-07-12 00:02:07 +03:00
dependabot[bot] 62f93fa46c Bump mongodb from 7.3.0 to 7.5.0
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.3.0 to 7.5.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.3.0...v7.5.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:47:29 +00:00
Lauri Ojansivu 5b3a3fb744 Merge pull request #6451 from wekan/dependabot/npm_and_yarn/aws-sdk/lib-storage-3.1085.0
Bump @aws-sdk/lib-storage from 3.1073.0 to 3.1085.0
2026-07-11 23:39:47 +03:00
Lauri Ojansivu 349f4aa046 Merge pull request #6452 from wekan/dependabot/npm_and_yarn/i18next-26.3.6
Bump i18next from 26.3.4 to 26.3.6
2026-07-11 23:39:10 +03:00
Lauri Ojansivu ae1f37658c Merge pull request #6449 from wekan/dependabot/npm_and_yarn/puppeteer-25.3.0
Bump puppeteer from 25.1.0 to 25.3.0
2026-07-11 23:38:24 +03:00
Lauri Ojansivu df94214798 Merge pull request #6448 from wekan/dependabot/npm_and_yarn/tests/playwright/mongodb-7.5.0
Bump mongodb from 7.4.0 to 7.5.0 in /tests/playwright
2026-07-11 23:37:56 +03:00
Lauri Ojansivu 6c22e43be8 Merge pull request #6450 from wekan/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.63.0
Bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.63.0
2026-07-11 23:37:29 +03:00
dependabot[bot] 5e5baf5ed7 Bump i18next from 26.3.4 to 26.3.6
---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:34:11 +00:00
dependabot[bot] 713c13831b Bump @aws-sdk/lib-storage from 3.1073.0 to 3.1085.0
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.1073.0 to 3.1085.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1085.0/lib/lib-storage)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:33:55 +00:00
dependabot[bot] e400ecf548 Bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.63.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.61.1 to 8.63.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.63.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:33:27 +00:00
dependabot[bot] e9db3a8c2f Bump puppeteer from 25.1.0 to 25.3.0
Bumps [puppeteer](https://github.com/puppeteer/puppeteer) from 25.1.0 to 25.3.0.
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-v25.1.0...puppeteer-v25.3.0)

---
updated-dependencies:
- dependency-name: puppeteer
  dependency-version: 25.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:32:58 +00:00
dependabot[bot] 260a2afacc Bump mongodb from 7.4.0 to 7.5.0 in /tests/playwright
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.4.0 to 7.5.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.4.0...v7.5.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:32:45 +00:00
Lauri Ojansivu a8cf44cfaa sandstorm.yml: write signing keyring to ~/.sandstorm-keyring (path meteor-spk reads)
meteor-spk pack crashed with 'open(/home/runner/.sandstorm-keyring): No such file
or directory' because the keyring restore step wrote the decoded SANDSTORM_KEYRING
secret to ~/.sandstorm/sandstorm-keyring, but spk reads the app private key from
~/.sandstorm-keyring (a file directly in $HOME). Write it to the correct path, and
fail early with an actionable message when the secret is missing (the .spk cannot
be signed without it) instead of the cryptic later crash.
2026-07-11 23:12:37 +03:00
Lauri Ojansivu d53e7cafbb sandstorm build-deps.sh: fix 'not writing through dangling symlink' at step 3/7
The meteor-spk 0.6.0 base ships some runtime libs in meteor-spk.deps/lib as
DANGLING symlinks (e.g. libstdc++.so.6 -> a lib that no longer exists). When step
[3/7] refreshed them with the host's newer libs, 'cp -fL' refused with 'not writing
through dangling symlink' and failed the Sandstorm build. Add --remove-destination
so cp deletes the existing destination (dangling symlink included) before copying
the host's real library. Verified: plain 'cp -fL' reproduces the error; with
--remove-destination it succeeds and writes a real file.
2026-07-11 22:59:28 +03:00
github-actions[bot] f278d9ae68 Bump versions for v9.87 2026-07-11 19:44:03 +00:00
Lauri Ojansivu a700152e58 Updated ChangeLog. 2026-07-11 22:42:47 +03:00
Lauri Ojansivu b5f9097681 Updated translations. 2026-07-11 22:42:29 +03:00
Lauri Ojansivu b227a93193 Fix #6446: let board members (not only admins) move cards between swimlanes
In the swimlanes view, boardBody.js disabled the .js-swimlanes sortable — which
also carries moving a card from one swimlane to another — with !isBoardAdmin(),
though its own comment said it should be disabled only for non-members. So a normal
board member could move a card within a swimlane but not between swimlanes (and
could not reorder swimlanes). Gate it on !Utils.canModifyCard() instead (the client
write-access predicate: board member, not comment-only/worker/read-only), matching
the server's allowIsBoardMemberWithWriteAccess.
2026-07-11 22:38:18 +03:00
Lauri Ojansivu 52c62021a9 Updated translations 2026-07-11 22:10:54 +03:00
Lauri Ojansivu 692a258758 Updated ChangeLog. 2026-07-11 22:08:40 +03:00
Lauri Ojansivu 8847798f3c Release: update official + unofficial Node.js download URL paths on wekan.fi install page
The install page (../w/wekan.fi/install/index.html) links to Node.js downloads for
each snap/bundle arch: the OFFICIAL nodejs.org build (amd64/arm64/s390x/ppc64le)
and the UNOFFICIAL unofficial-builds.nodejs.org build (riscv64, which nodejs.org
has no binary for). release-website.sh only updated the displayed Node.js version
number, and version.sh's local website block did not touch Node.js at all, so those
download URLs went stale (404) after a Node.js bump.

Both now rewrite the version in the nodejs.org/dist/vX.Y.Z and
unofficial-builds.nodejs.org/download/release/vX.Y.Z paths and the
node-vX.Y.Z-linux-<arch> filename to the current Dockerfile NODE_VERSION.
2026-07-11 22:08:40 +03:00
github-actions[bot] 087d9fd0d8 Bump versions for v9.86 2026-07-11 17:54:20 +00:00
Lauri Ojansivu da55f1c413 Updated ChangeLog. 2026-07-11 20:53:16 +03:00
Lauri Ojansivu 28b4c6687c gitignore: also ignore locally-cloned wekan/mongo-tools and wekan/mongosh
FerretDB/ and sandstorm/ were already ignored; add /mongo-tools/ and /mongosh/ so
that temporarily cloning those wekan repos under this repo for editing never risks
committing them (they are separate git repos).
2026-07-11 20:23:17 +03:00
Lauri Ojansivu 10db1c3e76 Snap: automated release publishes to candidate + beta + edge (core24), not stable
core24 allows the candidate channel (core26 does not), so the automated
release-all.yml snap job publishes to candidate + beta + edge. The stable channel
is not published by the workflow — it is done manually later once proven. Updated
the workflow, snapcraft.yaml header, CHANGELOG and CPU-platforms.md.
2026-07-11 20:18:19 +03:00
Lauri Ojansivu 1a376b5dde Snap: automated release publishes to edge + beta (stable is manual, core24)
The default snapcraft.yaml is base: core24 (grade: stable) so it CAN be published
to the Snap stable channel, but that is done manually once it is proven stable
enough (core26 cannot go to stable). Revert the automated release-all.yml snap job
to publish to edge + beta channels (was candidate + edge). Update the workflow
header, snapcraft.yaml header, CHANGELOG and CPU-platforms.md to match.
2026-07-11 20:15:57 +03:00
Lauri Ojansivu e20dd132c1 Bundle-centric snap; self-contained bundle launchers restart-loop FerretDB + WeKan
Bundle launchers (releases/ferretdb/start-wekan.sh/.bat): run the bundle's own
per-platform binaries — FerretDB (./ferretdb[.exe]) in the background and WeKan
(./main.js on the bundled ./node[.exe]) in the foreground — inside a restart loop,
so if WeKan exits the whole stack restarts. Ctrl-C / closing the window stops both.

Snap wekan part: the wekan-<arch>.zip bundle is now the single source of every
binary. Handle all snap arches (amd64, arm64, s390x, ppc64el->ppc64le, riscv64);
use the bundle's OWN arch-correct Node.js (fixes riscv64, which nodejs.org has no
build for) instead of re-downloading from nodejs.org; move the bundle's per-arch
MongoDB Database Tools into $SNAP/bin; keep the bundle's ferretdb. Drop the
separate mongotools part (the tools already ship in the bundle), so nothing is
downloaded twice.
2026-07-11 20:13:00 +03:00
Lauri Ojansivu 64da38b412 Embed MongoDB Database Tools (wekan/mongo-tools) in all WeKan builds
Previously the per-arch MongoDB Database Tools were only in the Snap and the
Windows bundle. Now every build embeds them from the wekan/mongo-tools fork's
newest release (per-arch <tool>-<arch>):

- Linux .zip bundles (amd64/arm64/s390x/ppc64le/riscv64) download their arch's 8
  tools; the win64 and macOS jobs drop the inherited Linux tools and add their own
  (win64 .exe, mac-arm64). The Docker image inherits them from the Linux bundle.
- Sandstorm build-deps.sh adds the amd64 tools (separate from the legacy MongoDB
  3.2 migratemongo CLIs used for the one-time migration).

All from wekan/mongo-tools, none from mongodb.com.
2026-07-11 20:02:57 +03:00
Lauri Ojansivu 2a6bdadc15 Drop mongosh, use bundled Node.js + mongodb driver; FerretDB/mongo-tools per-arch; snap FerretDB-only arches
Databases / MongoDB clients:
- Remove the mongosh binary everywhere (snap + bundles). All scripted DB access
  (readiness ping, replica-set initiate/status, v8.43 schema migration) now runs
  through the new snap-src/bin/db-eval (bundled Node.js 24 + the mongodb driver).
  Ports wekan-control, mongodb-control, migration-control, start-wekan.sh/.bat and
  migrate-schema-v843.js -> .mjs off mongosh. The legacy MongoDB 3.2 mongo shell
  (migratemongo, amd64) stays for migration-time reads only.
- MongoDB Database Tools now come from the wekan/mongo-tools fork (pure Go, every
  arch) instead of mongodb.com, in snapcraft and the Windows bundle. version.sh /
  test-download-urls.sh no longer probe mongodb.com for mongosh/tools. The MongoDB 7
  server (mongod) is still fetched from MongoDB (amd64/arm64 only).
- FerretDB v1 downloaded per-arch (ferretdb-<arch>) from wekan/FerretDB, no
  ferretdb.zip: release-all.yml bundle jobs, docker-compose.yml, sandstorm build.

Snap:
- Default snapcraft.yaml is now the core24 grade:stable build (core26 variant kept
  as snapcraft-core26.yaml); release publishes to candidate + edge.
- Add s390x, ppc64el, riscv64 platforms as FerretDB-only (mongod not built there);
  new installs default to FerretDB v1 (snap/hooks/install already guards existing
  MongoDB data), DDP_TRANSPORT=sockjs. Fix Caddy ppc64el->ppc64le mapping.

Docs: add docs/Platforms/FOSS/Snap/CPU-platforms.md; update CHANGELOG and the
Sandstorm migration doc.
2026-07-11 15:37:56 +03:00
Lauri Ojansivu 7598859a47 Updated ChangeLog. 2026-07-11 13:20:39 +03:00
Lauri Ojansivu 4a94793f68 At default docker-compose.yml, changed DDP_TRANSPORT from uws to sockjs, because uws does not work at s390x.
Thanks to xet7 !
2026-07-11 13:20:39 +03:00
github-actions[bot] d4b19389eb Bump versions for v9.85 2026-07-11 09:05:59 +00:00
Lauri Ojansivu b24bf69d6c Updated ChangeLog. 2026-07-11 11:56:16 +03:00
Lauri Ojansivu 5cc9b1dcbb Docker: add "Build from source & start" menu action; remove obsolete compose version: attribute
Add a "Build from source & start (up -d --build)" action to the Docker
menus in rebuild-wekan.sh and rebuild-wekan.bat. It builds the wekan-app
image from the local Dockerfile, tags it as the image the chosen compose
file references (ghcr.io/wekan/wekan:latest), and starts that freshly
built container instead of a possibly-stale prebuilt image pulled from
the registry - useful when a source change (e.g. the FerretDB
Version-page detection) is not in the pulled image yet.

Remove the obsolete top-level "version:" attribute from all docker
compose files, which Docker Compose v2 warns about and ignores:
docker-compose.yml, docker-compose-ferretdb-v2-postgresql.yml,
docker-compose-multitenancy.yml, .devcontainer/docker-compose.yml, and
docs/Databases/ToroDB-PostgreSQL/docker-compose.yml.

Thanks to xet7 !
2026-07-11 11:52:03 +03:00
Lauri Ojansivu 4dbf99010c Docker: make FerretDB v1 + SQLite the default docker-compose.yml
Rename docker-compose-ferretdb-v1-sqlite.yml -> docker-compose.yml (the
new default) and the old MongoDB docker-compose.yml ->
docker-compose-mongodb-v7.yml, so "docker compose up -d" uses the light,
self-contained FerretDB v1 + embedded SQLite from
https://github.com/wekan/FerretDB by default.

Update rebuild-wekan.sh and rebuild-wekan.bat Docker menus to list
FerretDB v1 SQLite first (default) and point at the new filenames.
Update the compose files' own header comments (self-identification and
cross-references), note the new default at the top of docker-compose.yml,
and update the Docker docs (main Docker.md gets a compose-file/database
map; FerretDB2-PostgreSQL.md points at the v2 postgresql compose file).

Thanks to xet7 !
2026-07-11 11:35:47 +03:00
Lauri Ojansivu 544e1b14b4 rebuild-wekan.sh/.bat: Dev server also frees rspack :8080 on restart; add "Kill all dev servers"
The Dev server options killed only the app port, but "meteor run" also
starts an rspack dev server on 8080 that can outlive the meteor parent.
A leftover one made restarting a dev server crash with
"Error: listen EADDRINUSE ... :8080". The kill now frees both the app
port and the rspack port 8080 (Meteor.devServerPort || 8080), matching
the rspack watcher by its devServerPort env.

Port detection now checks the listening socket directly (ss/lsof with a
bash /dev/tcp fallback that needs no external tools; netstat on .bat)
instead of an HTTP probe, so it also catches a server that is still
building and not yet serving. Fixes a hang where lsof did reverse-DNS
without -nP.

Add a new "Dev server -> Kill all dev servers" menu option that frees
every dev/test port the scripts use at once: dev app 3000 + Mongo 3001,
Mocha test server 3100 + Mongo 3101, Sandstorm standalone dev 4000 +
Mongo 4001, and rspack 8080. It kills meteor, the rspack watcher and
Meteor's bundled --replSet meteor Mongos, never a production/system
Mongo. Both scripts escalate to SIGKILL if a port does not free up.

Update CHANGELOG and Build-from-source.md docs.

Thanks to xet7 !
2026-07-11 11:30:25 +03:00
Lauri Ojansivu 5ef2adf6fb rebuild-wekan.sh/.bat: Dev server options kill existing Meteor on the port; docs for new menu
Every "Dev server" menu option now stops any Meteor dev server already
listening on its port before starting a fresh one, so re-running a dev
option no longer fails because the port is taken. .sh adds a reusable
kill_meteor_on_port helper called by all six dev options; .bat funnels
them through :runlog, which calls a new :kill_meteor_on_port (netstat +
taskkill). Both wait for the port to free up and escalate to SIGKILL.

Also update the build-from-source docs (README.md, Build-from-source.md,
Build-and-Create-Pull-Request.md, Emoji.md, and the two Sandstorm
developer docs) to the new two-level menu (Setup -> Install dependencies,
Setup -> Build WeKan, Dev server -> localhost:3000), fix a stale
dev-server port (localhost:4000 -> 3000), and fix the README anchor link
to the renamed heading. Add a CHANGELOG entry.

Thanks to xet7 !
2026-07-11 11:11:22 +03:00
Lauri Ojansivu ccc03913fc i18n(fi): translate Sandstorm 'grain' as 'jyva' (jyvassa/jyvan)
Use the Finnish term jyva for Sandstorm grain in the sandstorm-migration
strings: Sandstorm-jyvassa, jyvan hakemistoihin, kayttavat jyvassa, jyvan
levytilan.
2026-07-11 10:47:13 +03:00
Lauri Ojansivu af62ebd97a i18n: Finnish translations for the Sandstorm migration admin panel strings
Translate the 12 sandstorm-migration-* strings in fi.i18n.json, matching the
existing WeKan Finnish terminology (siirto, Liitetiedostot, Profiilikuvat,
tiedostojärjestelmä, Poista, Tallennustila). The proper noun Sandstorm and the
literal paths/DB names are kept as-is.
2026-07-11 10:43:54 +03:00
Lauri Ojansivu f25c6553bd Updated translations 2026-07-11 10:40:13 +03:00
Lauri Ojansivu c72ae4cf05 i18n: don't translate the project name 'Sandstorm' (hardcode it)
Remove the en.i18n.json key sandstorm-migration whose value was the bare
proper noun 'Sandstorm' (the https://sandstorm.org project name), and hardcode
'Sandstorm' in the Admin Panel / Attachments tab label and panel title instead.
The other sandstorm-migration-* keys are real translatable strings and stay.
2026-07-11 10:35:01 +03:00
Lauri Ojansivu 43dcf56a3b rebuild-wekan.sh/.bat: group menu into category submenus + two-stage Docker menu
Replace the long flat menu with a short top-level menu (Setup / Dev server /
Tests / Docker / Tools / Quit); each opens a small submenu with 0) Back, so
only a few items show at once and labels are shorter (category gives context).
Docker becomes a two-stage submenu: pick backend, then Start (up -d) / Follow
logs (logs -f) / Stop (down) -- removing 12 near-identical flat entries.

All action handlers are unchanged; only the front-end dispatch is regrouped:
.sh keeps every case handler and reaches them via a choose() 'short|full' map
(dispatch wrapped in 'for _once in 1'); .bat routes category submenus to the
existing :labels. Verified: .sh bash -n clean + all 23 menu labels map to a
handler; .bat has no orphan gotos.
2026-07-11 07:31:23 +03:00
Lauri Ojansivu 1778929869 rebuild-wekan.sh/.bat: add Follow logs / Stop docker menu options per compose file
Expands the docker-compose menu so each backend (MongoDB docker-compose.yml,
FerretDB v1 SQLite, FerretDB v2 PostgreSQL, MongoDB Multitenancy) has three
grouped entries -- Start (up -d), Follow logs (logs -f) and Stop (down) --
instead of just Start. The .sh uses a generalized wekan_docker helper that
auto-detects 'docker compose' vs legacy 'docker-compose'.
2026-07-11 07:04:14 +03:00
Lauri Ojansivu d50277721c rebuild-wekan.sh/.bat: add menu options to start WeKan Docker per compose file
New menu entries run 'docker compose -f <file> up -d' for each backend:
MongoDB (docker-compose.yml), FerretDB v1 SQLite, FerretDB v2 PostgreSQL,
and MongoDB Multitenancy. Each prints follow-logs + stop commands. The .sh
auto-detects 'docker compose' vs legacy 'docker-compose'.
2026-07-11 06:57:48 +03:00
Lauri Ojansivu f1749b2af7 docker-compose ferretdb v1-sqlite: drop leftover MONGODB_LOG_* debug env
The MONGODB_LOG_PATH/TOPOLOGY/SERVER_SELECTION/CONNECTION=debug vars were
temporary mongodb-driver diagnostics that got committed by accident; they
flood wekan-app logs with topology JSON. Remove them from the example compose.
2026-07-11 06:49:16 +03:00
Lauri Ojansivu d0d98bcadc FerretDB: --log-level=error to silence connection WARN spam; remove dead MongoDB-driver subsystem
Driver debug logs showed a 2nd, TLS-enabled Mongo monitor connection
retrying every ~0.5s against the plaintext FerretDB port; FerretDB logged
each rejection at WARN. Harmless (WeKan uses the real plaintext connection)
but noisy. Start FerretDB with --log-level=error everywhere it is launched
(Docker entrypoint, snap ferretdb-control, release start-wekan.sh, the
v1-sqlite compose example).

Also remove the dead, unused MongoDB Driver System (server/mongodb-driver-startup.js
and models/lib/{meteorMongoIntegration,mongodbConnectionManager,mongodbDriverManager}.js)
- an abandoned MongoDB 3.0-8.0 auto-detect/driver-select experiment whose
driver packages were never installed. WeKan uses the mongodb-7 driver via Meteor.

gitignore the local FerretDB/ reference clone.
2026-07-11 06:28:30 +03:00
Lauri Ojansivu 743a47c462 snap: install Caddy from GitHub release binary, not Cloudsmith apt repo
The caddy part fetched Caddy from the Cloudsmith apt repo
(curl dl.cloudsmith.io | gpg --dearmor), which fails on Launchpad remote
builders (restricted network via a fetch proxy): gpg got no key
('no valid OpenPGP data found') and override-build failed with code 2 on
both arches. Download the official prebuilt vanilla Caddy static binary from
GitHub releases instead (per-arch, latest stable + pinned fallback). WeKan
uses only built-in Caddy directives, so no apt repo, gpg, or xcaddy needed.
Applied to both snapcraft.yaml and snapcraft-core24.yaml.
2026-07-11 05:56:25 +03:00
Lauri Ojansivu cfa5707a40 Admin Panel / Version: detect FerretDB via top-level ferretdbVersion
The wekan/FerretDB v1 fork's buildInfo reports a top-level ferretdbVersion
string (e.g. v1.24.2-60-gb5523566) + ferretdbFeatures, and its commit in
gitVersion -- not a ferretdb sub-document. Detection only checked
buildInfo.ferretdb, so the Version page showed Database type: MongoDB and
hid the FerretDB rows. Handle both shapes: top-level ferretdbVersion/
ferretdbFeatures and the ferretdb sub-document. Also confirm in the Sandstorm
doc that FerretDB writes <dir>/wekan.sqlite (+ -shm/-wal).
2026-07-11 05:40:14 +03:00
Lauri Ojansivu b8a7a2e114 Sandstorm: keep niscu migration, build from upstream 0.6.0 (drop projects.7z/releases.wekan.team)
- start.js: chain niscu (2.x) -> MongoDB 3.0 -> FerretDB; keep niscud + old
  bundled driver for the niscu->3.0 stage. Migration from old versions is
  permanent (not dropped).
- build-deps.sh (new): assemble a modern meteor-spk.deps on top of upstream
  meteor-spk 0.6.0 from dl.sandstorm.io (already has mongod 3.0 + niscud);
  swap in Node 24, add FerretDB + Mongo 3.x CLIs + launcher/importer, keep
  niscud. Extra binaries from GitHub releases. Nothing needed from projects.7z.
- sandstorm.yml: replace the dead releases.wekan.team/projects.7z download with
  build-deps.sh.
- sandstorm-pkgdef.capnp: WRITABLE_PATH -> /var/files.
- docs: keep-niscu + build-source corrections; migration support is permanent.
2026-07-11 05:07:43 +03:00
Lauri Ojansivu 1237e4c2de Sandstorm: add grain launcher sandstorm-src/start.js (Node24 + FerretDB + migration)
CJS meteor-spk.deps launcher: on grain start, if old MongoDB 3.0 data exists and
migration is not yet done, start mongod 3.0 (wait for ready), start FerretDB v1
(SQLite), run migrate-mongo3-to-ferretdb (text -> FerretDB, GridFS attachments/
avatars -> /var/files), mark done (old data kept); then run WeKan (Node 24) on
FerretDB steady-state. Paths resolve from __dirname (grain mount root). Update
the design doc to point at the canonical file and mark the in-app panel done.
2026-07-11 04:50:36 +03:00
Lauri Ojansivu 61792f59c1 gitignore: ignore local projects/ and sandstorm/ build working copies
projects/ (unarchived meteor-spk fork binaries) and sandstorm/ (clone of
sandstorm-io/sandstorm) are large, machine-specific working copies used only
when building the Sandstorm .spk; never commit them.
2026-07-11 04:47:42 +03:00
Lauri Ojansivu 664c6e1943 Sandstorm: Admin Panel / Attachments / Sandstorm migration panel
When isSandstorm, show a new Sandstorm section in Admin Panel / Attachments:
one-time MongoDB 3 -> FerretDB migration status, disk space used by the raw
MongoDB 3 files / FerretDB SQLite / attachments / avatars in the grain, and a
guarded button to delete the redundant raw MongoDB files (only after a
confirmed-successful migration, behind a confirm) to free grain disk space.

- server/methods/sandstormMigration.js: admin-gated sandstormMigrationStatus
  + sandstormDeleteRawMongo (isSandstorm-gated, env-overridable grain paths).
- client Attachments: new Sandstorm tab (jade + js), shown only when isSandstorm.
- imports/i18n/data/en.i18n.json: sandstorm-* UI strings.
- migrate-mongo3-to-ferretdb.mjs: optional STATUS_FILE with the final migration
  state, which the panel reads (shared by snap + the Sandstorm spk).
2026-07-11 04:39:24 +03:00
Lauri Ojansivu 9d90b869cd docs: move Sandstorm Meteor3 migration doc under FOSS/Sandstorm
Existing Sandstorm docs live under docs/Platforms/FOSS/Sandstorm/; move the
new Meteor3/Migration.md there (was mistakenly at docs/Platforms/Sandstorm/),
fix its relative links (one level deeper) and the CHANGELOG path.
2026-07-11 04:39:01 +03:00
Lauri Ojansivu 453770ef98 docs: Sandstorm Meteor3/Node24 + MongoDB3->FerretDB migration design
Add docs/Platforms/Sandstorm/Meteor3/Migration.md: how to build a modern
Sandstorm .spk (Node 24, FerretDB v1 SQLite) migrating an existing grain's
MongoDB 3.0 data on first launch, reusing the snap's migrate-mongo3-to-ferretdb
logic. Includes seccomp compatibility analysis, rewritten start.js, and a new
isSandstorm-only Admin Panel / Attachments / Sandstorm section design.
2026-07-11 04:29:24 +03:00
github-actions[bot] a7f9018a60 Bump versions for v9.84 2026-07-11 00:21:01 +00:00
Lauri Ojansivu b6fe0a7256 Updated ChangeLog. 2026-07-11 03:11:16 +03:00
Lauri Ojansivu 02e3c35d69 Snap: show correct writable path for parallel installs in backup help
wekan.help backup/migration instructions hardcoded
/var/snap/wekan/common/files, which is wrong for a parallel snap install
(e.g. wekan_customer -> /var/snap/wekan_customer/common). Print
$SNAP_COMMON/files instead, which snapd sets per instance. Display-only;
all functional snap paths already derive from $SNAP_COMMON/$SNAP_DATA.
2026-07-11 03:03:23 +03:00
Lauri Ojansivu 4766b83da7 Fix flaky server-side Mocha i18n test (TAPi18n .loadLanguage)
The .loadLanguage suite stubbed addResourceBundle on the shared
TAPi18n.i18n singleton and asserted the call count. loadLanguage()
reads this.i18n late and each test re-init()s the singleton, so
cross-test state could leave the stub on a different i18next instance
than the bundle was registered on, making the count read 0 and the run
intermittently report 'expected addResourceBundle to be called once'.
Assert on observable i18next state (hasResourceBundle/getResourceBundle
under the normalised toI18nCode) instead, and stop a leaked
Tracker.autorun in the .getLanguage reactive test.
2026-07-11 02:40:34 +03:00
Lauri Ojansivu 011c172c8c Fix scheduled backup cron init crashing at Meteor 3 server startup
registerCron() used the synchronous BackupSettings.findOne/upsert, which
Meteor 3 forbids on the server, so cron init threw and scheduled backups
never registered. Use findOneAsync/upsertAsync, make registerCron async,
and await it in all callers. Add tests/backupCron.test.cjs (positive +
negative + source guard) to the test:unit:node suite.
2026-07-11 01:53:07 +03:00
xet7 cb18f9b735 Updated ChangeLog. 2026-07-11 01:31:38 +03:00
xet7 abe8af0788 Fix backup build: archiver@8 ESM (ZipArchive) + add @aws-sdk/lib-storage dep
The Meteor/rspack build failed on server/methods/backup.js:
- archiver@8 is now pure ESM with no default export and no archiver('zip', …)
  factory — it exports classes. Use `import { ZipArchive } from 'archiver'` and
  `new ZipArchive({ zlib: { level: 6 } })` (the streaming API — directory/append/
  finalize/pipe/on — is inherited from the Archiver core class).
- The S3 streaming upload requires @aws-sdk/lib-storage, which was not a
  dependency (only @aws-sdk/client-s3 was), so rspack failed with
  "Can't resolve '@aws-sdk/lib-storage'". Add it, pinned to ~3.1073.0 to match
  the installed @aws-sdk/client-s3 (3.1073.0) and avoid the newer lib-storage's
  peer requirement on a newer client-s3.
2026-07-11 01:31:05 +03:00
xet7 997f1fb9df Updated ChangeLog.
Refresh the Upcoming section: re-point every commit link to the current hash
(the earlier links referenced pre-rebase hashes that are not on GitHub), and add
bullets for the newest commits — Transifex duplicate-key fix, Finnish
translations, unit + negative tests, and the Alternative-Architectures doc.
2026-07-11 00:51:44 +03:00
xet7 9828081c76 docs: add Design/Multiverse/Alternative-Architectures.md
Document which CPU architectures WeKan can run on and why, the architecture
matrix (Node 24 vs MongoDB vs FerretDB v1), why armhf/armv7l/i386/loong64 are
not supported (Node dropped 32-bit; QEMU/base-image gaps for loong64), the
JavaScript-engine alternatives (Deno/Bun cover fewer arches; QuickJS/JSC/
SpiderMonkey/JVM run on 32-bit but cannot run Meteor), and the server-rewrite
options (Go recommended, QuickJS niche, Tcl/Tk a poor fit for a web app), plus
hardware notes (ODroid-U3, Raspberry Pi OS 32-bit, ReactOS).
2026-07-11 00:46:13 +03:00
xet7 d2ec3ee400 Unit + negative tests for newest features (lazy loading, streaming export, backup)
Extract the pure, security- and correctness-critical logic of the recent features
into Meteor-free models/lib/* modules (so production and tests share one
implementation) and add plain-Node tests with positive AND negative cases, wired
into test:unit:node:

- models/lib/mongoSelectorSafety.js hasWhere() — the windowed card publication
  refuses a client selector carrying $where (server-side JS). Tests: safe
  selectors pass; $where at top level / in $and / deep in arrays / in $elemMatch
  is rejected. (was inline in server/publications/cardsWindow.js)
- models/lib/cardsLoading.js resolveCardsLoadingMode / parseCardsLoadingEnv /
  windowCountId — CARDS_LOADING maps to 'lazy' only for exactly "lazy" (garbage
  never enables the experimental mode); a falsy swimlane id collapses to one
  count-doc id (listBody uses undefined, listHeader uses ''). (used by
  server/cards-loading.js, server/models/settings.js, client/lib/lazyCards.js)
- models/lib/base64Chunk.js encodeAligned/encodeFinal — streaming an attachment
  into the JSON export as base64 in any chunk sizes reconstructs exactly the
  whole-file base64; aligned pieces never carry padding. (used by
  Exporter.buildStream)
- models/lib/backupPaths.js filesRootFrom/scheduleText — WRITABLE_PATH -> files
  root (no double /files suffix, Windows-safe) and the synced-cron schedule text
  (daily/weekly/monthly + defaults, unknown frequency falls back to daily). (used
  by server/methods/backup.js)

41 new assertions across 4 files, all passing; whole test:unit:node suite green.
2026-07-11 00:14:49 +03:00
xet7 43006f9025 Finnish translations for newest features (Version, migration, backup, lazy loading)
Translate the remaining English strings for the CHANGELOG "Upcoming" features into
Finnish, matching existing terminology (Liitetiedostot, Profiilikuvat, Tallennustila,
Palauta, tiedostojärjestelmä, kokoelma, Tietokannan siirrot):
- Admin Panel / Version: Database_type/commit, FerretDB_version/commit,
  Reactivity_mode, MongoDB_version.
- Admin Panel / Attachments / Database migration (MongoDB <-> FerretDB v1).
- Admin Panel / Features: Card loading (all / lazy) + descriptions.
- Admin Panel / Attachments / Backup: schedule, storage, restore, list.
Product names and acronyms (FerretDB, MongoDB, SQLite, GridFS, S3/MinIO, Gantt,
LDAP, OAuth2, CAS) are kept as-is. 44 keys translated.
2026-07-10 23:56:07 +03:00
Lauri Ojansivu fcfd5bce9b Updated translations 2026-07-10 23:50:21 +03:00
xet7 4cbefed339 i18n: remove duplicate database-migration keys (Transifex parse_error)
en.i18n.json had two "database-migration" and two "database-migration-description"
keys, so the Transifex push failed with "Duplicate string key". The later
(block-2) copies — "Database Migration" / "Updating database structure..." — were
unused (only the Admin Panel / Attachments migration UI references these keys, and
it needs the MongoDB<->FerretDB wording in the earlier copy). Removed the unused
duplicates; JSON now parses with unique keys.
2026-07-10 23:40:11 +03:00
xet7 7d594c95ae Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 7d5f29e114 Lazy card loading: accurate card counters and WIP limits
In lazy mode minimongo holds only the loaded window, so the list card-counter
badge and the WIP-limit checks (reachedWipLimit / exceededWipLimit, the add-card
gate, and setting the WIP limit to the current count) previously reflected only
the cards loaded so far. They now use lazyListCardCount(), which subscribes to
and reads the server-side count for the SAME selector list.cards(swimlaneId) uses
(listCardsSelector + the board Filter) — so the count is exactly the accurate
value list.cards(swimlaneId).length would report if every card were loaded. The
template's whole-list count uses (cards.length) are routed through a new
cardsCountWhole helper the same way. Default 'all' mode falls back to the loaded
length and is unchanged.
2026-07-10 22:57:40 +03:00
xet7 29b617769a Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 f0d25a2431 Expose CARDS_LOADING on all platforms (docker-compose, launchers, snap)
Adds the CARDS_LOADING=all|lazy knob everywhere WeKan is configured:
- docker-compose.yml and the two FerretDB compose files (v1-sqlite, v2-postgresql)
- releases/ferretdb/start-wekan.sh and start-wekan.bat (bundle launchers)
- releases/virtualbox/start-wekan.sh
- snap: added to snap-src/bin/config keys + triplet, so
  "snap set wekan cards-loading=lazy" works, with an example in wekan-help.
All default to 'all' (unchanged behaviour).
2026-07-10 22:57:40 +03:00
xet7 09805a8a91 Lazy card loading (client) + Admin Panel / Features section
Client side of CARDS_LOADING=lazy. In lazy mode each list's cardsWithLimit
subscribes to boardCardsWindow for just the cards it renders (growing with the
infinite-scroll limit) and to boardListCardCount for the total; showSpinner uses
that server count to decide whether more cards remain, since minimongo now holds
only the loaded window. Default 'all' mode is unchanged (helpers short-circuit on
isLazyCards()).

Adds Admin Panel / Features — a new admin section (route /admin-features, nav
entry, adminFeatures template) intended as the home for optional / performance /
future tier-gated capabilities. First control: "Card loading" (all | lazy), which
writes the cardsLoading Setting; it mirrors, and is overridden on startup by, the
CARDS_LOADING env var. Includes an experimental-note (counters/WIP limits and the
Calendar/Table/Gantt views reflect only loaded cards; reload boards after change)
and the i18n keys.
2026-07-10 22:57:40 +03:00
xet7 6f7ad270d1 Lazy card loading (server): CARDS_LOADING=all|lazy + windowed publications
Adds an opt-in card-loading mode for very large boards. Default 'all' is
unchanged: the board publication still ships every card into minimongo.

When CARDS_LOADING=lazy (env) or Admin Panel / Features = lazy:
- The board publication no longer ships all cards, their comments/attachments,
  or the board-level checklists/checklistItems (gated by a lazyCards() check).
- A new publish-composite 'boardCardsWindow' publishes only the cards of one
  list/swimlane window (the client passes the exact selector it renders with,
  which is ANDed with a server-forced { boardId, archived:false } scope and
  refuses $where), plus each visible card's comments, attachments, checklists
  and checklist items. As the user scrolls the client raises the limit and
  re-subscribes, streaming more cards in on demand.
- A 'boardListCardCount' publication reactively publishes each window's total
  card count into the client-only boardListCardCounts collection, so the client
  knows whether more cards remain to load.

The mode is a Settings field (cardsLoading), seeded from the CARDS_LOADING env
var (authoritative on startup when set) and mirrored onto
Meteor.settings.public.cardsLoading for the publications to read; an
after.update hook keeps it in sync when an admin changes it.
2026-07-10 22:57:40 +03:00
xet7 bd1c611688 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 ae9be62ef3 Export board to HTML .zip: stream to disk and include every card
The client HTML export cloned the live DOM and built the whole .zip as an
in-memory blob. Two problems on large boards: infinite scroll keeps only ~10
cards per list in the DOM, so most cards were missing from the export; and the
entire archive was buffered in browser RAM.

Before snapshotting, every list's cardlimit is now lifted so the whole board
renders (then we wait for Blaze to flush). The zip is written with JSZip's
generateInternalStream piped straight to the chosen file via the File System
Access API, chunk by chunk with backpressure, so the archive is never held
whole in memory; browsers without the API fall back to the previous blob
download.
2026-07-10 22:57:40 +03:00
xet7 41cd166358 Stream board exports (JSON, CSV/TSV, Excel) with bounded memory
The board export routes buffered the entire board in memory before sending:
JSON export built one object with every card, comment, activity, checklist and
base64 attachment; CSV called that same build(); Excel additionally loaded data
it never renders (activities/checklists/subtasks/rules) and did O(n^2)
result.cards.find() lookups. On big boards this peaked at gigabytes and could
exceed V8's max string length.

Now:
- Exporter.buildStream(res) writes the JSON document straight to the response a
  document at a time from raw Mongo cursors, encoding each attachment file to
  base64 in 3-byte-aligned chunks. Same _format/keys, so import round-trips.
- Exporter.buildCsvStream(res) keeps only the small lookup tables in memory and
  streams cards from a cursor, writing one CSV/TSV row at a time.
- ExporterExcel uses the exceljs streaming WorkbookWriter, committing each row
  to the response, drops the unused collections, and resolves parent/comment
  card titles via an id->title map instead of scanning all cards.
Peak memory stays flat regardless of board size.
2026-07-10 22:57:40 +03:00
xet7 e39dcd4ebf Backup: switch from jszip to archiver+unzipper for low-memory streaming
Admin Panel / Attachments / Backup no longer loads whole files or the whole
zip into memory. The backup zip is written with archiver, streaming each
attachment/avatar straight from disk (archive.directory) and each text
collection a document at a time from a Mongo cursor as NDJSON, piped directly
to the destination (filesystem or S3/Azure/GCS streaming upload) with no temp
file. Restore reads the zip with unzipper, piping each file entry to disk and
each NDJSON data entry line-by-line in 200-doc batches. A board with thousands
of cards or a 5 GB attachment now backs up/restores with flat memory.
2026-07-10 22:57:40 +03:00
xet7 4f1a60aa7b Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 5757cfc8ca Add standalone sandstorm.yml workflow; remove build-sandstorm from release-all.yml
Sandstorm packaging is not tested well enough yet, so it should not run as part of
a full release. Remove the build-sandstorm job from release-all.yml, and add a
separate, manually-triggered sandstorm.yml that ONLY builds the Sandstorm .spk and
uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the newest WeKan GitHub
Release (found via `gh release list`), plus a workflow artifact — so it can be
downloaded and tested for errors without affecting releases. Same experimental
caveats (unprivileged user namespaces, SANDSTORM_KEYRING signing secret).

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 c7c8a200ff Admin Panel / Attachments / Backup: scheduled backups of Attachments/Avatars/Data, streamed to storage; restore + list
Adds a Backup section to Admin Panel / Attachments:
- Checkboxes to select Attachments, Avatars and/or Data (all text-based
  collections that are not attachments/avatars).
- Storage dropdown (filesystem, S3/MinIO, Azure, GCS).
- Backup now: the .zip is STREAMED DIRECTLY to the selected storage — no temp
  file, no extra disk — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip containing
  YYYY_MM_DD-HH_MM_SS/{attachments,avatars,data/<collection>.json}. Filesystem
  pipes to the file; S3 uses @aws-sdk/lib-storage streaming Upload, Azure
  uploadStream, GCS createWriteStream (cloud creds from the storage tabs).
- Scheduler (off/daily/weekly/monthly + time/day) saved and run via synced-cron.
- List backups: table with storage, datetime, path; select one and Restore with
  "Add missing data only" or "Replace all data".

Server methods (admin-only): runBackup, restoreBackup, listBackups,
getBackupSchedule, saveBackupSchedule, backupStatus (polled for live progress).

The cloud upload paths and restore are not exercised end-to-end here; the whole
zip is assembled with jszip, so very large attachment sets use notable memory.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 71fcf2b454 Make migrate-mongo3-to-ferretdb.mjs executable 2026-07-10 22:57:40 +03:00
xet7 1dbc26d200 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 84fee7b660 Admin Panel / Attachments: migrate text data between MongoDB and FerretDB v1 (SQLite), both directions
Adds a "Database migration" section to Admin Panel / Attachments with two buttons:
migrate text-based data (everything except attachments/avatars, which stay on the
filesystem) to FerretDB v1 (SQLite), or back to MongoDB.

WeKan is connected to one database at a time, so the new server methods open a
SECOND driver connection to the OTHER database (both MongoDB and FerretDB v1 speak
the MongoDB wire protocol) and copy the text collections from the live database
into it, upserting by _id (idempotent, re-runnable). The target URL is
WEKAN_FERRETDB_URL (default mongodb://127.0.0.1:27018/wekan) or WEKAN_MONGODB_URL
(default mongodb://127.0.0.1:27019/wekan); both must be running. Progress is polled
via migrateTextDatabaseStatus and shown live. After it finishes, point MONGO_URL at
the other database and restart (on Snap: snap set wekan database=ferretdb / =mongodb).

Admin-only. Attachments/avatars are skipped (they live on the filesystem).

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 3b7a276347 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 02cf16d8c6 Snap migration: only MongoDB 3 migrates (via mongo CLI read + Node driver insert), FerretDB SQLite at files/db
Per the design: a MongoDB 7 database works with newest WeKan as-is and is NOT
migrated; only the old WeKan 6.09 / MongoDB 3.2 data is migrated to FerretDB v1.

- migration-control now: if mongod 7 can open the existing $SNAP_COMMON data it is
  MongoDB 6/7 -> no migration (mark done, keep MongoDB); otherwise it is MongoDB
  3.x -> migrate to FerretDB. The dump/restore-into-mongod-7 hop is removed.
- New importer migrate-mongo3-to-ferretdb.mjs reads MongoDB 3.2 with the legacy
  mongo CLI (mongoexport — the modern Node driver can't talk to 3.2) and inserts
  into a running FerretDB with the Node driver: text data streamed directly, and
  CollectionFS/Meteor-Files GridFS attachments+avatars reassembled per-file from
  exported chunks straight to files/attachments and files/avatars (no duplicate
  copy, no mongodump/mongorestore, no intermediate MongoDB 7). Progress at ROOT_URL.
- FerretDB SQLite now lives at <files>/db, next to attachments/avatars (the
  "files/<name>" layout), in ferretdb-control, migration-control, the offline
  start-wekan.sh/.bat, the Docker entrypoint and docker-compose-ferretdb-v1-sqlite.

The amd64 6.09/MongoDB-3.2 path is not yet tested against real 6.09 data.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 54944717ee Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 b99d21d9f1 Snap migration: read MongoDB 3.2 via migratemongo CLI (dump -> restore into mongod 7), fix migrator NODE_PATH
The bundled Node MongoDB driver cannot connect to a MongoDB 3.2 server, and no
single driver version spans 3.2 and MongoDB 7 / FerretDB. So instead of aliasing
an old (EOL) Node driver into package.json, use the migratemongo CLI tools for the
amd64 6.09/3.2 case: migration-control now dumps the 'wekan' database with the old
migratemongo mongodump (MongoDB 3.2, old libraries), then loads it into a fresh
temporary mongod 7 with the snap's modern mongorestore. The existing driver-based
migrator then reads that mongod 7 exactly like the arm64 MongoDB-7 case (both
GridFS types -> filesystem, text -> FerretDB v1 SQLite). The MongoDB-7 path
(arm64 + amd64-on-newest-WeKan) connects the driver directly, unchanged.

Also fix a real bug: the standalone migrator script in $SNAP/bin could not resolve
its `mongodb`/`bson` imports; set NODE_PATH to the WeKan bundle's node_modules.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 3dfd84973c Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 dd4611e43f Snap migration: bundle migratemongo (MongoDB 3.2 binaries + old libs + AVX wrappers) and use it to read 6.09 data
Per docs/Backup/Backup.md and https://github.com/wekan/migratemongo, running the
old MongoDB tools/server in the snap needs LC_ALL=C, the mongo binaries on PATH,
and their libraries on LD_LIBRARY_PATH ($SNAP/lib/<arch>-linux-gnu). The 2016
MongoDB 3.2 binaries additionally need old libraries (libssl/libcrypto.so.1.0.0,
libpng12, libexpat) that modern bases lack.

- Add a migratemongo snapcraft part (both snapcraft.yaml and snapcraft-core24.yaml)
  that stages https://github.com/wekan/migratemongo at $SNAP/migratemongo (bin/ =
  MongoDB 3.2 binaries, lib/x86_64-linux-gnu/ = the old libraries, avx/ = QEMU
  wrappers for non-AVX CPUs), amd64 only. This also fills in the
  $SNAP/migratemongo/avx path that mongodb-control / mongodb-backup /
  mongodb-restore already referenced but was never bundled.
- Replace the naive fresh-download mongodb-old part (a bare 3.2 mongod with no old
  libs) with this.
- migration-control now reads the amd64 6.09 MongoDB 3.2 data with
  $SNAP/migratemongo/bin/mongod, LD_LIBRARY_PATH=$SNAP/migratemongo/lib/..., and
  the legacy $SNAP/migratemongo/bin/mongo shell (mongosh cannot talk to 3.2). The
  MongoDB 7 path (arm64 + amd64-on-newest-WeKan) still uses the snap's mongod 7 +
  mongosh.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 69e6b8bcd1 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 6ab29dc81e Snap: one-time MongoDB -> FerretDB v1 migration on upgrade (arm64 Mongo7 + amd64 6.09)
On first boot after upgrading an old MongoDB-based WeKan snap, mongodb-control now
hands off to a new migration-control before starting mongod:

- Detects existing MongoDB data in $SNAP_COMMON and opens it with the right
  bundled mongod: mongod 7 for MongoDB 7 data (all arches, including the arm64
  snaps already on newest WeKan), or a bundled old mongod 3.2 (amd64 only, new
  mongodb-old part) for WeKan 6.09 / MongoDB 3.2 data.
- Starts a temporary FerretDB v1 (SQLite) and runs migrate-mongodb-to-ferretdb.mjs,
  which migrates text data to FerretDB and BOTH CollectionFS GridFS and
  Meteor-Files GridFS attachments+avatars to the filesystem, shows a live progress
  counter at ROOT_URL, checkpoints to $SNAP_COMMON (resumable), and compacts the
  old MongoDB when done.
- On success switches the snap to database=ferretdb.

Idempotent (migration-pending helper + completion marker), resumable, never
deletes the source MongoDB data, only switches on success. The migrator is bundled
into the snap (/bin). Progress dashboard title fixed to 'MongoDB -> FerretDB'.

NOT yet tested end-to-end against a real 6.09 snap; in particular the 2016 mongod
3.2 binary may need extra runtime libraries on a modern base (amd64 6.09 path).
The arm64/MongoDB-7 path uses the already-bundled mongod 7.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 9ae6a91daf Fix compose commit link in ChangeLog 2026-07-10 22:57:40 +03:00
xet7 c3674330ac Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 be5ddd77ef Rename docker-compose-ferretdb.yml to ...-v2-postgresql.yml and add ...-v1-sqlite.yml
- docker-compose-ferretdb.yml -> docker-compose-ferretdb-v2-postgresql.yml
  (FerretDB 2 + PostgreSQL/DocumentDB, unchanged database).
- Add docker-compose-ferretdb-v1-sqlite.yml: FerretDB v1 with the embedded SQLite
  backend, no PostgreSQL/MongoDB. Its ferretdb service fetches the v1 binary for
  its architecture from the newest wekan/FerretDB release
  (https://github.com/wekan/FerretDB/releases), caches it on the volume, and runs
  it with --handler=sqlite.

Both compose files now carry the FULL wekan service from docker-compose.yml
(every documented environment variable and feature); the ONLY differences are
database-related: the ferretdb service replaces the mongodb service, MONGO_URL
points at FerretDB, MONGO_OPLOG_URL is dropped and METEOR_REACTIVITY_ORDER is
polling (FerretDB has no MongoDB change streams / replica-set oplog).

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 9fc88a783c Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 748fd2d391 Snap: migrate Caddyfile v1 -> v2 format when caddy is enabled
When upgrading from an old WeKan snap, $SNAP_COMMON/Caddyfile may still be in
Caddy v1 format, which Caddy 2 cannot parse (so caddy would fail to start).
caddy-control now runs migrate-caddyfile-v1-to-v2.sh before 'caddy run' when
caddy is enabled. It is safe: a no-op if the file already parses as Caddy 2;
otherwise it backs up the original, converts the common v1 directives (drop the
http:// scheme, 'proxy / TARGET' -> 'reverse_proxy TARGET', drop the v1
'websocket'/'transparent' presets that Caddy 2 does by default, 'gzip' ->
'encode gzip'), validates the result with 'caddy adapt', and only keeps it if
valid — falling back to the shipped Caddy 2 template otherwise, so caddy always
starts with valid config.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 4ac24dc546 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 4c630d4ba8 migrate-mongodb-to-ferretdb: resumable progress in WRITABLE_PATH + compact old MongoDB after success
Two additions to the MongoDB -> FerretDB / GridFS -> filesystem migrator used by
the Snap and Sandstorm migrations:

- Resumable: progress is checkpointed to $WRITABLE_PATH/migration-progress.json
  (atomic write) after every collection and file phase. On restart the migrator
  loads the checkpoint and SKIPS collections it already finished, so an
  interrupted migration (snap refresh, Sandstorm grain restart, power loss)
  continues instead of starting over. Safe because every document is upserted.

- Compact after success: once the migration is complete (fresh run, or a later
  boot that finds the completion marker), it runs 'compact' on each source
  MongoDB collection so WiredTiger returns the now-duplicated disk space to the
  OS. Runs once (tracked by a 'compacted' flag on the marker) and is best-effort.

The existing ROOT_URL progress dashboard and disk-space checks (statfsSync) are
kept; the dashboard is restored from the checkpoint on resume.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 a563d3c4b2 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 95b186e50a release-all.yml: add build-sandstorm job to pack and attach wekan-<version>.spk
Mirrors releases/release-sandstorm.sh + releases/install-sandstorm.sh: installs
Meteor, meteor-spk 0.6.0 (from the WeKan projects.7z) and a dev Sandstorm, then
'meteor-spk pack' produces wekan-<version>.spk and attaches it to the GitHub
Release. Decoupled from the release job so it never blocks the core release.

EXPERIMENTAL in CI: Sandstorm needs unprivileged user namespaces (GitHub's Ubuntu
VMs allow them), and meteor-spk signs the .spk with the app private key from the
Sandstorm keyring -> provide it as the SANDSTORM_KEYRING secret. 'spk publish' to
the app market and the scp upload stay manual, as in release-sandstorm.sh.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 bbc525ef27 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 bfb3fd246b Self-contained release bundles: bundle Node.js + FerretDB + start-wekan.{sh,bat}
Each wekan-<version>-<arch>.zip is now fully offline: its bundle/ directory
contains the WeKan server, a Node.js binary for that platform, a FerretDB v1
(SQLite) binary, and a start-wekan.sh (start-wekan.bat on Windows) that by
default runs WeKan on the bundled Node against the bundled FerretDB SQLite,
storing data and attachments/avatars on the filesystem under WRITABLE_PATH
(like docs/Platforms/Propietary/Windows/Offline.md). Override with WEKAN_DB /
MONGO_URL to use an external database.

- build-amd64/arm64/mac copy the runner's own Node 24 (matching arch); the
  emulated ppc64le/s390x/riscv64 builds copy the target-arch Node they already
  download; win64 bundles node.exe. Each overwrites the base bundle's node.
- start-wekan.sh supersedes start-wekan-ferretdb.sh (removed).
- Docker and snap have their own Node + entrypoint/wekan-control, so they strip
  the redundant bundled node + launchers (keeping the ferretdb binary), saving
  ~80 MB per image/snap.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 3a968052b9 Add snapcraft-core24.yaml: newest WeKan on base core24 for the Snap Stable channel
The main snapcraft.yaml uses base core26, which needs build-base devel + grade
devel until core26 is released, so it can only go to Snap Beta/Edge. This adds a
base core24 variant (a released base, grade stable) that builds the SAME newest
WeKan (Meteor 3.5, Node.js 24, FerretDB v1, MongoDB 7, Caddy 2), so the Stable
channel can be updated from the old 6.09 snap. Only base/build-base/grade differ
from snapcraft.yaml; migration wiring is added separately.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 d424bd6386 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 3824066f7f rebuild-all.yml: download prebuilt ferretdb.zip from wekan/FerretDB releases instead of building it, and stop attaching it to WeKan releases
FerretDB is now built and released separately in the wekan/FerretDB repo (its
build.sh cross-compiles every platform into ferretdb.zip and attaches it to a
release there). The WeKan release workflow no longer sets up Go or compiles
FerretDB: build-ferretdb just downloads the newest ferretdb.zip from
https://github.com/wekan/FerretDB/releases/latest and republishes it as a
workflow artifact, and every WeKan build embeds its per-arch binary from that.

ferretdb.zip is no longer attached to the WeKan releases, since it already lives
on the wekan/FerretDB releases.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 774d752620 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 dbca7cd72d Fix #6445: dynamic-import chunks 404 under a sub-path (build-chunks/build-chunks/)
Under a sub-path deployment (ROOT_URL like https://host/wekan, typically behind a
reverse proxy that strips the prefix), language selection and other lazy-loaded
features failed with:
  Error serving static file ENOENT ... /programs/server/build-chunks/build-chunks/<id>.js

rspack's client runtime builds each chunk URL as __webpack_public_path__ + chunkName,
and chunkName already includes the 'build-chunks/' prefix (u = e => 'build-chunks/'+e+...).
The default public path is '/', so at the site root chunks resolve correctly to
/build-chunks/<chunk>. But client/00-startup.js set the sub-path public path to
_rootPath + '/build-chunks/', so rspack appended a second 'build-chunks/', yielding
/wekan/build-chunks/build-chunks/<chunk> — which 404s. Set the public path to
_rootPath + '/' and let rspack add 'build-chunks/' itself.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 8497e7c537 rebuild-all.yml: build standalone ferretdb.zip (all Go-cross-compilable arches) and source every WeKan build's FerretDB binary from it
Adds a build-ferretdb job that cross-compiles FerretDB v1 (wekan/FerretDB, pure-Go
SQLite backend, CGO off, no QEMU) for every platform Go + modernc.org/sqlite
support — not only the arches Node.js runs on — and packs them into ferretdb.zip:

    ferretdb/<arch>/ferretdb-<arch>        (Linux/macOS/BSD, executable)
    ferretdb/<arch>/ferretdb-<arch>.exe    (Windows only)
    ferretdb/README.md                     (links to https://github.com/wekan/FerretDB)

Targets that do not compile on a given platform are skipped, so the zip contains
exactly what builds. ferretdb.zip is attached to the GitHub Release, and every
WeKan build (amd64, arm64, ppc64le, s390x, riscv64, win64, mac-arm64; and via the
bundle the docker image and snap) now extracts its FerretDB binary from that same
ferretdb.zip, so there is one source of truth. Windows/macOS bundles now include
FerretDB too (previously stripped), tolerant of an arch that FerretDB can't build.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 7f44459c9f Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 d5c69e0259 Admin Panel / Version: show database type, version, commit, storage engine and reactivity mode
getStatistics now reports, in Admin Panel / Version:
 - Database type: MongoDB or FerretDB (detected from buildInfo.ferretdb).
 - MongoDB-compatible version and the server git commit (buildInfo.gitVersion).
 - FerretDB version and commit, when the server is FerretDB.
 - Storage engine: WiredTiger for MongoDB, SQLite for FerretDB.
 - Reactivity mode currently in use: changeStreams / oplog / polling (from
   Meteor's oplog handle; FerretDB has no oplog so it shows polling).

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 598aa8dfa1 Snap: choose MongoDB or FerretDB v1 with 'snap set wekan database=ferretdb'; FerretDB default for new installs; disable mongosh telemetry
Adds a 'database' snap setting (mongodb|ferretdb). A new ferretdb service runs
FerretDB v1 (SQLite) on the same port MongoDB would use; only one database runs
at a time. The configure hook flips the two services when the setting changes,
mongodb-control disables itself when ferretdb is selected, and wekan-control
drops MONGO_OPLOG_URL for FerretDB (which has no replica-set oplog, so WeKan
uses polling) and waits on a plain ping instead of rs.status().

The FerretDB binary ships inside the WeKan bundle the wekan part already
downloads, so no extra build is needed. New installs default to FerretDB on all
platforms via the install hook (fresh installs only; upgrades keep MongoDB, and
a pre-existing MongoDB data dir is detected and kept). Switch with
'snap set wekan database=mongodb'.

mongosh collects anonymized usage analytics by default and the snap invokes it
several times; disable it (writable HOME + disableTelemetry) so nothing phones
home. mongod itself has no phone-home telemetry (Free Monitoring is opt-in).

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 0360ca1583 Fix rebuild-all.yml s390x build; add bundled FerretDB v1 for ppc64le, s390x, riscv64 and to the Docker image
The extra-arch bundle build ran node:24-slim under QEMU, but the official
node:24 image publishes no linux/s390x manifest (only amd64/arm64/ppc64le),
so the s390x leg failed with 'no matching manifest for linux/s390x'. Build
the emulated native-module rebuild on an ubuntu:26.04 base (which publishes
every arch) and install the Node.js binary from nodejs.org instead; riscv64
uses unofficial-builds.nodejs.org (nodejs.org ships no riscv64).

MongoDB Community only ships amd64/arm64 server binaries, so on ppc64le,
s390x and riscv64 WeKan now bundles FerretDB v1 (the wekan/FerretDB fork with
its embedded pure-Go SQLite backend, MongoDB wire protocol). The FerretDB
binary is cross-compiled once for all five arches (CGO off, static, no QEMU)
and embedded in every .zip next to main.js, so amd64/arm64 users can opt in
too via WEKAN_DB=ferretdb. The Docker image now covers all five arches and
auto-starts FerretDB on the MongoDB-less ones. FerretDB telemetry is disabled
and locked (--telemetry=disable, plus DO_NOT_TRACK). armv7l is still excluded:
no Node.js 24 build exists for it anywhere.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
github-actions[bot] 9c51391a75 Bump versions for v9.83 2026-07-09 11:57:12 +00:00
Lauri Ojansivu 2ed13a1593 Updated ChangeLog. 2026-07-09 14:48:08 +03:00
Lauri Ojansivu ca50c9d1aa Updated ChangeLog. 2026-07-09 14:45:09 +03:00
Lauri Ojansivu 548d7b483e Updated ChangeLog. 2026-07-09 14:42:48 +03:00
Lauri Ojansivu 71bbc9f67e Updated ChangeLog. 2026-07-09 14:09:14 +03:00
Lauri Ojansivu 05fd6fd3a5 Fix #3624 swimlane REST regression: await the async board.swimlanes()
The swimlanes CRUD e2e test (23-rest-api-more.e2e.js:209) regressed on all
browsers. Root cause: on the server ReactiveCache.getSwimlanes is async, so
board.swimlanes() returns a Promise in the REST handler. The #3624 change read
board.swimlanes().map(s => s.sort); .map on a Promise throws, the insert never
ran, the error was swallowed by the handler's catch (returned as 200 with no
_id), and the test read .title off a null swimlane.

The earlier "move the require to a top-level import" commit was not the real
cause (the same module.exports import pattern works server-side, e.g.
ruleDeletePermission in rulesButton.js). The actual fix is to await
board.swimlanes() before mapping. This also means API-created swimlanes now get
a real max(existing sort)+1 sort — previously the un-awaited `.length` yielded
undefined, so they were stored with no sort at all, which is the #3624 symptom.

Verified: the swimlane test passed in every kept local run through
2026-07-09_02-38-07 and failed starting 09-52-25 (right after the #3624 change),
confirming the regression window; the tests/swimlaneSort.test.cjs unit test
still passes.
2026-07-09 14:09:14 +03:00
github-actions[bot] e8939bc1e6 Bump versions for v9.82 2026-07-09 08:13:59 +00:00
Lauri Ojansivu bc8d58011c Prepare v9.82 release 2026-07-09 11:08:40 +03:00
Lauri Ojansivu 0bf8cae81c Updated ChangeLog. 2026-07-09 10:43:25 +03:00
Lauri Ojansivu 984ec160da Fix Playwright CI failures from #3624 and #4236
Two e2e tests failed across Chromium/Firefox/WebKit after this release's changes:

- 23-rest-api-more.e2e.js:209 (swimlanes: create, get, edit, delete): the
  #3624 change referenced models/lib/swimlaneSort via a function-scoped
  require('/models/lib/swimlaneSort'); the new module was not reliably bundled/
  resolved that way, so POST /api/boards/:id/swimlanes threw, was caught, and
  returned an error object with no _id — the test then read title off a null
  swimlane. Import nextSwimlaneSort at the module top level, matching every
  other import in server/models/swimlanes.js, so it is statically bundled.

- 02-cards-open-view.e2e.js:93 (editing the card title does not break the card):
  #4236 intentionally made plain Enter insert a newline in the card title
  (consistent with the description), with Ctrl/Cmd+Enter saving. The CardPage
  page object still saved with plain Enter, so the title never persisted and the
  read timed out. Save with Control+Enter (sends ctrlKey on every platform,
  which the save handler accepts).

Server-side Mocha, import regression, and Node E2E jobs already passed; this
makes the Playwright jobs green too.
2026-07-09 10:26:03 +03:00
Lauri Ojansivu a2df80e64d Updated ChangeLog. 2026-07-09 10:09:58 +03:00
Lauri Ojansivu 3aaf8f9b61 Fix #6444: RTL — typing a card title no longer garbles other lists' minicard titles
In an RTL language (e.g. Arabic) the board root is dir="rtl". Both the minicard
title (a dir="auto" .viewer) and the add-card composer textarea are dir="auto"
but had no bidirectional isolation, so they shared the surrounding bidi context.
Typing a strong RTL character into one list's composer re-resolved that shared
context and visibly reflowed the displayed minicard titles of the other lists
(no underlying data change — it reverted on refresh).

Each minicard title and the composer textarea now use `unicode-bidi: isolate`,
making them self-contained bidirectional runs that cannot perturb one another.
The isolation is locked in (positive + negative) by tests/minicardBidiIsolation.test.cjs;
full browser RTL behavior is covered by tests/playwright/specs/18-rtl-layout.e2e.js.

Thanks to xet7.
2026-07-09 10:09:32 +03:00
Lauri Ojansivu 1dfc223ca0 Updated translations 2026-07-09 09:48:44 +03:00
Lauri Ojansivu 604cc455e0 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu e85072a14a Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 1621f3784a Fix #5439: list scrollbar is now always visible on desktop and mobile browsers
List bodies used overflow-y:auto, so on platforms/browsers with overlay
scrollbars (macOS, iOS, Android, Firefox) the scrollbar auto-hid when idle and
there was no persistent affordance that a list is scrollable.

The list body now shows a scrollbar across all engines: overflow-y:scroll to
always reserve and show the track, ::-webkit-scrollbar width + thumb/track for
Chrome/Safari/Edge and mobile WebKit/Blink, scrollbar-width + scrollbar-color
for Firefox/Gecko (desktop and Android), and scrollbar-gutter:stable so content
does not shift. The cross-browser rules come from a Meteor-free builder
models/lib/scrollbarCss.js and are applied in client/components/lists/list.css;
both the builder contract and the applied CSS are unit tested (positive +
negative, including that no hide values sneak back in) in
tests/scrollbarCss.test.cjs.

Thanks to xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu ba6ad20a30 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 3847157753 Fix #3185: copying a card (or template) now copies its subtasks' checklists
When a card was copied — including generating a card from a template — each
subtask was inserted as a bare card document. The parent card's checklists were
copied, but the subtasks' checklists (and their items) were never copied, so
the new card's subtasks came out empty.

Card.copy() now copies each subtask's checklists (via Checklist.copy, which also
copies the checklist items) onto the newly inserted subtask. The subtask copy
document is built by a Meteor-free helper models/lib/subtaskCopy.js
(buildCopiedSubtaskFields — re-homes the subtask under the new parent and onto
the destination board; subtaskCopyChildKinds declares 'checklists' must be
reproduced), unit tested (positive + negative) in tests/subtaskCopy.test.cjs.

Thanks to ramses345 and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 4e77133aca Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 77c696e37b Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu e3f17be14d Fix #3624: new_swimlane REST API appends the swimlane last (and accepts a sort)
POST /api/boards/:boardId/swimlanes set the new swimlane's sort to
board.swimlanes().length (a count). When the existing swimlanes' sort values
were not a contiguous 0..n-1 run (after deletions/reordering, or swimlanes
created top-first with decreasing sorts), the count collided with or preceded
existing sorts and the new swimlane appeared FIRST instead of last.

The endpoint now computes the sort with nextSwimlaneSort(): max(existing sort)+1
for a reliable append (0 on an empty board), and honors an optional explicit
`sort` in the request body (the enhancement the reporter asked for). The pure
logic lives in models/lib/swimlaneSort.js, unit tested (positive + negative) in
tests/swimlaneSort.test.cjs.

Thanks to tamasberesoebb and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 5ca4ebe09a Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 3117e419bb Fix #3606: activity feed no longer shows "edited/deleted comment undefined"
The activity feed rendered "edited comment undefined" / "deleted comment
undefined" for comment edits and deletions. The template passed the comment's
ID into the "%s" of activity-editComment / activity-deleteComment: on older
activities the id was absent (literally "undefined"), and a deleted comment's
CardComment document is already removed so it cannot be looked up live either.

The edit and delete activities now store the comment text (commentText), and a
new Activities.commentDisplayText() helper picks the stored text, else the live
comment text (edit case), else an empty string — so the feed never renders the
word "undefined". The template shows the (sanitized) comment text. The
text-selection logic is a Meteor-free helper models/lib/commentActivity.js,
unit tested (positive + negative) in tests/commentActivity.test.cjs.

Thanks to janchuelo and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 3eba4aa782 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 127f1fb04e Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 14b4f5b5ed Fix #4249: filter by card title now works for renamed linked cards
Renaming a linked card (or linked-board card) wrote the new title only to the
linked target (via getRealId()/linkedId), leaving the linking card's OWN title
field stale. Filter-by-title queries each card document's own title field, so a
linked card silently dropped out of title filters after being renamed.

Card.setTitle() now also writes the linking card's own title (in addition to the
linked target), keeping it filterable while preserving the existing mirror
behavior. The set of documents to update is a Meteor-free helper
models/lib/linkedCardTitle.js (computeTitleUpdateTargets), unit tested
(positive + negative) in tests/linkedCardTitle.test.cjs.

Thanks to Ben0it-T and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu e6a4abd394 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 25cdc4db0d Fix #4494: Creating a board from a template no longer breaks subtasks
Creating a board from a template copies the template board. The copy inherited
the template's subtasksDefaultBoardId / dateSettingsDefaultBoardId verbatim.
When those pointed at the template board itself, subtasks created on the NEW
board were dropped onto the TEMPLATE board, and the subtask card linked back
across boards to the parent.

Board.copy() now repoints any subtasks/date-settings default that referenced
the source board to the new copy, and clears the paired list id so it self-heals
to a list on the new board. Defaults that deliberately point at another (shared)
board are left untouched. The remap logic is a Meteor-free helper
models/lib/boardCopyDefaults.js, unit tested (positive + negative) in
tests/boardCopyDefaults.test.cjs.

Thanks to Xilef11 and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu c062d93d57 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu e014d5c816 Fix #4394: Register / Forgot Password links stay hidden after a failed login
Security: with "Self Registration" and "Forgot Password" disabled in the Admin
Panel, the Register (.at-signup-link) and Forgot Password (.at-pwd-link) links
were hidden only by a one-shot jQuery .hide() in userFormsLayout.onRendered.
When a login failed (notably the LDAP "User not Found" path), useraccounts
re-rendered the form and recreated those link nodes without the inline
display:none, so the links reappeared and exposed registration / password-reset
flows that were meant to be off.

The disable state is now also reflected as a class on the stable <body>
ancestor (registration-disabled / forgotpwd-disabled) with matching CSS in
userForm.css. Because the class sits on <body>, the rule re-hides every copy of
the links that useraccounts recreates after a failed login, instead of relying
on a single inline style that the re-render dropped.

Thanks to Alsterdetektive1 and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 84ab805ec1 Fix #4055: add ISO week-number regression test (already correct in current code)
#4055 reported the calendar/date-badge week number was one week too high
(Mon-Sat) and two too high (Sunday) for 2021-10-25..31 (ISO week 43). That was
the old moment-based calculation. The current native, DST-safe
dateUtils.getISOWeek() computes these correctly (verified across a DST timezone).
Adds tests/isoWeek.test.cjs pinning the exact reported dates so the off-by-one/
two cannot silently return.

Thanks to marcungeschikts and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu a9a5ac7a07 Fix #4236: Enter adds a new line in the card title (consistent with description)
The card title textarea submitted on plain Enter (only Shift+Enter made a new
line), while the card description textarea and the shared inlinedForm insert a
new line on plain Enter and only save on Ctrl/Cmd+Enter. So Enter could not add
a new line in the title, an inconsistent experience.

The title keydown handler now uses a shared, Meteor-free rule isSubmitKey()
(models/lib/editorSubmitKey.js): submit ONLY on Enter together with Ctrl or Cmd;
plain Enter and Shift+Enter fall through to the textarea default (a new line).
This matches the description field exactly. Unit tested (positive + negative) in
tests/editorSubmitKey.test.cjs.

Thanks to listenerri and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 0119e62da5 Fix #4653: LDAP username with a hyphen no longer becomes a dot
With LDAP_UTF8_NAMES_SLUGIFY enabled, sync.js slugified the username with
limax(text, { separator: '.' }) to transliterate UTF-8 names. limax replaces
every run of non-alphanumeric characters — including hyphens — with the
separator, so an LDAP username like "p.parta-partb" was turned into
"p.parta.partb". The generated Wekan username no longer matched and the user
could not log in.

slug() now slugifies each hyphen-separated segment on its own and rejoins
them with '-', so hyphens (and existing dots) survive while UTF-8 characters
in each segment are still transliterated. The hyphen-preserving logic is a
Meteor/limax-free helper packages/wekan-ldap/server/usernameSlug.js
(slugifyPreservingHyphens, with the transliterate function injected), unit
tested (positive + negative) in tests/ldapUsernameSlug.test.cjs.

Thanks to RowhamD and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 284d4b25ea Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 75df523ef0 Fix #4946: Calendar week numbers now respect the defined start day of week
In the board Calendar view the week-number column was numbered from Sunday
regardless of the user's "start day of week" setting: setting Monday moved
the grid (via firstDay) but the week numbers still referred to Sunday-start
weeks. FullCalendar's default weekNumberCalculation is locale-based, so it
ignored firstDay.

The calendar now passes weekNumberCalculation that computes the number with
weekNumberByFirstDay(date, firstDay) from the same firstDay used to lay out
the grid, so the number matches the displayed week. The Meteor-free week math
lives in models/lib/weekStart.js and is unit tested (positive + negative,
including a case where Monday-start and Sunday-start must differ) in
tests/weekStart.test.cjs.

Thanks to helioguardabaxo and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu e5c844fca7 Fix #4881: Due this week / next week filter now respects the start day of week
The "Due this week" date filter selected the wrong week (it showed next
week's cards) and ignored the user's configured start day of week.

Root cause: relativeWeek() derived its window from startOf(now(), 'week'),
but the native dateUtils.startOf() has no 'week' case, so it returned the
date unchanged. The window therefore started at "today + startDayOfWeek"
(next week) instead of the beginning of the current week, and never took the
configured start weekday into account.

Fix:
- New Meteor-free helper models/lib/weekStart.js computes startOfWeek(),
  weekRange() and weekNumberByFirstDay() for an arbitrary start day of week
  (0=Sunday..6=Saturday, default Monday). Unit tested (positive + negative)
  in tests/weekStart.test.cjs, including a guard that "this week" must
  contain today.
- client/lib/filter.js relativeWeek() now uses weekRange(now(), weekStartDay,
  0|1) so "this week" and "next week" are the correct, non-overlapping
  Monday..Sunday (or configured) windows.
- The this/next-week buttons now toggle per week: clicking the active one
  clears it, clicking the other switches to it (the old code reset on either
  state, so you could not switch directly from this week to next week).

Thanks to mimZD and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 8a84707726 Fix #4978: Board background now updates when switching boards via favorites bar
Switching directly from one board to another using the favorites bar reused
the same boardBody template instance (currentBoard went A -> B, never null),
so boardBody.onRendered never fired again and the one-shot
Utils.setBackgroundImage() call never re-ran. The previous board's inline
background:url(...) on .board-wrapper stayed on screen. Switching via the
"all boards" list worked only because it destroys and re-creates boardBody.

Two-part fix:
- boardBody.onRendered now calls Utils.setBackgroundImage() inside a reactive
  this.autorun(), so it recomputes on every board switch (and background edit).
  It performs no DB writes, so there is no autorun write loop.
- setBackgroundImage() now clears any stale inline background when the new
  board has no image, instead of only ever setting one. The decision is a
  Meteor-free helper models/lib/boardBackground.js (computeBoardBackground),
  unit tested (positive + negative) in tests/boardBackground.test.cjs. Board
  color keeps being applied reactively via the .board-wrapper colorClass.

Thanks to dasarne and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 9f7eac158e Fix #5536: Automated rule can now move/link card to a different board
The rules move-card-to-top/bottom (on another board) and link-card-to-another-board
actions failed in two ways for boards the rule creator did not own:

- The action showed BLANK after creating the rule: the wizard inserted the
  trigger/action/rule with three optimistic client Collection.insert() calls
  that landed in minimongo limbo once the wizard subscription stopped (and the
  client allow/deny rules rejected them outright for non-owner members). These
  now create the rule through the server rules.createRule method, which keeps
  the action's own destination boardId.

- Execution crashed with a generic Internal Server Error: the destination
  swimlane fallback dereferenced ._id on a possibly-undefined swimlane titled
  exactly Default, which does not exist when the destination board's default
  swimlane was renamed, translated, or the board had no such swimlane.
  Resolution now uses the board's real default swimlane and a Meteor-free
  resolver (models/lib/ruleActionResolve.js) that returns empty string instead
  of throwing. The same guard is applied to the createCard and linkCard actions.
  Unit tested (positive + negative) in tests/ruleActionResolve.test.cjs.

Thanks to DarthKillian and xet7.
2026-07-09 09:45:53 +03:00
github-actions[bot] 9b729b502e Bump versions for v9.81 2026-07-09 00:17:50 +00:00
Lauri Ojansivu cef965f73f Updated ChangeLog. 2026-07-09 03:08:00 +03:00
Lauri Ojansivu 43dd98f8a1 Translate remaining English strings in fi.i18n.json to Finnish
Translate the strings that were still in English (mostly keys added this
release: the Rules Workflow trigger/action palette, the relative-date
unit selector, the custom color-wheel label, the Activities hide toggle
and the changed-description activity), using the terminology already used
in the existing Finnish translations.
2026-07-09 02:30:16 +03:00
Lauri Ojansivu 2cd772fcf5 Updated translations. 2026-07-09 02:25:49 +03:00
Lauri Ojansivu eaa1d823de Updated ChangeLog. 2026-07-09 02:19:52 +03:00
Lauri Ojansivu 1a41c72c21 Update CHANGELOG commit links to post-rebase hashes 2026-07-09 02:17:01 +03:00
Lauri Ojansivu 7943a17432 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu d41e17d6a0 Fix #5351: auto-add users to organizations matching their email domain on sign-up.
The Organization setting "Automatically add users with the domain name"
(org.orgAutoAddUsersWithDomainName) was never read at sign-up, so a user whose
email domain matched an organization was never added to it.

Add a pure helper models/lib/orgAutoAddByDomain.js (orgsToAutoAddForEmail) that
returns the ids of orgs whose configured domain exactly matches an email domain
(case-insensitive, exact — no subdomain, empty domain matches nothing), and wire
it into Accounts.onCreateUser so every non-admin sign-up path (password, LDAP,
invitation, new OIDC user) gains the matching org memberships using the same
{ orgId, orgDisplayName } shape used elsewhere, without duplicating existing
memberships. Add tests/orgAutoAddByDomain.test.cjs.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 4ec2cbcd5a Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 1ce2d8cc88 Fix #5369: Activities show/hide control is now a clear eye / eye-slash icon toggle.
Replace the counterintuitive unlabeled material toggle switch on the card
details Activities panel with an eye / eye-slash icon toggle: open eye means
activities are shown, crossed eye means they are hidden. Clicking flips the
state, the icon reflects the current visibility, and the tooltip states the
action (Show activities / Hide activities). The same eye pattern from the
login/register password-visibility toggle is reused for consistency.

For consistency the board sidebar Activities toggle (same showActivities
state) is switched from the check / empty-square icon to the same eye /
eye-slash icon toggle.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 8f76c7b2b0 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu b07c4d0a7b Feature #5394: sort the Link-card popup Cards dropdown alphabetically by title.
The "Link to this card" popup Cards pull-down previously listed cards in
board sort order, making a card hard to find on boards with many cards.
Sort it alphabetically by title (case-insensitive, locale/numeric aware)
via a small pure helper sortCardsByTitle, with unit tests. Thanks to xet7.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 5b43b7159e Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 7ce71dbb2e Feature #5396: edit Lists (title, color) via the REST API + api.py commands.
The REST endpoint PUT /api/boards/:boardId/lists/:listId already accepted
title, color, starred and wipLimit, but the color was stored unvalidated. It
now validates the color with normalizeListColor (a named palette color OR a
custom #rrggbb hex) and rejects an unknown color with a clear 400 instead of
silently storing None or hanging. The pure field/validation logic is extracted
to a Meteor-free helper models/lib/listApiUpdate.js so it is unit testable
without a running server (tests/listApiUpdate.test.cjs).

api.py gains two commands mirroring editcard/editcardcolor:
editlist BOARDID LISTID NEWLISTTITLE and
editlistcolor BOARDID LISTID COLOR, with usage lines in the help block.

Thanks to C0rn3j and xet7.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu d615e599f4 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu aad00cdba2 Fix #5442: outgoing webhooks now include the label name on add/remove label
Labels are embedded in the board document, not a separate collection, but the
Activities label() helper looked the label id up in the Cards collection and
always returned undefined. So the addedLabel/removedLabel webhook and
notification params never got a label value and the __label__ token in the
message text rendered as a bare, generic "label" with no name.

Resolve the label from the already-loaded board via getLabelById, and add a
pure labelDisplayName() helper (name -> color -> id fallback) so a nameless
label is shown by its color, matching the UI. Add a plain-Node unit test.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 07800da9af Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu da0d338e33 Fix #5482: adding/editing a card description fires the outgoing webhook
Card description changes did not create an activity, so no outgoing webhook
fired (unlike title/dueAt changes, whose Cards before.update hooks log an
activity). Log an a-changedDescription activity in the Cards before.update hook,
mirroring the title-change activity, so the existing Activities.after.insert
webhook hook fires. Fires on first-time set and later edits, not on no-op /
empty->empty saves. Pure descriptionChanged helper + tests, English i18n and
activity rendering.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu c3c091635d Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 8c499823cb Fix: the Rules Workflow view is now fully translatable via i18n.
The workflow trigger/action palette rendered hardcoded English labels
regardless of the UI language. Palette entries now carry an i18n labelKey
translated at render time via TAPi18n.__, reusing existing rule keys where
they fit and adding new r-w-* keys for workflow-only labels. Also routed
the slot clear tooltip through the existing r-remove key.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 1844425e8f Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 68dd14140f Fix: deleting a board rule no longer fails with "Access denied [403]"
Deleting a rule ran three separate client-side Collection.remove() calls
(Rules + Triggers + Actions). Each is gated by a per-collection allow() rule
that resolves the board from that document own boardId via
Boards.findOneAsync(doc.boardId). When a trigger/action document has no
resolvable boardId (legacy rules created before boardId was denormalised, or
docs not published to the client), the board resolves to null and
allowIsBoardAdmin returns false, so Meteor rejects the mutation with 403
"Access denied" and the delete fails even for a legitimate board admin.

Route rule deletion through a new server Meteor method rules.deleteRule, which
authorizes ONCE (active board admin or site admin) and removes the rule, its
trigger and its action server-side (bypassing the brittle client allow/deny).
The permission decision is extracted into the Meteor-free helper
models/lib/ruleDeletePermission.js and covered by
tests/ruleDeletePermission.test.cjs. No permission is loosened: only board
admins and site admins may delete, matching the board-admin gate used to
create a rule.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 1ba32f5269 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 30f36a7f31 Feature #5514: custom color-wheel (RGB/hex) picker with automatic readable text contrast.
Add a native color wheel (<input type="color">) alongside the existing fixed
named-color swatches in the color pickers for card labels, swimlanes, lists and
cards, so any #rrggbb color can be chosen. The schema color fields accept a
custom hex in addition to the named palette (via a validation that allows the
named values or a ^#[0-9a-fA-F]{6}$ hex), and normalizeListColor now passes hex
through, so existing named-color data keeps working. When a stored value is a
hex, elements apply an inline background-color instead of the card-details- /
swimlane- / list-header- / minicard- CSS class.

A new pure, Meteor-free helper models/lib/contrastColor.js computes a readable
text color from sRGB relative luminance (white on dark, black on light),
maps the named palette to hex, and validates/normalizes hex; it is applied as
an inline text color wherever a chosen color is a background behind text (label
chips, swimlane/list headers, minicard, card details header). Covered by
tests/contrastColor.test.cjs (20 assertions).
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 20b9c9ddd6 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 0e9aed5a4b Fix #5510: adding a board label via the REST API no longer errors/hangs
PUT /api/boards/:boardId/labels only wrote an HTTP response inside
`if (hasOwnProperty(req.body, "label"))` and read req.body.label.color/.name
unconditionally. Two consequences:

- A body without a `label` key fell through the `if` and NEVER sent a response,
  so the request hung until the client timed out (the reported "timeout").
- A body where `label` was a bare string (e.g. { "label": "labelName" }, the
  exact reported payload) produced name === undefined / color === undefined and
  pushed a schema-invalid label via Boards.direct, returning 200 for garbage.
- The catch returned { code: 200, data: error }, masking real failures as
  success (same anti-pattern already fixed for the board create/delete handlers).

The handler now returns a 404 for an unknown board, delegates input handling to
a new pure, Meteor-free helper models/lib/restLabel.js (buildBoardLabel), and
always sends a JSON response: 2xx for a well-formed create (documented contract
unchanged), 400 with a clear message for bad input, or the real error status in
the catch. buildBoardLabel accepts { label: { name, color } } (color defaulted
when absent, rejected when unknown) and, for convenience, a bare string taken as
the label name. Added tests/restLabel.test.cjs covering these cases.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 27d8468be0 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu d01526bbff Fix #5604: CSV export no longer crashes on boards with dangling references.
Exporter.buildCsv resolved each card list/swimlane/owner/member/assignee/label/
customField by id and read a property directly on the result of Array.find(),
which is undefined when the referenced document was deleted. Large/old boards
often contain such dangling references, so the read threw "Cannot read property
title of undefined" and crashed the whole CSV export request. Extracted the
per-card row builder into a pure, null-safe helper models/lib/exporterCsvRow.js
(unit-testable without Meteor) that emits a blank cell for a missing reference
while keeping identical output for well-formed cards. Added
tests/exporterCsvRow.test.cjs.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu ab3b48b334 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu b0da9097c1 Fix #5656: Calendar view now honors the active board filters.
The Calendar view queried cards with only a board+date selector via
Board.cardsInInterval()/cardsDueInBetween() and ignored the active board
Filter (member / assignee / due-date / label / custom-field), so it showed
every card in the interval regardless of the sidebar filter, unlike the
Board / Lists / Swimlanes views.

Extract the calendar selector building into a pure, Meteor-free helper
(models/lib/calendarFilter.js) that ANDs the Filter selector into the
date-interval selector via combineWithFilter, so the Filter is always applied
and can never collide with the interval selectors top-level $or. The calendar
events() callback now passes Filter._getMongoSelector() when Filter.isActive(),
and the onRendered autorun reads Filter.isActive() so events are refetched when
the filter changes. Add tests/calendarFilter.test.cjs.

Thanks to kerier and xet7.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 158130cbfc Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 53328f40c6 Add unit selector (minute/hour/day/week/month) to the Rules relative-date action (#5621) 2026-07-09 02:14:07 +03:00
Lauri Ojansivu e7eeaf0b10 Add 'Count amount of tests by category' menu option to rebuild-wekan scripts 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 609b07a8db Add 'Count amount of tests by category' menu option to rebuild-wekan scripts 2026-07-09 00:02:17 +03:00
Lauri Ojansivu 7545bb5506 Add #6442 fix to Upcoming WeKan release CHANGELOG section 2026-07-08 23:58:21 +03:00
Lauri Ojansivu 457713968c Fix #6442: All Boards custom drag order — drop now persists the reorder
Follow-up to #6439, which restored the drag preview but left the drop a
no-op: dragging a board on the All Boards page in "Custom (drag order)"
mode showed the dashed-border preview, but releasing it snapped the board
back without reordering.

The drop handler built the current on-screen order from
`el.classList[0]` for each `.js-board`. But the board list item is
`li.js-board(class="{{_id}} …")`, and Jade emits the literal `js-board`
class FIRST, so classList[0] is the string "js-board" for every board —
never the board _id. The ordered ids were therefore
['js-board','js-board',…]; computeReorderedSortIndex could not find the
dragged/target ids in that array, returned null, and nothing was written
to profile.boardSortIndex. The preview still worked because it is driven
by the dragover CSS class, independent of the id.

Read each board's _id from its Blaze data context (the same source
dragstart already uses via `this._id`) instead of the literal class, so
the real display order reaches the (already correct, unit-tested) reorder
helper and the drop persists. Add a regression guard in
tests/boardSortReorder.test.cjs asserting the wrong-class extraction
("js-board" repeated) yields no mapping while real ids reorder.
2026-07-08 23:57:39 +03:00
Lauri Ojansivu 372a58c112 Updated ChangeLog. 2026-07-08 23:49:32 +03:00
Lauri Ojansivu 527962966b Add Upcoming WeKan release CHANGELOG section for #6443 fix
Link the fix commit under a new "Upcoming WeKan release" heading, in the
same style as previous release entries.
2026-07-08 23:44:10 +03:00
Lauri Ojansivu a37e7d1cc7 Fix #6443: cards on a deleted swimlane are invisible in swimlane view
On old boards a swimlane can be deleted while its cards keep the now
dangling swimlaneId (an "orphaned" card). Cards with no swimlane at all
(null / '' / missing) already surface in every swimlane, but an orphaned
card matched no existing swimlane and so appeared in NO swimlane in
swimlane view, while still showing in list view (which applies no
swimlane scope) — the reported "no swimlane" symptom where some boards
show no cards in swimlane mode but work in list mode.

Mirror the existing orphaned-LIST fallback (Swimlanes.orphanedSwimlaneLists,
which surfaces orphaned lists in the first swimlane) for cards: when a
list's cards are fetched for the board's FIRST swimlane, build the
swimlane-membership clause as a single { swimlaneId: { $nin:
<otherSwimlaneIds> } } (everything not owned by another existing
swimlane: own id, null/'', missing, or orphaned). This keeps it a single
field clause with no second $or, so the #6441 board-wide label filter
still holds. Orphaned cards therefore appear once, in the first swimlane,
without a database migration.

Threaded through the pure selector helpers (swimlaneMembershipSelector /
listCardsSelector / filteredListCardsSelector) and the in-memory sibling
filterCardsByListAndSwimlane, a new List.orphanedCardsSwimlaneIds helper,
and the cards()/cardsUnfiltered()/allCards() model methods plus the
listBody cardsWithLimit render helper. Covered by new cases in
tests/swimlaneFilter.test.cjs (18 assertions pass; #6441 guards intact).
2026-07-08 23:43:26 +03:00
Lauri Ojansivu 33ad40d012 Updated translations. 2026-07-08 23:12:53 +03:00
Lauri Ojansivu 44b5e12de9 Update README to reflect Meteor version change 2026-07-06 19:07:03 +03:00
github-actions[bot] 1207b573b5 Bump versions for v9.80 2026-07-06 15:40:14 +00:00
Lauri Ojansivu a26295a2fb Updated ChangeLog. 2026-07-06 18:35:50 +03:00
Lauri Ojansivu b2da9ac8d4 Admin Panel Domains table: server-side pagination, column sort and search (like Board Table view). Thanks to xet7. 2026-07-06 18:27:38 +03:00
Lauri Ojansivu 53dcf7379d Updated ChangeLog. 2026-07-06 18:13:31 +03:00
Lauri Ojansivu dfa6c78d4f Add regression test for board-invite email language. Refs #5664. 2026-07-06 16:02:56 +03:00
Lauri Ojansivu 795850ed05 Updated ChangeLog. 2026-07-06 15:25:54 +03:00
Lauri Ojansivu 9ef7f4a07a Thanks to 32Dexter and xet7 . Fixes #5666 2026-07-06 15:22:22 +03:00
Lauri Ojansivu cb2394a95d Updated ChangeLog. 2026-07-06 15:12:21 +03:00
Lauri Ojansivu ac368de06a Thanks to MarcusDger and xet7 . Fixes #5667 2026-07-06 15:12:21 +03:00
435 changed files with 39004 additions and 4615 deletions

No files matched your search

-2
View File
@@ -1,5 +1,3 @@
version: '3.7'
services:
wekandb-dev:
+107
View File
@@ -0,0 +1,107 @@
name: Build meteor-spk bundle
# Manually triggered. Assembles WeKan's modernized meteor-spk (the Sandstorm packaging
# tool + its meteor-spk.deps: Node 24, FerretDB, the Mongo 3.x CLIs and the modern
# sandstorm-http-bridge) and zips it as meteor-spk-YYYY-MM-DD_HH_MM_SS.zip — the same kind
# of self-contained meteor-spk bundle that https://github.com/sandstorm-io/meteor-spk
# releases, but with WeKan's Node 24 / FerretDB deps baked in. Download it to `meteor-spk
# pack` WeKan (or any Meteor 3.x app) without re-running build-deps.sh each time.
#
# gh workflow run meteor-spk.yml
#
# This only assembles + zips the tool; it does NOT pack or sign a WeKan .spk, so it needs
# no SANDSTORM_KEYRING. The .spk itself is built by sandstorm.yml; the Sandstorm App
# Market publish is done by sandstorm-store.yml.
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch, tag or SHA to build the meteor-spk deps from (default: main)'
required: false
default: 'main'
permissions:
contents: write
env:
NODE_VERSION: '24'
jobs:
meteor-spk:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.ref || 'main' }}
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
# Same as sandstorm.yml: Ubuntu 24.04's AppArmor blocks the unprivileged user
# namespaces the Sandstorm install relies on; relax it so the install (whose
# sandstorm-http-bridge build-deps.sh copies in) can run on the runner.
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git zip
- name: Install Sandstorm (dev, unattended)
# build-deps.sh's [bridge] step copies the modern
# /opt/sandstorm/latest/bin/sandstorm-http-bridge into the deps, so Sandstorm
# must be installed first — same ordering as sandstorm.yml.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (Node 24 + FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io; build-deps.sh swaps in
# Node 24, FerretDB, the Mongo 3.x CLIs, the migration launcher/importer, keeps
# niscud, and swaps in the modern sandstorm-http-bridge. Produces
# $HOME/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
run: bash sandstorm-src/build-deps.sh
- name: Zip the meteor-spk bundle
id: zip
run: |
set -euo pipefail
STAMP="$(date +'%Y-%m-%d_%H_%M_%S')"
ZIP="meteor-spk-${STAMP}.zip"
BUNDLE_DIR="$HOME/projects/meteor-spk"
if [ ! -d "$BUNDLE_DIR/meteor-spk-0.6.0" ]; then
echo "::error::Expected meteor-spk bundle at $BUNDLE_DIR/meteor-spk-0.6.0 was not produced by build-deps.sh."
exit 1
fi
# Zip so it extracts to a top-level meteor-spk-0.6.0/ directory, like the
# upstream meteor-spk release archives. Exclude the base tarball build-deps.sh
# downloaded next to it.
( cd "$BUNDLE_DIR" && zip -r -y -q "$GITHUB_WORKSPACE/$ZIP" meteor-spk-0.6.0 -x '*.tar.xz' )
ZIP_BYTES=$(stat -c%s "$GITHUB_WORKSPACE/$ZIP")
echo "Packed $ZIP: $((ZIP_BYTES/1024/1024)) MiB ($ZIP_BYTES bytes)"
ls -la "$GITHUB_WORKSPACE/$ZIP"
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.zip.outputs.zip }}
path: ${{ steps.zip.outputs.zip }}
retention-days: 7
- name: Upload the meteor-spk bundle to the newest WeKan release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
echo "::warning::No existing GitHub Release found; the meteor-spk bundle is available only as a workflow artifact."
exit 0
fi
echo "Uploading ${{ steps.zip.outputs.zip }} to the newest release: $TAG"
gh release upload "$TAG" "${{ steps.zip.outputs.zip }}" --clobber
+330 -101
View File
@@ -7,13 +7,20 @@ name: Release All Platforms
# Or use the convenience script (bumps versions, commits, pushes, then triggers):
# ./releases/release-all.sh 9.35 9.36
#
# The Docker image and snap cover the platforms MongoDB ships server binaries for
# (amd64, arm64; win64 + mac bundles too); MongoDB 7.x is the default database.
# Additionally, WeKan server bundles are built for every other architecture
# Node.js 24.x publishes binaries for — s390x and ppc64le — and attached to the
# GitHub Release as extra downloads (run a separate MongoDB for those). These
# extra-arch bundles are NOT part of the Docker image or snap. (armv7l/32-bit ARM
# is not built: Node.js 24 ships no armv7l binaries.)
# WeKan's default database is MongoDB, which ships server binaries only for amd64
# and arm64 (+ the win64/mac bundles). On every OTHER architecture that has a
# Node.js 24 build — ppc64le, s390x, riscv64 — there is no MongoDB, so those
# bundles ship FerretDB v1 instead: the wekan/FerretDB fork with its embedded
# pure-Go SQLite backend, which speaks the MongoDB wire protocol. FerretDB is
# built + released SEPARATELY (wekan/FerretDB, its own build.sh) as individual
# per-arch binaries; each bundle job downloads ONLY the ferretdb-<arch> binary for
# the platform it targets and bakes it in, so amd64/arm64 users can opt into it
# too (WEKAN_DB=ferretdb, `snap set wekan
# database=ferretdb`, or Docker env). Whichever backend is chosen binds the same
# MongoDB port, so MONGO_URL is unchanged. ppc64le/s390x/riscv64 are now covered
# by the Docker image as well (self-contained, FerretDB auto-started). riscv64
# uses unofficial-builds.nodejs.org for Node 24. (armv7l/32-bit ARM is still not
# built: there is NO Node.js 24 build for it anywhere, so nothing could run it.)
#
# This workflow does EVERYTHING for a release remotely (nothing is built or
# pushed on the maintainer's machine), running jobs in parallel where possible:
@@ -26,17 +33,27 @@ name: Release All Platforms
# only AFTER the `docker` job has published the image (skipped if
# docker fails)
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
# modules for arm64 from that amd64 base
# modules for arm64 from that amd64 base. Each bundle job downloads
# only the ferretdb-<arch> binary it needs from the newest wekan/FerretDB
# release (FerretDB is built + released separately by its own build.sh —
# not compiled here) and embeds it
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
# uploads the core wekan-<version>-{amd64,arm64}.zip bundles
# - build-win64 / build-mac-arm64 / build-extra-arches: built AFTER release, each
# rebuilds the amd64 bundle's native modules for its platform (Windows,
# macOS arm64, and QEMU-emulated s390x/ppc64le) and attaches its
# .zip to the Release as an extra download. Decoupled from `release` so
# a slow/flaky build never blocks it; not in the Docker image or snap
# - docker: builds and pushes the multi-arch image to Docker Hub, Quay.io, GHCR
# - snap: builds the snap on Launchpad (amd64 + arm64), pushes it to the Snap
# Store and attaches the .snap files to the GitHub Release
# macOS arm64, and QEMU-emulated ppc64le/s390x/riscv64) and attaches
# its .zip to the Release. Windows/macOS strip the Linux FerretDB back
# out (MongoDB-only). Decoupled from `release` so a slow/flaky build
# never blocks it
# - docker: builds and pushes the multi-arch image (amd64, arm64, ppc64le, s390x,
# riscv64) to Docker Hub, Quay.io, GHCR
# - snap: builds the snap on Launchpad from the default core24 snapcraft.yaml
# (platforms: amd64, arm64, s390x, ppc64el, riscv64), pushes it to the
# Snap Store stable + candidate + beta + edge channels and attaches
# the .snap files to the GitHub Release. core24 allows stable and
# candidate (core26 does not).
# `snap set wekan database=ferretdb` switches the running backend to
# FerretDB v1 (snapcraft-core26.yaml is the core26 variant)
#
# Required GitHub Secrets:
# GHCR_AUTH - base64("username:token") for ghcr.io
@@ -258,7 +275,7 @@ jobs:
# The full Meteor build always runs here on x86_64; every other platform then
# only rebuilds the native npm modules from this bundle.
build-amd64:
needs: prepare
needs: [prepare]
runs-on: ubuntu-24.04
timeout-minutes: 180
env:
@@ -308,6 +325,35 @@ jobs:
cd .build/bundle/programs/server
npm install
- name: Embed Node.js, FerretDB (amd64) and launcher into the bundle
# Make the release .zip self-contained (offline): bundle Node.js + FerretDB
# + a start-wekan.sh that runs WeKan on the bundled Node against the bundled
# FerretDB (SQLite) by default, storing files on the filesystem under
# WRITABLE_PATH. This amd64 bundle is the base every other Linux arch is
# derived from; those jobs overwrite bundle/node and bundle/ferretdb with
# their own arch's binaries and inherit this launcher.
run: |
# Bundled Node.js: the runner's node (setup-node) is linux-x64 Node 24,
# matching this base bundle's arch.
cp -L "$(command -v node)" .build/bundle/node
chmod +x .build/bundle/node
# Bundled FerretDB (amd64): download only this arch's binary from the
# newest wekan/FerretDB release (per-arch assets, no ferretdb.zip).
curl -fSL --retry 5 --retry-delay 10 -o .build/bundle/ferretdb \
"https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64"
chmod +x .build/bundle/ferretdb
# Bundled MongoDB Database Tools (amd64) from wekan/mongo-tools (per-arch,
# no mongodb.com). This base amd64 bundle is inherited by the other Linux
# arches, which overwrite these with their own arch's tools below.
MT="https://github.com/wekan/mongo-tools/releases/latest/download"
for t in bsondump mongodump mongoexport mongofiles mongoimport mongorestore mongostat mongotop; do
curl -fSL --retry 5 --retry-delay 10 -o ".build/bundle/$t" "$MT/$t-amd64"
chmod +x ".build/bundle/$t"
done
# Self-contained launcher (defaults to FerretDB SQLite + WRITABLE_PATH).
cp releases/ferretdb/start-wekan.sh .build/bundle/
chmod +x .build/bundle/start-wekan.sh
- name: Create amd64 zip
run: |
cd .build
@@ -360,6 +406,23 @@ jobs:
node:${NODE_VERSION}-slim \
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
- name: Embed Node.js + FerretDB (arm64) into the bundle
# The launcher (start-wekan.sh) is inherited from the amd64 base bundle;
# swap bundle/node (runner is arm64 -> linux-arm64 Node 24) and bundle/ferretdb.
run: |
cp -L "$(command -v node)" bundle/node
chmod +x bundle/node
# Download only the arm64 FerretDB binary from the newest wekan/FerretDB release.
curl -fSL --retry 5 --retry-delay 10 -o bundle/ferretdb \
"https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64"
chmod +x bundle/ferretdb
# Overwrite the inherited amd64 MongoDB Database Tools with arm64 ones.
MT="https://github.com/wekan/mongo-tools/releases/latest/download"
for t in bsondump mongodump mongoexport mongofiles mongoimport mongorestore mongostat mongotop; do
curl -fSL --retry 5 --retry-delay 10 -o "bundle/$t" "$MT/$t-arm64"
chmod +x "bundle/$t"
done
- name: Create arm64 zip
run: |
zip -r wekan-${VERSION}-arm64.zip bundle
@@ -392,6 +455,11 @@ jobs:
tag_name: v${{ needs.prepare.outputs.version }}
name: v${{ needs.prepare.outputs.version }}
body: ${{ needs.prepare.outputs.changelog }}
# FerretDB binaries are NOT attached here: the per-arch ferretdb-<arch>
# binaries live on the wekan/FerretDB releases
# (https://github.com/wekan/FerretDB/releases), which is where the WeKan
# builds fetch them from. Each wekan-<version>-<arch>.zip bundle embeds
# its own arch's FerretDB binary.
files: |
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
@@ -436,6 +504,12 @@ jobs:
shell: bash
run: |
7z x wekan-${VERSION}-amd64.zip
# Drop the Linux node + FerretDB binaries + MongoDB Database Tools + POSIX
# launcher inherited from the amd64 base bundle; the Windows node.exe,
# ferretdb.exe, *.exe tools and start-wekan.bat are added in later steps.
rm -f bundle/node bundle/ferretdb bundle/.ferretdb-default bundle/start-wekan.sh
rm -f bundle/bsondump bundle/mongodump bundle/mongoexport bundle/mongofiles \
bundle/mongoimport bundle/mongorestore bundle/mongostat bundle/mongotop
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
@@ -458,40 +532,47 @@ jobs:
rm -rf node_modules
npm install
- name: Add MongoDB tools, mongosh and start-wekan.bat to the bundle
- name: Add MongoDB Database Tools to the bundle
shell: bash
run: |
set -euo pipefail
# Use the same MongoDB tools/mongosh versions as the snap, which
# releases/version.sh keeps at the newest release for each bundle.
TOOLS_VER=$(grep -oE 'mongodb-database-tools-ubuntu2204-\$\{TOOLS_ARCH\}-[0-9]+\.[0-9]+\.[0-9]+' src/snapcraft.yaml \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
MONGOSH_VER=$(grep -oE 'mongosh-[0-9]+\.[0-9]+\.[0-9]+-linux' src/snapcraft.yaml \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
echo "MongoDB Database Tools: $TOOLS_VER mongosh: $MONGOSH_VER"
# MongoDB Database Tools (Windows): bsondump, mongodump, mongoexport,
# mongofiles, mongoimport, mongorestore, mongostat, mongotop.
TOOLS_DIR="mongodb-database-tools-windows-x86_64-${TOOLS_VER}"
curl -fL -o tools.zip \
"https://fastdl.mongodb.org/tools/db/${TOOLS_DIR}.zip"
7z x -y tools.zip > /dev/null
cp "${TOOLS_DIR}/bin/"*.exe bundle/
# mongosh (Windows): mongosh.exe + mongosh_crypt_v1.dll.
MONGOSH_DIR="mongosh-${MONGOSH_VER}-win32-x64"
curl -fL -o mongosh.zip \
"https://downloads.mongodb.com/compass/${MONGOSH_DIR}.zip"
7z x -y mongosh.zip > /dev/null
cp "${MONGOSH_DIR}/bin/mongosh.exe" bundle/
cp "${MONGOSH_DIR}/bin/mongosh_crypt_v1.dll" bundle/
# Windows launcher from the repo root.
cp src/start-wekan.bat bundle/
# MongoDB Database Tools (Windows) built by wekan/mongo-tools (pure Go),
# one <tool>-win64.exe per tool from that repo's newest release — no
# mongodb.com dependency. No mongosh: WeKan no longer bundles the MongoDB
# Shell; scripted DB access uses the bundled Node.js 24 + `mongodb` driver.
# (The Windows launcher, start-wekan.bat, is added by the FerretDB embed
# step below from releases/ferretdb/start-wekan.bat.)
BASE="https://github.com/wekan/mongo-tools/releases/latest/download"
for tool in bsondump mongodump mongoexport mongofiles mongoimport mongorestore mongostat mongotop; do
curl -fL --retry 5 --retry-delay 10 -o "bundle/${tool}.exe" "${BASE}/${tool}-win64.exe"
done
echo "=== bundle contents ==="
ls -1 bundle/
- name: Embed Node.js, FerretDB (win64) and launcher into the bundle
shell: bash
# Make the Windows .zip self-contained: bundle node.exe (from the runner's
# win-x64 Node 24), ferretdb.exe (downloaded per-arch from the newest
# wekan/FerretDB release) and start-wekan.bat. FerretDB embedding is
# tolerant: if the win64 asset is missing the bundle stays MongoDB-only
# (start-wekan.bat then won't find ferretdb.exe).
run: |
# Bundled Node.js (win-x64 node.exe from setup-node).
NODE_SRC="$(command -v node)"
[ -f "$NODE_SRC" ] || NODE_SRC="${NODE_SRC}.exe"
cp "$NODE_SRC" bundle/node.exe
# Bundled FerretDB (win64): download only this arch's binary.
if curl -fSL --retry 5 --retry-delay 10 -o bundle/ferretdb.exe \
"https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe"; then
echo "Embedded FerretDB win64 -> bundle/ferretdb.exe"
else
rm -f bundle/ferretdb.exe
echo "No win64 FerretDB asset in the release; Windows bundle stays MongoDB-only."
fi
# Self-contained Windows launcher (defaults to FerretDB SQLite).
cp src/releases/ferretdb/start-wekan.bat bundle/
- name: Create win64 zip
shell: cmd
run: 7z a wekan-%VERSION%-win64.zip bundle
@@ -532,6 +613,12 @@ jobs:
- name: Rebuild native modules for macOS arm64
run: |
unzip -q wekan-${VERSION}-amd64.zip
# Replace the Linux node + FerretDB binaries + MongoDB Database Tools
# inherited from the amd64 base bundle with the macOS builds (added in a
# later step). The POSIX launcher start-wekan.sh works on macOS, so keep it.
rm -f bundle/node bundle/ferretdb bundle/.ferretdb-default
rm -f bundle/bsondump bundle/mongodump bundle/mongoexport bundle/mongofiles \
bundle/mongoimport bundle/mongorestore bundle/mongostat bundle/mongotop
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
@@ -551,6 +638,31 @@ jobs:
npm install
cd ../../..
- name: Embed Node.js + FerretDB (mac-arm64) into the bundle
# Bundle the macOS arm64 Node.js (runner's darwin-arm64 Node 24) and the
# macOS FerretDB binary, downloaded per-arch from the newest wekan/FerretDB
# release. FerretDB is tolerant: if the mac-arm64 asset is missing the
# bundle stays MongoDB-only (start-wekan.sh then won't find ferretdb).
run: |
cp -L "$(command -v node)" bundle/node
chmod +x bundle/node
if curl -fSL --retry 5 --retry-delay 10 -o bundle/ferretdb \
"https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64"; then
chmod +x bundle/ferretdb
echo "Embedded FerretDB mac-arm64 -> bundle/ferretdb"
else
rm -f bundle/ferretdb
echo "No mac-arm64 FerretDB asset in the release; macOS bundle stays MongoDB-only."
rm -f bundle/start-wekan.sh
fi
# macOS MongoDB Database Tools (mac-arm64) from wekan/mongo-tools; tolerant
# (skip any tool not built for macOS).
MT="https://github.com/wekan/mongo-tools/releases/latest/download"
for t in bsondump mongodump mongoexport mongofiles mongoimport mongorestore mongostat mongotop; do
curl -fSL --retry 5 --retry-delay 10 -o "bundle/$t" "$MT/$t-mac-arm64" \
&& chmod +x "bundle/$t" || { rm -f "bundle/$t"; echo "No $t-mac-arm64; skipping"; }
done
- name: Create mac-arm64 zip
run: zip -r wekan-${VERSION}-mac-arm64.zip bundle
@@ -568,11 +680,14 @@ jobs:
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-mac-arm64.zip" --clobber
# Extra-architecture Linux bundles (s390x, ppc64le).
# armv7l (32-bit ARM) is intentionally excluded: Node.js 24 ships no armv7l
# binaries (official or unofficial), so there is no node:24 linux/arm/v7 image
# to rebuild native modules against and no Node 24 runtime to run a bundle on
# an armv7 device. See https://nodejs.org/dist/latest-v24.x/ (no -linux-armv7l).
# Extra-architecture Linux bundles (ppc64le, s390x, riscv64) — the arches that
# have a Node.js 24 build but NO MongoDB server, so they ship FerretDB instead.
# ppc64le and s390x use official nodejs.org binaries; riscv64 uses the
# unofficial-builds.nodejs.org binary (nodejs.org ships no riscv64). armv7l
# (32-bit ARM) is intentionally excluded: there is NO Node.js 24 build for it
# anywhere (official or unofficial), so there is no runtime to run the bundle on
# an armv7 device, regardless of RAM. See https://nodejs.org/dist/latest-v24.x/
# (no -linux-armv7l) and https://unofficial-builds.nodejs.org/ (riscv64 only).
build-extra-arches:
needs: [prepare, build-amd64, release]
runs-on: ubuntu-24.04
@@ -583,8 +698,13 @@ jobs:
include:
- arch: s390x
platform: linux/s390x
node_base: official
- arch: ppc64le
platform: linux/ppc64le
node_base: official
- arch: riscv64
platform: linux/riscv64
node_base: unofficial
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
@@ -620,11 +740,62 @@ jobs:
popd > /dev/null
rm -rf "$TMP"
fi
# The official node:${NODE_VERSION} Docker image only publishes amd64,
# arm64 and ppc64le manifests — no s390x, no riscv64 — so
# `docker run --platform linux/s390x node:24-slim` fails with "no matching
# manifest". Instead build on an ubuntu:26.04 base (which publishes every
# arch) and install the Node.js binary tarball inside the emulated
# container: ppc64le/s390x from nodejs.org (official), riscv64 from
# unofficial-builds.nodejs.org (nodejs.org ships no riscv64). Resolve the
# exact latest ${NODE_VERSION}.x version on the host (fast, no emulation)
# and pass it in along with which mirror to use.
NODE_FULL=$(curl -fsSL https://nodejs.org/dist/index.json \
| python3 -c "import json,sys; print(next(r['version'] for r in json.load(sys.stdin) if r['version'].startswith('v${NODE_VERSION}.')))")
echo "Using Node ${NODE_FULL} (${{ matrix.node_base }}) for ${{ matrix.arch }}"
docker run --rm --platform ${{ matrix.platform }} \
-e NODE_FULL="$NODE_FULL" -e ARCH="${{ matrix.arch }}" \
-e NODE_BASE="${{ matrix.node_base }}" \
-v "$(pwd)/bundle:/bundle" \
-w /bundle/programs/server \
node:${NODE_VERSION}-slim \
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
ubuntu:26.04 \
bash -c '
set -eux
apt-get update -qq
apt-get install -y -q build-essential python3 curl xz-utils ca-certificates
if [ "$NODE_BASE" = unofficial ]; then
NODE_URL="https://unofficial-builds.nodejs.org/download/release/${NODE_FULL}/node-${NODE_FULL}-linux-${ARCH}.tar.xz"
else
NODE_URL="https://nodejs.org/dist/${NODE_FULL}/node-${NODE_FULL}-linux-${ARCH}.tar.xz"
fi
curl -fsSL -o /tmp/node.tar.xz "$NODE_URL"
mkdir -p /opt/node
tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1
export PATH=/opt/node/bin:$PATH
node --version
npm install
# Bundle the target-arch Node.js binary for the self-contained
# launcher. /bundle is the host bundle/ dir (see -v mount above).
cp /opt/node/bin/node /bundle/node
chmod +x /bundle/node
'
- name: Embed FerretDB (${{ matrix.arch }}) into the bundle
# These arches have no MongoDB server, so FerretDB is their default DB.
# The launcher (start-wekan.sh) is inherited from the amd64 base bundle;
# download only this arch's binary from the newest wekan/FerretDB release.
# A marker file makes FerretDB the default backend at runtime (see the
# launcher + Docker entrypoint).
run: |
curl -fSL --retry 5 --retry-delay 10 -o bundle/ferretdb \
"https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-${{ matrix.arch }}"
chmod +x bundle/ferretdb
# Overwrite the inherited amd64 MongoDB Database Tools with this arch's ones.
MT="https://github.com/wekan/mongo-tools/releases/latest/download"
for t in bsondump mongodump mongoexport mongofiles mongoimport mongorestore mongostat mongotop; do
curl -fSL --retry 5 --retry-delay 10 -o "bundle/$t" "$MT/$t-${{ matrix.arch }}"
chmod +x "bundle/$t"
done
touch bundle/.ferretdb-default
- name: Create ${{ matrix.arch }} zip
run: zip -r wekan-${VERSION}-${{ matrix.arch }}.zip bundle
@@ -647,9 +818,11 @@ jobs:
# Mirrors releases/docker-build.sh step 3 (multi-arch buildx --push).
# Needs `release` because the Dockerfile downloads wekan-<version>-<arch>.zip
# from the GitHub Release; building before those assets exist 404s and (without
# set -e in the Dockerfile) shipped a broken image missing /build/main.js.
# set -e in the Dockerfile) shipped a broken image missing /build/main.js. Also
# needs `build-extra-arches` because the image now covers ppc64le/s390x/riscv64,
# whose zips (with FerretDB baked in) are attached by that job after `release`.
docker:
needs: [prepare, release]
needs: [prepare, release, build-extra-arches]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -687,14 +860,14 @@ jobs:
# Release for each built platform. Even though this job needs `release`
# (so the assets are uploaded), GitHub's releases/download/<tag>/<asset>
# URL can briefly 404 right after upload (CDN/propagation lag), which used
# to fail the multi-arch build mid-way. Poll here until both the amd64 and
# arm64 bundles return 200, so the (expensive, emulated) buildx run only
# starts once the assets are actually fetchable. The Dockerfile also
# to fail the multi-arch build mid-way. Poll here until every arch bundle
# baked into the image returns 200, so the (expensive, emulated) buildx run
# only starts once the assets are actually fetchable. The Dockerfile also
# retries per-arch as a second layer of safety.
run: |
set -euo pipefail
base="https://github.com/wekan/wekan/releases/download/v${VERSION}"
for arch in amd64 arm64; do
for arch in amd64 arm64 ppc64le s390x riscv64; do
url="${base}/wekan-${VERSION}-${arch}.zip"
echo "Waiting for ${url} ..."
ok=0
@@ -771,7 +944,7 @@ jobs:
# report in the Admin Panel — a stale, different version.
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--platform linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/riscv64 \
--build-arg VERSION=${VERSION} \
-t wekanteam/wekan:v${VERSION} \
-t wekanteam/wekan:latest \
@@ -784,11 +957,85 @@ jobs:
# ─── 9. Build the snap on Launchpad, push to the store, attach to release ────
# The snap's `wekan` part downloads wekan-<version>-<arch>.zip from the GitHub
# Release, so this job runs only after `release` has published those assets.
# `snapcraft remote-build` builds every platform listed in snapcraft.yaml
# (amd64 + arm64) on Launchpad.
snap:
# Release, so these jobs run only after `release` has published those assets.
#
# snap-native + snap-launchpad replace the old single `snap` job. The mainstream
# arches (amd64 + arm64) build NATIVELY on GitHub-hosted runners — fast, no
# Launchpad queue. The exotic arches (s390x, ppc64el, riscv64) have no native
# GitHub runners and snapcraft cannot cross-build, so they stay on Launchpad but
# in a NON-BLOCKING, per-arch matrix so a slow/failed arch (riscv64 queues for
# hours) never holds up the release or the other arches. Each arch publishes to
# the Snap Store (stable + candidate + beta + edge) and attaches to the Release the moment
# it finishes — all 5 arches, published independently.
snap-native:
needs: [prepare, release]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- name: Verify snapcraft.yaml version matches the release
# snapcraft.yaml's `version:` names the produced file (wekan_<version>_<arch>.snap).
# Assert it matches the tag so a stale version fails fast here.
run: |
set -euo pipefail
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
exit 1
fi
echo "snapcraft.yaml is at version '${VERSION}'."
- name: Build the ${{ matrix.arch }} snap natively
# Runs on a native ${{ matrix.arch }} GitHub runner, so snapcraft builds the
# platform whose `build-on` matches this host — no Launchpad, no emulation.
id: build
uses: snapcore/action-build@v1
- name: Verify the built snap is ${{ matrix.arch }}
run: |
set -euo pipefail
snap="${{ steps.build.outputs.snap }}"
echo "Built: $snap"
case "$snap" in
*_${{ matrix.arch }}.snap) : ;;
*) echo "::error::Built snap '$snap' is not ${{ matrix.arch }}."; exit 1 ;;
esac
- name: Push ${{ matrix.arch }} to the Snap Store (stable + candidate + beta + edge)
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
with:
snap: ${{ steps.build.outputs.snap }}
release: stable,candidate,beta,edge
- name: Attach the ${{ matrix.arch }} snap to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "v${VERSION}" "${{ steps.build.outputs.snap }}" --clobber
snap-launchpad:
needs: [prepare, release]
# continue-on-error: the exotic Launchpad arches must NOT hold up or fail the
# release. riscv64 in particular queues for hours / can time out; that must not
# turn the whole release red. fail-fast: false so one arch never cancels the
# others. Each leg publishes the moment it finishes.
continue-on-error: true
strategy:
fail-fast: false
matrix:
arch: [s390x, ppc64el, riscv64]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -800,11 +1047,8 @@ jobs:
fetch-depth: 0
- name: Install snapcraft
# The Snap Store intermittently rejects the install with
# 'error: cannot install "snapcraft": too many requests' (a 429-style
# rate limit) that instantly fails the whole snap job before any build.
# Retry a few times so one transient store throttle does not fail the
# release (same spirit as the remote-build retry below).
# The Snap Store intermittently rejects the install with a 429-style rate
# limit; retry so one transient throttle does not fail the build.
run: |
set -uo pipefail
for i in 1 2 3 4 5; do
@@ -824,15 +1068,6 @@ jobs:
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
- name: Verify snapcraft.yaml version matches the release
# The `bump` job (releases/version.sh) is the single source of truth for
# the snap version and updates snapcraft.yaml before the tag is created.
# snapcraft.yaml's `version:` names the produced files
# (wekan_<version>_<arch>.snap), so if a stale tag carried the wrong
# version the build would produce wekan_<old>_*.snap and the upload/attach
# globs (wekan_${VERSION}_*.snap) would match nothing — passing the literal
# glob to `snapcraft upload` ("is not a valid file", exit 64). Assert it
# here so that mismatch fails fast (before a ~20 min Launchpad build) with
# a clear pointer, instead of re-bumping the version inside this build job.
run: |
set -euo pipefail
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
@@ -841,31 +1076,26 @@ jobs:
fi
echo "snapcraft.yaml is at version '${VERSION}'."
- name: Build snaps on Launchpad (amd64 + arm64)
# `snapcraft remote-build` is experimental and talks to Launchpad over
# HTTPS after pushing the whole repo; it intermittently dies with a
# transient TLS drop (e.g. "SSLEOFError: EOF occurred in violation of
# protocol", exit 70) that has nothing to do with the build itself.
# Retry a few times so one network hiccup doesn't fail the release.
- name: Build the ${{ matrix.arch }} snap on Launchpad
# --build-for ${{ matrix.arch }}: build ONLY this leg's arch so a slow/failed
# arch never blocks the others. remote-build can exit 0 without producing a
# .snap (it just downloads logs), so require the file. Retry a few times to
# survive transient TLS drops to Launchpad.
run: |
set -uo pipefail
shopt -s nullglob
attempts=3
for i in $(seq 1 "$attempts"); do
echo "::group::Remote build attempt $i/$attempts"
snapcraft remote-build --launchpad-accept-public-upload && rc=0 || rc=$?
echo "::group::Remote build ${{ matrix.arch }} attempt $i/$attempts"
snapcraft remote-build --launchpad-accept-public-upload \
--build-for ${{ matrix.arch }} && rc=0 || rc=$?
echo "::endgroup::"
# snapcraft remote-build can exit 0 even when the Launchpad build
# FAILED (it just downloads the build logs and produces no .snap), so
# don't trust the exit code alone — require that .snap files exist.
snaps=( wekan_${VERSION}_*.snap )
snaps=( wekan_${VERSION}_${{ matrix.arch }}.snap )
if [ "$rc" -eq 0 ] && [ "${#snaps[@]}" -gt 0 ]; then
echo "Remote build succeeded on attempt $i: ${snaps[*]}"
echo "Remote build ${{ matrix.arch }} succeeded on attempt $i: ${snaps[*]}"
exit 0
fi
echo "Attempt $i: snapcraft exit=$rc, .snap files produced=${#snaps[@]}."
# Surface the Launchpad build logs so a real build failure is
# diagnosable here, instead of as a later cryptic upload error.
for log in snapcraft-wekan-*.txt; do
echo "::group::Launchpad build log: $log (last 150 lines)"
tail -n 150 "$log"
@@ -876,34 +1106,33 @@ jobs:
sleep 60
fi
done
echo "::error::Remote build failed after $attempts attempts (no wekan_${VERSION}_*.snap produced)." >&2
echo "::error::Remote build ${{ matrix.arch }} failed after $attempts attempts (no wekan_${VERSION}_${{ matrix.arch }}.snap produced)." >&2
exit 1
- name: Push snaps to the Snap Store (edge + beta channels)
- name: Push ${{ matrix.arch }} to the Snap Store (stable + candidate + beta + edge)
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
run: |
set -euo pipefail
shopt -s nullglob
snaps=( wekan_${VERSION}_*.snap )
snaps=( wekan_${VERSION}_${{ matrix.arch }}.snap )
if [ "${#snaps[@]}" -eq 0 ]; then
echo "::error::No wekan_${VERSION}_*.snap files to upload."
echo "::error::No wekan_${VERSION}_${{ matrix.arch }}.snap to upload."
exit 1
fi
# base: core24 allows publishing to stable and candidate (core26 does not).
for f in "${snaps[@]}"; do
echo "Uploading $f to the Snap Store (edge,beta)..."
snapcraft upload --release=edge,beta "$f"
echo "Uploading $f to the Snap Store (stable,candidate,beta,edge)..."
snapcraft upload --release=stable,candidate,beta,edge "$f"
done
- name: Attach snaps to the GitHub Release
- name: Attach the ${{ matrix.arch }} snap to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
snaps=( wekan_${VERSION}_*.snap )
if [ "${#snaps[@]}" -eq 0 ]; then
echo "::error::No wekan_${VERSION}_*.snap files to attach."
exit 1
snaps=( wekan_${VERSION}_${{ matrix.arch }}.snap )
if [ "${#snaps[@]}" -gt 0 ]; then
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
fi
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
+145
View File
@@ -0,0 +1,145 @@
name: Publish .spk to Sandstorm App Market
# Manually triggered. Takes the Sandstorm .spk that sandstorm.yml already built and
# attached to a WeKan GitHub Release, renames it to wekan-<VERSION>.spk (VERSION is the
# WeKan release version, e.g. 9.88), and publishes it to the Sandstorm App Market with
# spk publish wekan-<VERSION>.spk
# exactly like releases/release-sandstorm.sh does locally.
#
# gh workflow run sandstorm-store.yml
# gh workflow run sandstorm-store.yml -f version=9.88
# gh workflow run sandstorm-store.yml -f version=9.88 -f tag=v9.88
#
# Needs SANDSTORM_KEYRING (base64 of ~/.sandstorm-keyring) — `spk publish` proves app
# ownership with the WeKan Sandstorm app's private key from that keyring — and installs
# Sandstorm + assembles meteor-spk the same way sandstorm.yml does, so `spk` is present.
# It does NOT rebuild the .spk; it publishes the one already on the release.
on:
workflow_dispatch:
inputs:
version:
description: 'WeKan version for the store filename wekan-<VERSION>.spk (e.g. 9.88). Default: derived from the release tag.'
required: false
default: ''
tag:
description: 'Release tag whose .spk to publish (default: newest release).'
required: false
default: ''
permissions:
contents: read
env:
NODE_VERSION: '24'
jobs:
sandstorm-store:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git
- name: Install Sandstorm (dev, unattended)
# Provides the `spk` command used to publish, and the sandstorm-http-bridge that
# build-deps.sh copies into the meteor-spk deps.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (same as sandstorm.yml)
run: |
bash sandstorm-src/build-deps.sh
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
- name: Restore the Sandstorm signing keyring
env:
SANDSTORM_KEYRING: ${{ secrets.SANDSTORM_KEYRING }}
run: |
if [ -n "${SANDSTORM_KEYRING:-}" ]; then
# spk publish reads the app signing key from ~/.sandstorm-keyring (a FILE in
# $HOME), same location meteor-spk pack uses.
printf '%s' "$SANDSTORM_KEYRING" | base64 -d > "$HOME/.sandstorm-keyring"
chmod 600 "$HOME/.sandstorm-keyring"
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
else
echo "::error::SANDSTORM_KEYRING secret is not set. 'spk publish' proves ownership of the WeKan Sandstorm app with the private key from this keyring, so publishing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
- name: Download the newest .spk from the WeKan release and name it wekan-<VERSION>.spk
id: fetch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
IN_TAG: ${{ github.event.inputs.tag }}
IN_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
# 1) Which release to publish from.
TAG="${IN_TAG:-}"
if [ -z "$TAG" ]; then
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
fi
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
echo "::error::No release found to publish the .spk from."
exit 1
fi
# 2) VERSION for the store filename wekan-<VERSION>.spk. Use the input, else
# derive it from the tag (strip a leading 'v', e.g. v9.88 -> 9.88).
VERSION="${IN_VERSION:-}"
if [ -z "$VERSION" ]; then
VERSION="${TAG#v}"
fi
echo "Release: $TAG -> store filename: wekan-${VERSION}.spk"
# 3) Download the newest .spk asset on that release. sandstorm.yml attaches it
# as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk; if several exist, take the
# lexicographically-newest (the timestamp sorts chronologically).
ASSET="$(gh release view "$TAG" --json assets \
--jq '[.assets[].name | select(endswith(".spk"))] | sort | last' 2>/dev/null || true)"
if [ -z "${ASSET:-}" ] || [ "$ASSET" = "null" ]; then
echo "::error::Release $TAG has no .spk asset. Run sandstorm.yml first to build and attach one."
exit 1
fi
echo "Downloading .spk asset: $ASSET"
gh release download "$TAG" --pattern "$ASSET" --dir . --clobber
SPK="wekan-${VERSION}.spk"
mv -f "$ASSET" "$SPK"
ls -la "$SPK"
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
- name: Publish to the Sandstorm App Market
env:
SPK: ${{ steps.fetch.outputs.spk }}
run: |
set -euo pipefail
# Resolve the spk binary: prefer PATH, then the Sandstorm install location.
SPK_BIN="$(command -v spk || true)"
if [ -z "$SPK_BIN" ]; then
for c in /opt/sandstorm/latest/bin/spk /opt/sandstorm/spk /usr/local/bin/spk; do
[ -x "$c" ] && SPK_BIN="$c" && break
done
fi
if [ -z "$SPK_BIN" ]; then
echo "::error::Could not find the 'spk' command from the Sandstorm install."
exit 1
fi
echo "Publishing $SPK with $SPK_BIN ..."
# Same command as releases/release-sandstorm.sh: spk publish wekan-<VERSION>.spk
"$SPK_BIN" publish "$SPK"
+144
View File
@@ -0,0 +1,144 @@
name: Build Sandstorm .spk
# Manually triggered. Builds ONLY the Sandstorm .spk package (nothing else) and
# uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the NEWEST WeKan GitHub
# Release as an attachment, so you can download it, test whether the Sandstorm
# build works, and see any errors — without running a full release.
#
# gh workflow run sandstorm.yml
#
# EXPERIMENTAL in CI (same caveats as the build-sandstorm job in release-all.yml):
# * Sandstorm needs unprivileged user namespaces; GitHub's Ubuntu runners are VMs
# that allow them, but this is the fragile part.
# * meteor-spk SIGNS the .spk with the app private key from the Sandstorm keyring;
# provide it as the SANDSTORM_KEYRING secret (base64 of ~/.sandstorm keyring).
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch, tag or SHA to build the .spk from (default: main)'
required: false
default: 'main'
permissions:
contents: write
env:
NODE_VERSION: '24'
jobs:
sandstorm:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
with:
# Build the dispatched ref so a fix branch can be tested before it is
# merged to main; defaults to main for a plain `gh workflow run`.
ref: ${{ github.event.inputs.ref || 'main' }}
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
# which blocks the unprivileged user namespaces that the Sandstorm install and
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies and Meteor
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git
if [ ! -f "$HOME/.meteor/meteor" ]; then curl https://install.meteor.com/ | sh; fi
echo "$HOME/.meteor" >> "$GITHUB_PATH"
- name: Install Sandstorm (dev, unattended)
# Installed BEFORE assembling the deps: build-deps.sh's [bridge] step copies
# the modern /opt/sandstorm/latest/bin/sandstorm-http-bridge over the ancient
# bundled one, so the Sandstorm install must be in place first.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (Node 24 + FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
# niscud). build-deps.sh swaps in Node 24, adds FerretDB + the Mongo 3.x
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
# on the retired releases.wekan.team or the old projects.7z.
run: |
bash sandstorm-src/build-deps.sh
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
- name: Restore the Sandstorm signing keyring
env:
SANDSTORM_KEYRING: ${{ secrets.SANDSTORM_KEYRING }}
run: |
if [ -n "${SANDSTORM_KEYRING:-}" ]; then
# meteor-spk / spk read the app signing key from ~/.sandstorm-keyring —
# a FILE directly in $HOME (see the pack crash path
# "/home/runner/.sandstorm-keyring"). Write it exactly there, NOT to
# ~/.sandstorm/sandstorm-keyring, or `meteor-spk pack` aborts with
# "open(~/.sandstorm-keyring): No such file or directory".
printf '%s' "$SANDSTORM_KEYRING" | base64 -d > "$HOME/.sandstorm-keyring"
chmod 600 "$HOME/.sandstorm-keyring"
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
else
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
- name: Install Meteor npm dependencies
# PUPPETEER_SKIP_DOWNLOAD: puppeteer is a devDependency used only by tests,
# not by the server, so it never belongs in the packed .spk. Skipping its
# Chromium download (~150 MB) avoids any risk of it bloating the build and
# speeds up CI.
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
run: |
(meteor update --npm 2>/dev/null || true) && meteor npm install
- name: Build the Sandstorm .spk
id: build
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
run: |
set -euo pipefail
STAMP="$(date +'%Y_%m_%d-%H_%M_%S')"
SPK="wekan-sandstorm-${STAMP}.spk"
meteor-spk pack "$SPK"
# Report the packed size and warn if it exceeds Cloudflare's 100 MB upload
# cap (a proxied browser upload to a Cloudflare-fronted Sandstorm 413s above
# this; install via a DNS-only host / SSH tunnel, or trim further).
SPK_BYTES=$(stat -c%s "$SPK")
echo "Packed $SPK: $((SPK_BYTES/1024/1024)) MiB ($SPK_BYTES bytes)"
if [ "$SPK_BYTES" -gt $((100*1024*1024)) ]; then
echo "::warning::$SPK is $((SPK_BYTES/1024/1024)) MiB — over Cloudflare's 100 MB upload limit. Upload via a DNS-only host / SSH tunnel to Sandstorm, or trim the bundle further."
fi
ls -la "$SPK"
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.build.outputs.spk }}
path: ${{ steps.build.outputs.spk }}
retention-days: 7
- name: Upload the .spk to the newest WeKan release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
echo "::error::No existing GitHub Release found to attach the .spk to."
exit 1
fi
echo "Uploading ${{ steps.build.outputs.spk }} to the newest release: $TAG"
gh release upload "$TAG" "${{ steps.build.outputs.spk }}" --clobber
+14
View File
@@ -70,3 +70,17 @@ test-results/
_build-local-test
*/build-assets-local-test
*/build-chunks-local-test
# Local-only working copies: other wekan repos cloned under this repo for editing,
# and build inputs. These are separate git repos / large machine-specific dirs;
# never commit them.
# - projects/ : unarchived meteor-spk fork (Node/Mongo/FerretDB binaries)
# - sandstorm/ : clone of sandstorm-io/sandstorm (platform source, for reference)
# - FerretDB/ : clone of wekan/FerretDB
# - mongo-tools/ : clone of wekan/mongo-tools
# - mongosh/ : clone of wekan/mongosh
/projects/
/sandstorm/
/FerretDB/
/mongo-tools/
/mongosh/
+1 -1
View File
@@ -1,6 +1,6 @@
[main]
host = https://www.transifex.com
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja-JP, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
[o:wekan:p:wekan:r:application]
file_filter = imports/i18n/data/<lang>.i18n.json
+2040 -13
View File
File diff suppressed because it is too large. Load diff
+36 -10
View File
@@ -7,7 +7,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=9.79
ARG VERSION=9.95
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
@@ -185,24 +185,35 @@ apt-get update --assume-yes
apt-get upgrade --assume-yes
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS}
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name
# arm/v7 uses TARGETARCH=arm; armhf has no MongoDB Community -> uses FerretDB
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name.
# amd64/arm64 have MongoDB Community; ppc64le/s390x/riscv64 have no MongoDB server
# and ship FerretDB v1 instead (the ferretdb binary is baked into their .zip and
# started by wekan-entrypoint.sh). riscv64's Node.js 24 comes from
# unofficial-builds.nodejs.org (nodejs.org ships no riscv64). armv7l is excluded:
# there is no Node.js 24 build for it anywhere.
NODE_BASE="official"
case "${TARGETARCH}" in
"amd64") NODE_ARCH="x64" WEKAN_ARCH="amd64" ;;
"arm64") NODE_ARCH="arm64" WEKAN_ARCH="arm64" ;;
"arm") NODE_ARCH="armv7l" WEKAN_ARCH="armhf" ;;
"ppc64le") NODE_ARCH="ppc64le" WEKAN_ARCH="ppc64le" ;;
"s390x") NODE_ARCH="s390x" WEKAN_ARCH="s390x" ;;
"riscv64") NODE_ARCH="riscv64" WEKAN_ARCH="riscv64" NODE_BASE="unofficial" ;;
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;;
esac
# Node.js Installation (official nodejs.org builds for Node 22)
# Node.js installation. Official nodejs.org builds for amd64/arm64/ppc64le/s390x;
# unofficial-builds.nodejs.org for riscv64 (nodejs.org ships no riscv64 binary).
cd /tmp
wget "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz"
wget "https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc"
grep "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc | shasum -a 256 -c -
if [ "${NODE_BASE}" = "unofficial" ]; then
NODE_DIST="https://unofficial-builds.nodejs.org/download/release/${NODE_VERSION}"
else
NODE_DIST="https://nodejs.org/dist/${NODE_VERSION}"
fi
wget "${NODE_DIST}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz"
wget "${NODE_DIST}/SHASUMS256.txt"
grep " node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz\$" SHASUMS256.txt | shasum -a 256 -c -
tar xzf "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc
rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
# NPM configuration
@@ -229,6 +240,14 @@ rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
npm install --prefix ./bundle/programs/server
mv /home/wekan/app/bundle /build
# The .zip bundle now ships a self-contained launcher + its own Node.js for the
# offline downloads; the Docker image installs its own Node and uses
# wekan-entrypoint.sh, so drop the redundant bundled node + launchers. Keeps
# /build/ferretdb (used by the entrypoint) and the per-arch MongoDB Database Tools
# (bsondump, mongodump, mongorestore, … from wekan/mongo-tools, embedded in the
# bundle) for backup/restore inside the container. Saves ~80 MB per arch.
rm -f /build/node /build/start-wekan.sh /build/start-wekan.bat
# Restore original tar
mv $(which tar)~ $(which tar)
@@ -247,10 +266,17 @@ mkdir -p /data
chown wekan:wekan --recursive /data
EOR
# Database-backend selector entrypoint. Every arch's bundle now ships a FerretDB
# binary (baked into the .zip and moved to /build/ferretdb above); this script
# starts FerretDB v1 (SQLite) or leaves the DB external based on WEKAN_DB and the
# /build/.ferretdb-default marker (present only on MongoDB-less arches). See
# releases/ferretdb/wekan-entrypoint.sh.
COPY --chmod=755 releases/ferretdb/wekan-entrypoint.sh /build/wekan-entrypoint.sh
USER wekan
ENV PORT=8080
EXPOSE $PORT
STOPSIGNAL SIGKILL
WORKDIR /build
CMD ["bash", "-c", "ulimit -s 65500; exec node main.js"]
CMD ["bash", "/build/wekan-entrypoint.sh"]
+21 -13
View File
@@ -103,10 +103,10 @@ By working directly with WeKan ® you get the benefit of active maintenance and
## Getting Started with Development
The default branch uses Meteor 3.5-rc.2 with Node.js 24.x.
The main branch uses Meteor 3.5 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-install-dependencies-build-wekan-and-run-the-dev-server). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
@@ -138,11 +138,17 @@ curl https://install.meteor.com/ | sh
### Building WeKan
The `rebuild-wekan.sh` script guides you through a three-stage process:
The `rebuild-wekan.sh` script shows a two-level menu. The top level groups options into categories:
1. **Install dependencies (Option 1)** - Downloads all required Meteor packages and npm modules
2. **Build WeKan (Option 2)** - Compiles the application
3. **Run WeKan (Option 3)** - Starts the development server at http://localhost:4000
```
1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit
```
You pick a category number, then the item number inside it (each submenu also has a `Back` entry). Building from source is a three-stage process:
1. **Setup -> Install dependencies** - Downloads all required Meteor packages and npm modules
2. **Setup -> Build WeKan** - Compiles the application
3. **Dev server -> localhost:3000** - Starts the development server at http://localhost:3000
```bash
# Clone your fork
@@ -152,25 +158,27 @@ cd wekan
# Make the script executable
chmod +x rebuild-wekan.sh
# Step 1: Install dependencies
# Step 1: Install dependencies (Setup -> Install dependencies)
./rebuild-wekan.sh
# Press 1 and Enter
# Press 1 (Setup) and Enter, then 1 (Install dependencies) and Enter
# Step 2: Build WeKan (after dependencies complete)
# Step 2: Build WeKan (Setup -> Build WeKan, after dependencies complete)
./rebuild-wekan.sh
# Press 2 and Enter
# Press 1 (Setup) and Enter, then 2 (Build WeKan) and Enter
# Step 3: Run WeKan in development mode
# Step 3: Run WeKan in development mode (Dev server -> localhost:3000)
./rebuild-wekan.sh
# Press 3 and Enter
# Press 2 (Dev server) and Enter, then 1 (localhost:3000) and Enter
```
If a dev server is already running on that port, the **Dev server** options stop it automatically and start a fresh server on the same port.
### WSL Users
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
The development server will start at http://localhost:4000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
The development server will start at http://localhost:3000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
## Screenshot
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.79.0"
appVersion: "v9.95.0"
files:
userUploads:
- README.md
+45
View File
@@ -37,6 +37,8 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py lists BOARDID # Lists of BOARDID
python3 api.py list BOARDID LISTID # Info of LISTID
python3 api.py createlist BOARDID LISTTITLE # Create list
python3 api.py editlist BOARDID LISTID NEWLISTTITLE # Edit list title
python3 api.py editlistcolor BOARDID LISTID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit list color
python3 api.py addcard AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION
python3 api.py editcard BOARDID LISTID CARDID NEWCARDTITLE NEWCARDDESCRIPTION
python3 api.py getcard BOARDID LISTID CARDID # Get card info
@@ -432,6 +434,49 @@ if arguments == 5:
if arguments >= 4:
if sys.argv[1] == 'editlist':
# ------- EDIT LIST START -----------
boardid = sys.argv[2]
listid = sys.argv[3]
newlisttitle = sys.argv[4]
edlist = wekanurl + apiboards + boardid + s + l + s + listid
print(edlist)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'title': '{}'.format(newlisttitle)}
body = requests.put(edlist, data=put_data, headers=headers)
print("=== EDIT LIST ===\n")
body = requests.get(edlist, headers=headers)
data2 = body.text.replace('}', "}\n")
print(data2)
# ------- EDIT LIST END -----------
if sys.argv[1] == 'editlistcolor':
# ------- EDIT LIST COLOR START -----------
boardid = sys.argv[2]
listid = sys.argv[3]
newcolor = sys.argv[4]
valid_colors = ['white', 'green', 'yellow', 'orange', 'red', 'purple', 'blue', 'sky', 'lime', 'pink', 'black',
'silver', 'peachpuff', 'crimson', 'plum', 'darkgreen', 'slateblue', 'magenta', 'gold', 'navy',
'gray', 'saddlebrown', 'paleturquoise', 'mistyrose', 'indigo']
if newcolor not in valid_colors:
print("Invalid color. Choose a color from the list.")
sys.exit(1)
edlist = wekanurl + apiboards + boardid + s + l + s + listid
print(edlist)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'color': '{}'.format(newcolor)}
body = requests.put(edlist, data=put_data, headers=headers)
print("=== EDIT LIST COLOR ===\n")
body = requests.get(edlist, headers=headers)
data2 = body.text.replace('}', "}\n")
print(data2)
# ------- EDIT LIST COLOR END -----------
if sys.argv[1] == 'newuser':
# ------- CREATE NEW USER START -----------
+10 -4
View File
@@ -1,7 +1,13 @@
// Fix dynamic-import chunk loading when Wekan runs under a sub-path (ROOT_URL includes a pathname,
// e.g. https://example.com/wekan). Rspack uses __webpack_public_path__ to prefix chunk URLs; by
// default it is set to /build-chunks/ (no context path). We update it here, at module evaluation
// time, before any import() expression runs (those are deferred to Meteor.startup callbacks).
// e.g. https://example.com/wekan). Rspack's runtime builds every chunk URL as
// __webpack_public_path__ + chunkName, and the chunk name ALREADY includes the "build-chunks/"
// prefix (its runtime does `u = e => "build-chunks/" + e + "." + hash + ".js"`). The default
// public path is "/", so at the site root chunks correctly resolve to "/build-chunks/<chunk>".
// Under a sub-path we must prefix that sub-path — but must NOT re-add "build-chunks/" ourselves,
// or the URL becomes "/wekan/build-chunks/build-chunks/<chunk>" and 404s (issue #6445, which broke
// the lazy-loaded language chunks). So set the public path to "<sub-path>/" and let rspack append
// "build-chunks/" itself. Done here at module evaluation time, before any import() expression runs
// (those are deferred to Meteor.startup callbacks).
/* global __webpack_public_path__:writable */
try {
const _cfg = typeof window !== 'undefined' && window.__meteor_runtime_config__;
@@ -9,7 +15,7 @@ try {
if (_rootUrl) {
const _rootPath = new URL(_rootUrl).pathname.replace(/\/+$/, '');
if (_rootPath && _rootPath !== '/') {
__webpack_public_path__ = _rootPath + '/build-chunks/';
__webpack_public_path__ = _rootPath + '/';
}
}
} catch (_) {}
@@ -15,6 +15,20 @@
.reactions-popup .add-comment-reaction:hover {
background-color: #b0c4de;
}
/* Eye / eye-slash icon toggle for showing/hiding activities. Open eye
(.eye-icon) = activities shown; crossed eye (.eye-slash-icon) = hidden.
Matches the password-visibility toggle used on the login/register pages. */
.activities-eye-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4em;
text-decoration: none;
}
.activities-eye-toggle .eye-icon,
.activities-eye-toggle .eye-slash-icon {
line-height: 1;
}
.activities {
clear: both;
}
+5 -2
View File
@@ -106,10 +106,10 @@ template(name="activity")
//- comment activity ----------------------------------------------------
if($eq activity.activityType 'deleteComment')
| {{{_ 'activity-deleteComment' activity.commentId}}}.
| {{{_ 'activity-deleteComment' (sanitize activity.commentDisplayText)}}}.
if($eq activity.activityType 'editComment')
| {{{_ 'activity-editComment' activity.commentId}}}.
| {{{_ 'activity-editComment' (sanitize activity.commentDisplayText)}}}.
if($eq activity.activityType 'addComment')
| {{{_ 'activity-on' cardLink}}}
@@ -133,6 +133,9 @@ template(name="activity")
if($eq activity.activityType 'a-changedTitle')
| {{{_ 'activity-changedTitle' (sanitize activity.value) cardLink}}}.
if($eq activity.activityType 'a-changedDescription')
| {{{_ 'activity-changedDescription' cardLink}}}.
//- customField activity ------------------------------------------------
if($eq activity.activityType 'createCustomField')
| {{_ 'activity-customfield-created' customField}}.
+39 -5
View File
@@ -6,11 +6,13 @@ import '/client/lib/dragscrollTouch';
import { boardConverter } from '/client/lib/boardConverter';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
import { weekNumberByFirstDay } from '/models/lib/weekStart';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { Filter } from '/client/lib/filter';
// SubsManager removed for Meteor 3 migration
const { calculateIndex } = Utils;
@@ -568,17 +570,32 @@ Template.boardBody.onRendered(function () {
$swimlanesDom.sortable('option', 'handle', '.swimlane-header');
}
// Disable drag-dropping if the current user is not a board member
// #6446: enable swimlane drag-drop — which also carries moving cards BETWEEN
// swimlanes — for any board member with write access, not only board admins.
// The old !isBoardAdmin() check disabled this sortable for every non-admin, so
// a normal member could still move a card WITHIN a swimlane but not BETWEEN
// swimlanes (a card crossing swimlanes is received across this sortable), and
// could not reorder swimlanes. canModifyCard() is the client write-access
// predicate (board member, not comment-only / worker / read-only), matching
// the server's allowIsBoardMemberWithWriteAccess.
$swimlanesDom.sortable(
'option',
'disabled',
!ReactiveCache.getCurrentUser()?.isBoardAdmin(),
!Utils.canModifyCard(),
);
}
});
dragscroll.reset();
Utils.setBackgroundImage();
// #4978: apply the board background inside a reactive autorun instead of a
// one-shot call. Switching directly between boards (e.g. the favorites bar)
// reuses this template instance and never re-fires onRendered, so the
// background used to stay on the previously shown board. Reading the current
// board inside setBackgroundImage() tracks both board switches and background
// edits; it performs no DB writes, so there is no autorun write loop.
this.autorun(() => {
Utils.setBackgroundImage();
});
});
Template.boardBody.onDestroyed(function () {
@@ -929,6 +946,11 @@ Template.calendarView.onRendered(function () {
document.documentElement.dir = TAPi18n.getLanguageDirection();
this.autorun(function () {
// #5656: refetch calendar events whenever the active board Filter changes
// so the Calendar view stays in sync with the other views. Reading the
// Filter's reactive state here establishes the dependency; the events()
// callback below applies the actual selector.
Filter.isActive();
const calendarEl = document.getElementById('calendar-view');
if (calendarEl && calendarEl._wekanCalendar) {
calendarEl._wekanCalendar.refetchEvents();
@@ -959,6 +981,12 @@ Template.calendarView.helpers({
editable: true,
selectable: true,
weekNumbers: true,
// #4946: FullCalendar's default weekNumberCalculation is locale-based
// (Sunday-anchored for many locales), so the week-number column ignored
// the user's start-day-of-week setting even though `firstDay` moved the
// grid. Compute the number from the SAME firstDay so the label matches the
// way the week is laid out.
weekNumberCalculation: date => weekNumberByFirstDay(date, firstDay),
// Use non-localized AM/PM time format to avoid confusing notations like 上/下/中
// Use full 'am'/'pm' instead of single-letter 'a'/'p' for clarity
eventTimeFormat: {
@@ -1002,6 +1030,12 @@ Template.calendarView.helpers({
isRTL: TAPi18n.isRTL(),
events(fetchInfo, callback) {
const currentBoard = Utils.getCurrentBoard();
// #5656: apply the active board Filter (member / assignee / due-date /
// label / custom-field) so the Calendar view shows the same scoped set
// of cards as the Board / Lists / Swimlanes views.
const filterSelector = Filter.isActive()
? Filter._getMongoSelector()
: undefined;
const events = [];
const pushEvent = function (card, title, start, end, extraCls) {
start = start || card.startAt;
@@ -1026,12 +1060,12 @@ Template.calendarView.helpers({
});
};
currentBoard
.cardsInInterval(fetchInfo.start, fetchInfo.end)
.cardsInInterval(fetchInfo.start, fetchInfo.end, filterSelector)
.forEach(function (card) {
pushEvent(card);
});
currentBoard
.cardsDueInBetween(fetchInfo.start, fetchInfo.end)
.cardsDueInBetween(fetchInfo.start, fetchInfo.end, filterSelector)
.forEach(function (card) {
pushEvent(
card,
+35 -29
View File
@@ -23,15 +23,16 @@ template(name="boardHeaderBar")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
unless watchDisabled
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
@@ -67,14 +68,15 @@ template(name="boardHeaderBar")
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
unless watchDisabled
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
@@ -267,9 +269,10 @@ template(name="createBoard")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
@@ -293,9 +296,10 @@ template(name="createBoardPopup")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
@@ -319,9 +323,10 @@ template(name="headerBarCreateBoardPopup")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
@@ -346,9 +351,10 @@ template(name="createTemplateContainerPopup")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
+8 -3
View File
@@ -1019,10 +1019,15 @@ Template.boardList.events({
evt.stopPropagation();
evt.currentTarget.classList.remove('board-reorder-over');
const targetId = this._id;
// Current display order of the board grid: the li.js-board carries the board
// id as its first class (see the .js-board template and #filterBtn handler).
// #6442: current display order of the board grid. Read each board's _id from
// its Blaze data context (the same source dragstart uses via `this._id`) —
// NOT el.classList[0]. On `li.js-board(class="{{_id}} …")` Jade emits the
// literal `js-board` class FIRST, so classList[0] is the string "js-board"
// for every board; the old code therefore produced ['js-board','js-board',…],
// computeReorderedSortIndex could not find the dragged/target ids among them,
// returned null, and the drop silently did nothing (board snapped back).
const orderedIds = Array.from(tpl.findAll('.js-board'))
.map((el) => el.classList[0])
.map((el) => Blaze.getData(el)?._id)
.filter(Boolean);
const mapping = computeReorderedSortIndex(orderedIds, draggedId, targetId);
if (!mapping) return;
+1 -1
View File
@@ -66,7 +66,7 @@ template(name="tableView")
td.table-view-cell-labels
.div
each label in row.labels
span.card-label(class="card-label-{{label.color}}" title="{{label.name}}")
span.card-label(class="{{labelColorClass label.color}}" style="{{labelColorStyle label.color}}" title="{{label.name}}")
+viewer
= label.name
if showReceivedColumn
+13 -6
View File
@@ -432,11 +432,16 @@ export async function handleFileUpload(card, files) {
fileName: fileName,
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
// Use HTTP transport instead of DDP so file chunks go over a dedicated
// fetch POST rather than flooding the WebSocket/DDP channel. DDP
// upload causes repeated DDP reconnects in Safari, showing the
// "Loading, please wait" offline banner and stalling progress at ~95%.
transport: 'http',
// Use HTTP transport (a dedicated fetch POST per chunk) rather than DDP,
// which floods the WebSocket channel and causes repeated reconnects in
// Safari (the "Loading, please wait" offline banner, stalling at ~95%).
// EXCEPT on Sandstorm: the grain's sandstorm-http-bridge strips Meteor-Files'
// custom upload headers — x-start/x-mtok/x-chunkid/x-fileid/x-eof are not on
// Sandstorm's request-header whitelist — so the server never sees x-start,
// treats every request as a chunk continuation, and fails with "Can't
// continue upload, session expired" [408]. DDP uploads go over method calls
// (no custom HTTP headers), so they work through the bridge.
transport: Meteor.settings?.public?.sandstorm ? 'ddp' : 'http',
};
config.meta.fileId = fileId;
@@ -528,7 +533,9 @@ Template.previewClipboardImagePopup.events({
meta: Utils.getCommonAttachmentMetaFrom(card),
fileName: file.name || file.type.replace('image/', 'clipboard.'),
chunkSize: 'dynamic',
transport: 'http',
// DDP on Sandstorm (its bridge strips Meteor-Files' x-* upload headers),
// HTTP elsewhere — see the note in the other upload handler above.
transport: Meteor.settings?.public?.sandstorm ? 'ddp' : 'http',
};
config.meta.fileId = fileId;
const uploader = await Attachments.insertAsync(
+12 -8
View File
@@ -6,7 +6,7 @@ template(name="cardDetails")
+attachmentViewer
section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}' class='{{#if cardCollapsed}}card-details-collapsed{{/if}}'): .card-details-canvas
.card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}')
.card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}' style='{{colorStyle}}')
+inlinedForm(classNames="js-card-details-title")
+editCardTitleForm
else
@@ -127,7 +127,7 @@ template(name="cardDetails")
| {{_ 'labels'}}
a(class="{{#if canModifyCard}}js-add-labels{{else}}is-disabled{{/if}}" title="{{_ 'card-labels-title'}}")
each labels
span.card-label(class="card-label-{{color}}" title=name)
span.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+viewer
= name
if canModifyCard
@@ -741,12 +741,12 @@ template(name="cardDetails")
i.fa.fa-history
| {{ _ 'activities'}}
if currentUser.isBoardMember
.material-toggle-switch(title="{{_ 'show-activities'}}")
if showActivities
input.toggle-switch(type="checkbox" id="toggleShowActivitiesCard" checked="checked")
else
input.toggle-switch(type="checkbox" id="toggleShowActivitiesCard")
label.toggle-label(for="toggleShowActivitiesCard")
if showActivities
a.activities-eye-toggle.js-toggle-show-activities-card(title="{{_ 'hide-activities'}}" aria-label="{{_ 'hide-activities'}}")
i.fa.fa-eye.eye-icon
else
a.activities-eye-toggle.js-toggle-show-activities-card(title="{{_ 'show-activities'}}" aria-label="{{_ 'show-activities'}}")
i.fa.fa-eye-slash.eye-slash-icon
if currentUser.isBoardAdmin
if isLoaded.get
@@ -1199,6 +1199,10 @@ template(name="setCardColorPopup")
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
// #5514: native color wheel for picking a custom RGB / hex color
.color-wheel-wrap
label(for="cardColorWheel") {{_ 'custom-color'}}
input.js-card-color-wheel#cardColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
+19 -4
View File
@@ -50,8 +50,10 @@ import Users from '/models/users';
import Lists from '/models/lists';
import CardComments from '/models/cardComments';
import { ALLOWED_COLORS } from '/config/const';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { uniqBy } from '/imports/lib/collectionHelpers';
import { memberTargetBoardId } from '/models/lib/linkedCardMembers';
import { isSubmitKey } from '/models/lib/editorSubmitKey';
import { UserAvatar } from '../users/userAvatar';
import { Filter } from '/client/lib/filter';
import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlaneListCard';
@@ -1046,7 +1048,7 @@ Template.cardDetails.events({
const card = Template.currentData();
await card.toggleHideCheckedChecklistItems();
},
async 'change #toggleShowActivitiesCard'() {
async 'click .js-toggle-show-activities-card'() {
const card = Template.currentData();
await card.toggleShowActivities();
},
@@ -1498,9 +1500,11 @@ Template.editCardTitleForm.events({
Utils.showCopied(promise, $tooltip);
},
'keydown .js-edit-card-title'(event) {
// If enter key was pressed, submit the data
// Unless the shift key is also being pressed
if (event.keyCode === 13 && !event.shiftKey) {
// #4236: match the card description field — Ctrl/Cmd+Enter saves; plain
// Enter (and Shift+Enter) insert a new line instead of submitting. The old
// handler submitted on any Enter without Shift, so Enter could not add a
// newline in the title.
if (isSubmitKey(event)) {
$('.js-submit-edit-card-title-form').click();
}
},
@@ -2032,6 +2036,10 @@ Template.setCardColorPopup.helpers({
}
return tpl.currentColor.get() === color;
},
// #5514: current color as a '#rrggbb' hex for the native color wheel <input>.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#0079bf';
},
});
Template.setCardColorPopup.events({
@@ -2039,6 +2047,13 @@ Template.setCardColorPopup.events({
const paletteData = Blaze.getData(event.currentTarget);
tpl.currentColor.set(paletteData?.color);
},
// #5514: picking from the native color wheel stores a custom hex.
'input .js-card-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
async 'click .js-submit'(event, tpl) {
event.preventDefault();
const card = Cards.findOne(getCardId());
+21
View File
@@ -1,3 +1,24 @@
/* #5514: native color-wheel (custom RGB / hex) picker wrapper, shared by the
label / list / swimlane / card color popups. */
.color-wheel-wrap {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 10px 0;
}
.color-wheel-wrap label {
margin: 0;
}
.color-wheel-wrap input[type="color"] {
width: 44px;
height: 28px;
padding: 0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: none;
}
.card-label {
border: 1px solid #000;
border-radius: 4px;
+6 -3
View File
@@ -4,9 +4,12 @@ template(name="formLabel")
label {{_ "select-color"}}
.palette-colors: each labels
span.card-label.palette-color.js-palette-color(class="card-label-{{color}}")
span.card-label.palette-color.js-palette-color(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
if(isSelected color)
i.fa.fa-check
.color-wheel-wrap
label(for="labelColorWheel") {{_ 'custom-color'}}
input.js-label-color-wheel#labelColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
template(name="createLabelPopup")
form.create-label
@@ -31,8 +34,8 @@ template(name="cardLabelsPopup")
a.card-label-edit-button.fa.fa-pencil.js-edit-label
if isTouchScreenOrShowDesktopDragHandles
span.fa.label-handle(class="fa-arrows" title="{{_ 'dragLabel'}}")
span.card-label.card-label-selectable.js-select-label.card-label-wrapper(class="card-label-{{color}}"
class="{{# if isLabelSelected ../_id }}active{{/if}}")
span.card-label.card-label-selectable.js-select-label.card-label-wrapper(class="{{labelColorClass color}}"
class="{{# if isLabelSelected ../_id }}active{{/if}}" style="{{labelColorStyle color}}")
+viewer
= name
if(isLabelSelected ../_id)
+33 -2
View File
@@ -1,5 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { LABEL_COLORS } from '/models/metadata/colors';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
@@ -28,6 +29,11 @@ Template.formLabel.helpers({
isSelected(color) {
return Template.instance().currentColor.get() === color;
},
// #5514: current color as a '#rrggbb' hex for the color-wheel <input>, which
// only accepts hex. Named colors map to their palette hex; fall back to green.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#3cb500';
},
});
Template.formLabel.events({
@@ -36,6 +42,13 @@ Template.formLabel.events({
const selectedColor = paletteData?.color || Template.currentData()?.color || getFallbackLabelColor();
tpl.currentColor.set(selectedColor);
},
// #5514: picking a color from the native color wheel stores a custom hex.
'input .js-label-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
});
Template.createLabelPopup.helpers({
@@ -131,7 +144,16 @@ Template.createLabelPopup.events({
? selectedColorIcon.closest('.js-palette-color')
: null;
const selectedColorData = selectedPaletteNode && Blaze.getData(selectedPaletteNode);
const color = selectedColorData?.color || getFallbackLabelColor();
// #5514: a named swatch (has the check icon) wins; otherwise fall back to
// the custom color-wheel hex, then to the default palette color.
let color = selectedColorData?.color;
if (!color) {
const wheel = templateInstance.find('.js-label-color-wheel');
if (wheel && isHexColor(wheel.value)) {
color = wheel.value;
}
}
color = color || getFallbackLabelColor();
board.addLabel(name, color);
Popup.back();
},
@@ -158,7 +180,16 @@ Template.editLabelPopup.events({
? selectedColorIcon.closest('.js-palette-color')
: null;
const selectedColorData = selectedPaletteNode && Blaze.getData(selectedPaletteNode);
const color = selectedColorData?.color || getFallbackLabelColor();
// #5514: a named swatch (has the check icon) wins; otherwise fall back to
// the custom color-wheel hex, then to the default palette color.
let color = selectedColorData?.color;
if (!color) {
const wheel = templateInstance.find('.js-label-color-wheel');
if (wheel && isHexColor(wheel.value)) {
color = wheel.value;
}
}
color = color || getFallbackLabelColor();
board.editLabel(this._id, name, color);
Popup.back();
},
+11
View File
@@ -183,6 +183,13 @@
}
.minicard .minicard-title {
margin-inline-end: 1.5vw;
/* #6444: isolate each title's bidirectional context. The title is a
dir="auto" .viewer; under a global dir="rtl" document, non-isolated
dir="auto" runs share bidi state, so typing a strong RTL (e.g. Arabic)
character into another list's dir="auto" composer re-resolved the shared
context and visibly reflowed OTHER lists' minicard titles (no data change,
reverts on refresh). Isolation makes each title a self-contained run. */
unicode-bidi: isolate;
}
.minicard .minicard-title .card-number {
color: #b3b3b3;
@@ -448,6 +455,10 @@
min-height: 50px;
margin-bottom: 20px;
overflow-y: auto;
/* #6444: isolate the composer's dir="auto" bidi run so typing a strong RTL
character here cannot perturb the rendering of the surrounding minicard
titles under a dir="rtl" document. */
unicode-bidi: isolate;
}
.parent-prefix {
color: #b3b3b3;
+4 -3
View File
@@ -3,7 +3,8 @@ template(name="minicard")
class="{{#if isLinkedCard}}linked-card{{/if}}"
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{agingClass}}"
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}"
style="{{colorStyle}}")
if canMoveCard
if isTouchScreenOrShowDesktopDragHandles
.handle
@@ -93,11 +94,11 @@ template(name="minicard")
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels
unless hiddenMinicardLabelText
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
span.js-card-label.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+viewer
= name
if hiddenMinicardLabelText
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title="{{name}}")
.minicard-custom-fields
each customFieldsWD
+7 -2
View File
@@ -6,6 +6,7 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
import Cards from '/models/cards';
import { resolveCoverId } from '/models/lib/linkedCardCover';
import {
parseChecklistItemTitles,
buildChecklistItemPayload,
@@ -168,8 +169,12 @@ Template.minicard.helpers({
}
},
cover() {
if (!this.coverId) return null;
const attachment = ReactiveCache.getAttachment(this.coverId);
// #5666: for a linked card the cover lives on the real card it points at, so
// resolve the cover id through it (mirroring getTitle/getDue/...); a plain
// card resolves to its own coverId.
const coverId = resolveCoverId(this, id => ReactiveCache.getCard(id));
if (!coverId) return null;
const attachment = ReactiveCache.getAttachment(coverId);
if (!attachment) return null;
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
if (!coverLink) return null;
+20 -1
View File
@@ -736,9 +736,28 @@ form.inlined-form .list-composer {
flex: 1 1 auto;
flex-direction: column;
display: flex;
overflow-y: auto;
/* #5439: always show a scrollbar on the list body across desktop and mobile
browsers (overlay scrollbars on macOS/iOS/Android/Firefox otherwise auto-
hide it). Keep these declarations in sync with models/lib/scrollbarCss.js
(alwaysVisibleScrollbarCss), which is unit tested. */
overflow-y: scroll;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.35) rgba(0, 0, 0, 0.08);
scrollbar-gutter: stable;
padding: 5px 11px;
}
.list-body::-webkit-scrollbar {
width: 10px;
height: 10px;
-webkit-appearance: none;
}
.list-body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.08);
}
.list-body::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 5px;
}
.list-body .minicards {
flex-grow: 1;
flex-shrink: 0;
+1 -1
View File
@@ -46,7 +46,7 @@ template(name="addCardForm")
if getLabels
.minicard-labels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title="{{name}}")
textarea.minicard-composer-textarea.js-card-title(autofocus dir="auto")
if members.get
.minicard-members.js-minicard-composer-members
+37 -3
View File
@@ -11,6 +11,8 @@ import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
import { sortCardsByTitle } from '/models/lib/sortCardsByTitle';
import { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import autosize from 'autosize';
// SubsManager removed for Meteor 3 migration
@@ -345,8 +347,31 @@ Template.listBody.helpers({
// bare top-level `$or`, so it never competes with the board Filter's own
// top-level `$or` (labels/members/exceptions) and the label filter applies
// board-wide across every swimlane.
const selector = listCardsSelector(Template.currentData()._id, swimlaneId);
const ret = ReactiveCache.getCards(Filter.mongoSelector(selector), {
// #6443: in the first swimlane also surface orphaned cards (a swimlaneId
// pointing at a deleted swimlane) so they are not invisible in swimlane view.
const list = Template.currentData();
const selector = listCardsSelector(
list._id,
swimlaneId,
list.orphanedCardsSwimlaneIds
? list.orphanedCardsSwimlaneIds(swimlaneId)
: undefined,
);
const mongoSelector = Filter.mongoSelector(selector);
// Lazy card loading: the board publication ships no cards, so subscribe to
// just this list/swimlane window (grows with `limit` as the user scrolls)
// plus its reactive total count. Default mode keeps everything in minimongo
// already, so these subscriptions are skipped.
if (isLazyCards() && list.boardId) {
tpl.subscribe('boardCardsWindow', list.boardId, mongoSelector, sortBy, limit);
tpl.subscribe(
'boardListCardCount',
windowCountId(list._id, swimlaneId),
list.boardId,
mongoSelector,
);
}
const ret = ReactiveCache.getCards(mongoSelector, {
// sort: ['sort'],
sort: sortBy,
limit,
@@ -357,6 +382,13 @@ Template.listBody.helpers({
showSpinner(swimlaneId) {
const tpl = Template.instance();
const list = Template.currentData();
if (isLazyCards()) {
// In lazy mode minimongo only holds the loaded window, so the total comes
// from the server count published into BoardListCardCounts.
const countDoc = BoardListCardCounts.findOne(windowCountId(list._id, swimlaneId));
const total = countDoc ? countDoc.count : 0;
return total > tpl.cardlimit.get();
}
return list.cards(swimlaneId).length > tpl.cardlimit.get();
},
@@ -711,7 +743,9 @@ Template.linkCardPopup.helpers({
if (tpl.selectedListId.get()) selector.listId = tpl.selectedListId.get();
const ret = ReactiveCache.getCards(selector, { sort: { sort: 1 } });
return ret;
// #5394: sort the Cards dropdown alphabetically by title
// (case-insensitive) so a card can be found on boards with many cards.
return sortCardsByTitle(ret);
},
isTitleDefault(title) {
+13 -8
View File
@@ -1,7 +1,8 @@
template(name="listHeader")
.list-header.js-list-header.nodragscroll(
class="{{#if limitToShowCardsCount}}list-header-card-count{{/if}}"
class=colorClass)
class=colorClass
style=colorStyle)
//- #459: keyboard/screen-reader list reordering (visually hidden, focusable).
.list-move-buttons.sr-only
a.js-list-move-left(href="#" role="button" aria-label="{{_ 'move-list-left'}}") {{_ 'move-list-left'}}
@@ -15,7 +16,7 @@ template(name="listHeader")
i.fa.fa-caret-left
else
if collapsed
if showCardsCountForList cards.length
if showCardsCountForList cardsCountWhole
br
span.cardCount {{cardsCount}}
if isMiniScreen
@@ -26,10 +27,10 @@ template(name="listHeader")
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cardsCountWhole}}
|/#{wipLimit.value})
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if showCardsCountForList cardsCountWhole
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cardsCountWhole}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
@@ -47,11 +48,11 @@ template(name="listHeader")
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cardsCountWhole}}
|/#{wipLimit.value})
unless collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if showCardsCountForList cardsCountWhole
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cardsCountWhole}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
@@ -279,6 +280,10 @@ template(name="setListColorPopup")
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
// #5514: native color wheel for picking a custom RGB / hex color
.color-wheel-wrap
label(for="listColorWheel") {{_ 'custom-color'}}
input.js-list-color-wheel#listColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
+39 -19
View File
@@ -5,8 +5,10 @@ import dragscroll from '@wekanteam/dragscroll';
import { BoardSwimlaneListDialog } from '/client/lib/dialogWithBoardSwimlaneList';
import Cards from '/models/cards';
import { LIST_COLORS } from '/models/metadata/colors';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { lazyListCardCount } from '/client/lib/lazyCards';
let listsColors;
Meteor.startup(() => {
@@ -59,24 +61,33 @@ Template.listHeader.helpers({
swimlaneId = list.swimlaneId || '';
}
const ret = list.cards(swimlaneId).length;
return ret;
// In lazy mode minimongo holds only the loaded window, so use the accurate
// server count for the same selector; fall back to the loaded length.
const lazyCount = lazyListCardCount(list, swimlaneId);
return lazyCount !== null ? lazyCount : list.cards(swimlaneId).length;
},
reachedWipLimit() {
const list = Template.currentData();
return (
list.getWipLimit('enabled') &&
list.getWipLimit('value') <= list.cards().length
);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') <= count;
},
exceededWipLimit() {
const list = Template.currentData();
return (
list.getWipLimit('enabled') &&
list.getWipLimit('value') < list.cards().length
);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') < count;
},
// Accurate whole-list card count (used by the badge visibility, WIP-limit
// number and pluralization in the template). In lazy mode `cards.length`
// (= list.cards().length) is only the loaded window, so prefer the server count.
cardsCountWhole() {
const list = Template.currentData();
const lazyCount = lazyListCardCount(list, undefined);
return lazyCount !== null ? lazyCount : list.cards().length;
},
showCardsCountForList(count) {
@@ -141,10 +152,9 @@ Template.listHeader.helpers({
Template.listHeader.onCreated(function () {
this.reachedWipLimit = function () {
const list = Template.currentData();
return (
list.getWipLimit('enabled') &&
list.getWipLimit('value') <= list.cards().length
);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') <= count;
};
});
@@ -305,11 +315,10 @@ Template.setWipLimitPopup.events({
async 'click .js-enable-wip-limit'() {
const list = Template.currentData();
// Prevent user from using previously stored wipLimit.value if it is less than the current number of cards in the list
if (
!list.getWipLimit('enabled') &&
list.getWipLimit('value') < list.cards().length
) {
await list.setWipLimit(list.cards().length);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
if (!list.getWipLimit('enabled') && list.getWipLimit('value') < count) {
await list.setWipLimit(count);
}
Meteor.call('enableWipLimit', list._id);
},
@@ -476,6 +485,10 @@ Template.setListColorPopup.helpers({
return tpl.currentColor.get() === color;
}
},
// #5514: current color as a '#rrggbb' hex for the native color wheel <input>.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#0079bf';
},
});
Template.setListColorPopup.events({
@@ -483,6 +496,13 @@ Template.setListColorPopup.events({
const paletteData = Blaze.getData(event.currentTarget);
tpl.currentColor.set(paletteData?.color);
},
// #5514: picking from the native color wheel stores a custom hex.
'input .js-list-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
async 'submit form'(event, tpl) {
event.preventDefault();
try {
+35 -2
View File
@@ -383,10 +383,29 @@ Blaze.Template.registerHelper(
'mentions',
new Template('mentions', function() {
const view = this;
// Admin Panel / Features / Security: read the setting FIRST (a reactive dependency
// of this viewer) and push the "always show all code as plain text" flag into the
// wekan-markdown package BEFORE rendering the inner markdown below. Doing it here —
// not only in the separate startup autorun — removes a re-render race: this viewer
// already re-renders whenever the setting doc changes (it reads it for stripLinks),
// and the inner markdown helper then reads the up-to-date flag in the SAME
// synchronous render, so toggling "always show all code as plain text" takes effect
// immediately. Without this the viewer could re-render (setting changed) BEFORE the
// startup autorun updated the flag, read the stale value, and never re-render again
// since it does not itself depend on that ReactiveVar.
const setting = ReactiveCache.getCurrentSetting();
if (typeof Markdown !== 'undefined' && Markdown.alwaysShowCodeAsText) {
Markdown.alwaysShowCodeAsText.set(!!(setting && setting.alwaysShowCodeAsText));
}
let content = Blaze.toHTML(view.templateContentBlock);
// Admin Panel / Features: when "render links as plain text" is enabled, every
// link (markdown [label](url) and raw HTML <a href>) is stripped to plain,
// non-clickable text everywhere rich text is shown. Reactive: toggling the
// setting re-renders viewers.
const stripLinks = !!(setting && setting.renderLinksAsPlainText);
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard)
return HTML.Raw(sanitizeHTML(content));
return HTML.Raw(sanitizeHTML(content, { stripLinks }));
const knowedUsers = [...new Set([...currentBoard.members
.filter(member => member.isActive)
.map(member => {
@@ -440,7 +459,7 @@ Blaze.Template.registerHelper(
content = content.replace(fullMention, Blaze.toHTML(link));
}
return HTML.Raw(sanitizeHTML(content));
return HTML.Raw(sanitizeHTML(content, { stripLinks }));
}),
);
@@ -473,3 +492,17 @@ Template.viewer.events({
}
},
});
// Admin Panel / Features / Security: keep the wekan-markdown package's
// "show all code as plain text" flag in sync with the alwaysShowCodeAsText setting.
// The package cannot import app code, so we push the setting into the reactive flag
// it exposes on the exported Markdown object; the markdown helper reads it reactively
// and shows the raw source (escaped, non-clickable, non-running) when enabled.
Meteor.startup(() => {
Tracker.autorun(() => {
const setting = ReactiveCache.getCurrentSetting();
if (typeof Markdown !== 'undefined' && Markdown.alwaysShowCodeAsText) {
Markdown.alwaysShowCodeAsText.set(!!(setting && setting.alwaysShowCodeAsText));
}
});
});
+9
View File
@@ -83,14 +83,23 @@ Template.userFormsLayout.onRendered(() => {
})();
});
// #4394 (security): a one-shot $('.at-signup-link').hide() does not survive
// the useraccounts form re-rendering that happens after a failed login (e.g.
// an LDAP "User not Found") — the links were recreated without the inline
// display:none and reappeared even though registration / forgot-password
// were disabled in the Admin Panel. Toggle a class on the stable <body>
// ancestor and hide the links via CSS (userForm.css) so the rule keeps
// applying to every freshly re-rendered copy of the links.
Meteor.call('isDisableRegistration', (_, result) => {
if (result) {
document.body.classList.add('registration-disabled');
$('.at-signup-link').hide();
}
});
Meteor.call('isDisableForgotPassword', (_, result) => {
if (result) {
document.body.classList.add('forgotpwd-disabled');
$('.at-pwd-link').hide();
}
});
+1 -1
View File
@@ -94,7 +94,7 @@ template(name="myCards")
td
.div
each label in card.labelIds
span.card-label(class="card-label-{{labelColor board label}}" title=name)
span.card-label(class="{{labelColorClass (labelColor board label)}}" style="{{labelColorStyle (labelColor board label)}}" title=name)
| {{labelName board label}}
td
if card.dueAt
+17 -40
View File
@@ -106,38 +106,19 @@ Template.boardActions.events({
const boardId = Session.get('currentBoard');
const destBoardId = tpl.find('#board-id').value;
const desc = Utils.getTriggerActionDesc(event, tpl);
if (actionSelected === 'top') {
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveCardToTop',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
}
if (actionSelected === 'bottom') {
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveCardToBottom',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
}
// #5536: create the rule on the server (see server/rulesButton.js →
// rules.createRule) instead of three optimistic client Collection.insert()
// calls. The optimistic writes landed in minimongo limbo once the wizard
// subscription stopped, so the cross-board move action rendered BLANK; and
// the client allow/deny rules rejected them outright for non-owner members.
// The action keeps its own destination `boardId` (the board to move to).
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType: actionSelected === 'bottom' ? 'moveCardToBottom' : 'moveCardToTop',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
},
'click .js-add-gen-move-action'(event, tpl) {
const desc = Utils.getTriggerActionDesc(event, tpl);
@@ -203,20 +184,16 @@ Template.boardActions.events({
const boardId = Session.get('currentBoard');
const destBoardId = tpl.find('#board-id-link').value;
const desc = Utils.getTriggerActionDesc(event, tpl);
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
// #5536: create on the server so the cross-board link action does not land
// blank in minimongo limbo / get rejected by client allow/deny. The action
// keeps its own destination `boardId` (the board to link the card into).
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType: 'linkCard',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
},
'click .js-sort-list-action'(event, tpl) {
const data = Template.currentData();
@@ -100,8 +100,15 @@ template(name="cardActions")
option(value="receivedAt") {{_'r-df-received-at'}}
div.trigger-dropdown
input(id="reldate-days",type="number",value="0")
div.trigger-dropdown
select(id="reldate-unit")
option(value="minutes") {{_'r-unit-minutes'}}
option(value="hours") {{_'r-unit-hours'}}
option(value="days",selected="selected") {{_'r-unit-days'}}
option(value="weeks") {{_'r-unit-weeks'}}
option(value="months") {{_'r-unit-months'}}
div.trigger-text
| {{_'r-days-after'}}
| {{_'r-later'}}
div.trigger-button.js-set-reldate-action.js-goto-rules
i.fa.fa-plus
@@ -238,12 +238,16 @@ Template.cardActions.events({
const boardId = Session.get('currentBoard');
const desc = Utils.getTriggerActionDesc(event, tpl);
const dateField = tpl.find('#reldate-datefield').value;
// `days` keeps its name for backward compatibility: it is the numeric
// amount, interpreted together with `unit` (minutes/hours/days/weeks/months).
const days = parseInt(tpl.find('#reldate-days').value, 10) || 0;
const unit = tpl.find('#reldate-unit').value;
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'setDateRelative',
dateField,
days,
unit,
boardId,
desc,
});
+4 -5
View File
@@ -1,7 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
function boardRuleIds() {
const boardId = Session.get('currentBoard');
@@ -62,9 +60,10 @@ Template.rulesList.events({
getSelected().forEach(ruleId => {
const rule = ReactiveCache.getRule(ruleId);
if (rule) {
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
// Delete the rule + its trigger + action server-side (see
// server/rulesButton.js `rules.deleteRule`): three client-side
// Collection.remove() calls were rejected with 403 "Access denied".
Meteor.call('rules.deleteRule', rule._id);
}
});
setSelected([]);
+6 -6
View File
@@ -1,9 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Utils } from '/client/lib/utils';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
Template.rulesMain.onCreated(function () {
this.rulesCurrentTab = new ReactiveVar('rulesList');
@@ -83,9 +80,12 @@ function sanitizeObject(obj) {
Template.rulesMain.events({
'click .js-delete-rule'() {
const rule = Template.currentData();
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
// Delete the rule + its trigger + action in one server call. Doing this
// client-side as three separate Collection.remove() calls failed with 403
// "Access denied" whenever a trigger/action document's boardId did not
// resolve to a board in the allow() rule; the method authorizes once and
// removes all three server-side.
Meteor.call('rules.deleteRule', rule._id);
},
'click .js-goto-trigger'(event, tpl) {
event.preventDefault();
+2 -2
View File
@@ -12,7 +12,7 @@ template(name="rulesWorkflow")
.workflow-node.workflow-node-when
span.workflow-node-label {{_ 'r-when'}}
span.workflow-node-text= builderTriggerLabel
a.workflow-slot-clear.js-clear-when(title="{{_ 'remove'}}") &#10005;
a.workflow-slot-clear.js-clear-when(title="{{_ 'r-remove'}}") &#10005;
else
span.workflow-slot-placeholder {{_ 'r-when'}}: {{_ 'r-drop-trigger'}}
.workflow-arrow
@@ -22,7 +22,7 @@ template(name="rulesWorkflow")
.workflow-node.workflow-node-then
span.workflow-node-label {{_ 'r-action'}}
span.workflow-node-text= builderActionLabel
a.workflow-slot-clear.js-clear-then(title="{{_ 'remove'}}") &#10005;
a.workflow-slot-clear.js-clear-then(title="{{_ 'r-remove'}}") &#10005;
else
span.workflow-slot-placeholder {{_ 'r-action'}}: {{_ 'r-drop-action'}}
.workflow-builder-controls
+46 -32
View File
@@ -12,31 +12,44 @@ import Triggers from '/models/triggers';
// can be built entirely by dragging. Parameterized rules (specific list, label,
// member, schedule time, …) are still created with the form builder on the
// List view.
// Each palette entry carries an i18n `labelKey` (translated at render time via
// TAPi18n.__) instead of a hardcoded English string, so the whole workflow view
// follows the UI language. `labelParams` is passed to TAPi18n.__ for labels that
// need a placeholder (e.g. the scheduled time). Existing rule keys are reused
// where they fit; workflow-only labels use new `r-w-*` keys (see en.i18n.json).
const TRIGGER_PALETTE = [
{ label: 'Card is created', doc: { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ label: 'Card is moved', doc: { activityType: 'moveCard', listName: '*', oldListName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ label: 'Card is archived', doc: { activityType: 'archivedCard', userId: '*' } },
{ label: 'Card is unarchived', doc: { activityType: 'restoredCard', userId: '*' } },
{ label: 'Any label is added', doc: { activityType: 'addedLabel', labelId: '*', userId: '*' } },
{ label: 'Any label is removed', doc: { activityType: 'removedLabel', labelId: '*', userId: '*' } },
{ label: 'A member is added', doc: { activityType: 'joinMember', username: '*', userId: '*' } },
{ label: 'A member is removed', doc: { activityType: 'unjoinMember', username: '*', userId: '*' } },
{ label: 'A checklist is added', doc: { activityType: 'addChecklist', checklistName: '*', userId: '*' } },
{ label: 'An attachment is added', doc: { activityType: 'addAttachment', userId: '*' } },
{ label: 'Every day at 09:00', doc: { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' } },
{ labelKey: 'r-w-card-created', doc: { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ labelKey: 'r-when-a-card-is-moved', doc: { activityType: 'moveCard', listName: '*', oldListName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ labelKey: 'r-w-card-archived', doc: { activityType: 'archivedCard', userId: '*' } },
{ labelKey: 'r-w-card-unarchived', doc: { activityType: 'restoredCard', userId: '*' } },
{ labelKey: 'r-w-label-added', doc: { activityType: 'addedLabel', labelId: '*', userId: '*' } },
{ labelKey: 'r-w-label-removed', doc: { activityType: 'removedLabel', labelId: '*', userId: '*' } },
{ labelKey: 'r-w-member-added', doc: { activityType: 'joinMember', username: '*', userId: '*' } },
{ labelKey: 'r-w-member-removed', doc: { activityType: 'unjoinMember', username: '*', userId: '*' } },
{ labelKey: 'r-w-checklist-added', doc: { activityType: 'addChecklist', checklistName: '*', userId: '*' } },
{ labelKey: 'r-w-attachment-added', doc: { activityType: 'addAttachment', userId: '*' } },
{ labelKey: 'r-w-every-day-at', labelParams: { time: '09:00' }, doc: { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' } },
];
const ACTION_PALETTE = [
{ label: 'Move card to top', doc: { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' } },
{ label: 'Move card to bottom', doc: { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' } },
{ label: 'Archive card', doc: { actionType: 'archive' } },
{ label: 'Unarchive card', doc: { actionType: 'unarchive' } },
{ label: 'Mark card complete', doc: { actionType: 'markCardComplete' } },
{ label: 'Mark card incomplete', doc: { actionType: 'markCardIncomplete' } },
{ label: 'Remove all members', doc: { actionType: 'removeMember', username: '*' } },
{ label: 'Set received date to now', doc: { actionType: 'setDate', dateField: 'receivedAt' } },
{ labelKey: 'r-d-move-to-top-gen', doc: { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' } },
{ labelKey: 'r-d-move-to-bottom-gen', doc: { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' } },
{ labelKey: 'r-d-archive', doc: { actionType: 'archive' } },
{ labelKey: 'r-d-unarchive', doc: { actionType: 'unarchive' } },
{ labelKey: 'r-mark-complete', doc: { actionType: 'markCardComplete' } },
{ labelKey: 'r-mark-incomplete', doc: { actionType: 'markCardIncomplete' } },
{ labelKey: 'r-remove-all', doc: { actionType: 'removeMember', username: '*' } },
{ labelKey: 'r-w-set-received-now', doc: { actionType: 'setDate', dateField: 'receivedAt' } },
];
// Translate a palette entry's label for display / storage in the current UI
// language (matches the classic Rules view, which also stores already-translated
// description text at creation time).
function paletteLabel(entry) {
if (!entry) return '';
return TAPi18n.__(entry.labelKey, entry.labelParams);
}
Template.rulesWorkflow.onCreated(function () {
this.builderTrigger = new ReactiveVar(null);
this.builderAction = new ReactiveVar(null);
@@ -52,24 +65,22 @@ Template.rulesWorkflow.helpers({
return Utils.getCurrentBoard();
},
triggerPalette() {
return TRIGGER_PALETTE.map((t, idx) => ({ idx, label: t.label }));
return TRIGGER_PALETTE.map((t, idx) => ({ idx, label: paletteLabel(t) }));
},
actionPalette() {
return ACTION_PALETTE.map((a, idx) => ({ idx, label: a.label }));
return ACTION_PALETTE.map((a, idx) => ({ idx, label: paletteLabel(a) }));
},
builderTrigger() {
return Template.instance().builderTrigger.get();
},
builderTriggerLabel() {
const t = Template.instance().builderTrigger.get();
return t ? t.label : '';
return paletteLabel(Template.instance().builderTrigger.get());
},
builderAction() {
return Template.instance().builderAction.get();
},
builderActionLabel() {
const a = Template.instance().builderAction.get();
return a ? a.label : '';
return paletteLabel(Template.instance().builderAction.get());
},
createDisabled() {
const tpl = Template.instance();
@@ -96,9 +107,11 @@ function persistRule(tpl) {
if (!t || !a) return;
const boardId = Session.get('currentBoard');
const titleField = tpl.find('.js-workflow-rule-title');
const title = (titleField.value || '').trim() || `${t.label} → ${a.label}`;
const triggerId = Triggers.insert({ ...t.doc, boardId, desc: t.label });
const actionId = Actions.insert({ ...a.doc, boardId, desc: a.label });
const tLabel = paletteLabel(t);
const aLabel = paletteLabel(a);
const title = (titleField.value || '').trim() || `${tLabel} → ${aLabel}`;
const triggerId = Triggers.insert({ ...t.doc, boardId, desc: tLabel });
const actionId = Actions.insert({ ...a.doc, boardId, desc: aLabel });
Rules.insert({ title, triggerId, actionId, boardId });
tpl.builderTrigger.set(null);
tpl.builderAction.set(null);
@@ -140,7 +153,7 @@ Template.rulesWorkflow.events({
const a = ACTION_PALETTE[tpl.dragItem.idx];
const boardId = Session.get('currentBoard');
const oldActionId = rule.actionId;
const newActionId = Actions.insert({ ...a.doc, boardId, desc: a.label });
const newActionId = Actions.insert({ ...a.doc, boardId, desc: paletteLabel(a) });
Rules.update(ruleId, { $set: { actionId: newActionId } });
if (oldActionId) Actions.remove(oldActionId);
tpl.dragItem = null;
@@ -163,8 +176,9 @@ Template.rulesWorkflow.events({
const ruleId = node && node.dataset.ruleId;
const rule = ReactiveCache.getRule(ruleId);
if (!rule) return;
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
// Delete the rule + its trigger + action server-side (see
// server/rulesButton.js `rules.deleteRule`): three client-side
// Collection.remove() calls were rejected with 403 "Access denied".
Meteor.call('rules.deleteRule', rule._id);
},
});
@@ -0,0 +1,101 @@
template(name="adminFeatures")
.setting-content
unless currentUser.isAdmin
| {{_ 'error-notAuthorized'}}
else
.content-title.ext-box
span
span.emoji-icon
i.fa.fa-flask
| {{_ 'features'}}
.content-body
.side-menu
ul
li(class="{{#if isPerformanceActive}}active{{/if}}")
a.js-features-menu(data-id="performance")
span.emoji-icon
i.fa.fa-tachometer
| {{_ 'features-performance'}}
li(class="{{#if isSecurityActive}}active{{/if}}")
a.js-features-menu(data-id="security")
span.emoji-icon
i.fa.fa-shield
| {{_ 'features-security'}}
li(class="{{#if isNotificationsActive}}active{{/if}}")
a.js-features-menu(data-id="notifications")
span.emoji-icon
i.fa.fa-bell
| {{_ 'features-notifications'}}
.main-body
if isPerformanceActive
ul.setting-detail
li.layout-form
.title {{_ 'cards-loading'}}
.form-group
select#cards-loading.js-cards-loading
each cardsLoadingOptions
if this.selected
option(value="{{value}}" selected) {{_ label}}
else
option(value="{{value}}") {{_ label}}
p.description {{_ 'cards-loading-description'}}
p.description {{_ 'cards-loading-lazy-note'}}
if isSecurityActive
ul.setting-detail
li
a.flex.js-toggle-render-links-as-plain-text
.materialCheckBox(class="{{#if renderLinksAsPlainText}}is-checked{{/if}}")
span {{_ 'render-links-as-plain-text'}}
p.description {{_ 'render-links-as-plain-text-description'}}
li
a.flex.js-toggle-always-show-code-as-text
.materialCheckBox(class="{{#if alwaysShowCodeAsText}}is-checked{{/if}}")
span {{_ 'always-show-code-as-text'}}
p.description {{_ 'always-show-code-as-text-description'}}
li
a.flex.js-toggle-disable-all-import
.materialCheckBox(class="{{#if disableAllImport}}is-checked{{/if}}")
span {{_ 'disable-all-import'}}
p.description {{_ 'disable-all-import-description'}}
li
a.flex.js-toggle-disable-all-export
.materialCheckBox(class="{{#if disableAllExport}}is-checked{{/if}}")
span {{_ 'disable-all-export'}}
p.description {{_ 'disable-all-export-description'}}
li
a.flex.js-toggle-disable-import-avatars
.materialCheckBox(class="{{#if disableImportAvatars}}is-checked{{/if}}")
span {{_ 'disable-import-avatars'}}
p.description {{_ 'disable-import-avatars-description'}}
li
a.flex.js-toggle-disable-export-avatars
.materialCheckBox(class="{{#if disableExportAvatars}}is-checked{{/if}}")
span {{_ 'disable-export-avatars'}}
p.description {{_ 'disable-export-avatars-description'}}
li
a.flex.js-toggle-anonymize-import-users
.materialCheckBox(class="{{#if anonymizeImportUsers}}is-checked{{/if}}")
span {{_ 'anonymize-import-users'}}
p.description {{_ 'anonymize-import-users-description'}}
li
a.flex.js-toggle-anonymize-export-users
.materialCheckBox(class="{{#if anonymizeExportUsers}}is-checked{{/if}}")
span {{_ 'anonymize-export-users'}}
p.description {{_ 'anonymize-export-users-description'}}
if isNotificationsActive
ul.setting-detail
li
a.flex.js-toggle-disable-activities
.materialCheckBox(class="{{#if disableActivities}}is-checked{{/if}}")
span {{_ 'disable-activities'}}
p.description {{_ 'disable-activities-description'}}
li
a.flex.js-toggle-disable-notifications
.materialCheckBox(class="{{#if disableNotifications}}is-checked{{/if}}")
span {{_ 'disable-notifications'}}
p.description {{_ 'disable-notifications-description'}}
li
a.flex.js-toggle-disable-watch
.materialCheckBox(class="{{#if disableWatch}}is-checked{{/if}}")
span {{_ 'disable-watch'}}
p.description {{_ 'disable-watch-description'}}
+118
View File
@@ -0,0 +1,118 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import Settings from '/models/settings';
// Admin Panel / Features — a home for optional / performance / tier-gated
// capabilities. First entry: "Card loading" (all | lazy), which also mirrors the
// CARDS_LOADING env var. Grouped by category in the side menu so more feature
// toggles (and, later, pricing-tier gating) can be added here.
Template.adminFeatures.onCreated(function () {
this.activePane = new ReactiveVar('performance');
Meteor.subscribe('setting');
});
Template.adminFeatures.helpers({
isPerformanceActive() {
return Template.instance().activePane.get() === 'performance';
},
isSecurityActive() {
return Template.instance().activePane.get() === 'security';
},
isNotificationsActive() {
return Template.instance().activePane.get() === 'notifications';
},
cardsLoadingOptions() {
const current = (ReactiveCache.getCurrentSetting() || {}).cardsLoading || 'all';
return [
{ value: 'all', label: 'cards-loading-all', selected: current === 'all' },
{ value: 'lazy', label: 'cards-loading-lazy', selected: current === 'lazy' },
];
},
renderLinksAsPlainText() {
return (ReactiveCache.getCurrentSetting() || {}).renderLinksAsPlainText;
},
alwaysShowCodeAsText() {
return (ReactiveCache.getCurrentSetting() || {}).alwaysShowCodeAsText;
},
disableAllImport() {
return (ReactiveCache.getCurrentSetting() || {}).disableAllImport;
},
disableAllExport() {
return (ReactiveCache.getCurrentSetting() || {}).disableAllExport;
},
disableImportAvatars() {
return (ReactiveCache.getCurrentSetting() || {}).disableImportAvatars;
},
disableExportAvatars() {
return (ReactiveCache.getCurrentSetting() || {}).disableExportAvatars;
},
anonymizeImportUsers() {
return (ReactiveCache.getCurrentSetting() || {}).anonymizeImportUsers;
},
anonymizeExportUsers() {
return (ReactiveCache.getCurrentSetting() || {}).anonymizeExportUsers;
},
disableActivities() {
return (ReactiveCache.getCurrentSetting() || {}).disableActivities;
},
disableNotifications() {
return (ReactiveCache.getCurrentSetting() || {}).disableNotifications;
},
disableWatch() {
return (ReactiveCache.getCurrentSetting() || {}).disableWatch;
},
});
// Toggle one boolean setting field, saving immediately.
function toggleSettingField(field) {
const setting = ReactiveCache.getCurrentSetting();
if (setting) {
Settings.update(setting._id, { $set: { [field]: !setting[field] } });
}
}
Template.adminFeatures.events({
'click .js-features-menu'(event, tpl) {
tpl.activePane.set(event.currentTarget.dataset.id);
},
'change .js-cards-loading'(event) {
const value = event.currentTarget.value === 'lazy' ? 'lazy' : 'all';
const setting = ReactiveCache.getCurrentSetting();
if (setting) {
Settings.update(setting._id, { $set: { cardsLoading: value } });
}
},
'click .js-toggle-render-links-as-plain-text'() {
toggleSettingField('renderLinksAsPlainText');
},
'click .js-toggle-always-show-code-as-text'() {
toggleSettingField('alwaysShowCodeAsText');
},
'click .js-toggle-disable-all-import'() {
toggleSettingField('disableAllImport');
},
'click .js-toggle-disable-all-export'() {
toggleSettingField('disableAllExport');
},
'click .js-toggle-disable-import-avatars'() {
toggleSettingField('disableImportAvatars');
},
'click .js-toggle-disable-export-avatars'() {
toggleSettingField('disableExportAvatars');
},
'click .js-toggle-anonymize-import-users'() {
toggleSettingField('anonymizeImportUsers');
},
'click .js-toggle-anonymize-export-users'() {
toggleSettingField('anonymizeExportUsers');
},
'click .js-toggle-disable-activities'() {
toggleSettingField('disableActivities');
},
'click .js-toggle-disable-notifications'() {
toggleSettingField('disableNotifications');
},
'click .js-toggle-disable-watch'() {
toggleSettingField('disableWatch');
},
});
@@ -31,6 +31,11 @@ template(name="adminReports")
i.fa.fa-id-card-o
| {{_ 'cardsReportTitle'}}
li
a.js-report-impersonation(data-id="report-impersonation")
i.fa.fa-user-secret
| {{_ 'impersonationReportTitle'}}
.main-body
if loading.get
+spinner
@@ -44,6 +49,8 @@ template(name="adminReports")
+boardsReport(currentPage=boardsCurrentPage totalPages=boardsTotalPages hasPrevPage=hasBoardsPrevPage hasNextPage=hasBoardsNextPage)
else if showCardsReport.get
+cardsReport(currentPage=cardsCurrentPage totalPages=cardsTotalPages hasPrevPage=hasCardsPrevPage hasNextPage=hasCardsNextPage)
else if showImpersonationReport.get
+impersonationReport(currentPage=impersonationCurrentPage totalPages=impersonationTotalPages hasPrevPage=hasImpersonationPrevPage hasNextPage=hasImpersonationNextPage)
template(name="brokenCardsReport")
@@ -158,6 +165,43 @@ template(name="cardsReport")
else
div {{_ 'no-results' }}
template(name="impersonationReport")
h1 {{_ 'impersonationReportTitle'}}
.admin-report-search
input.js-impersonation-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-impersonation-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-impersonation-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-impersonation-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
tr
th {{_ 'date'}}
th {{_ 'impersonation-admin'}}
th {{_ 'impersonation-user'}}
th {{_ 'board'}}
th {{_ 'reason'}}
each row in results
tbody
tr
td {{ formatDate row.createdAt }}
td
if row.adminId
a.edit-user.js-impersonation-edit-user(data-user-id="{{ row.adminId }}") {{ userName row.adminId }}
td
if row.userId
a.edit-user.js-impersonation-edit-user(data-user-id="{{ row.userId }}") {{ userName row.userId }}
td {{ row.boardId }}
td {{ row.reason }}
else
div {{_ 'no-results' }}
template(name="boardsReport")
h1 {{_ 'boardsReportTitle'}}
.admin-report-search
@@ -6,6 +6,7 @@ import SessionData from '/models/usersessiondata';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Rules from '/models/rules';
import ImpersonatedUsers from '/models/impersonatedUsers';
const { filesize } = require('filesize');
// --- Shared helper functions (formerly AdminReport base class methods) ---
@@ -60,6 +61,7 @@ function reportConfig(tmpl) {
'report-rules': { page: tmpl.rulesPage, count: tmpl.rulesCount, search: tmpl.rulesSearch, pub: 'rulesReport', countMethod: 'getRulesReportCount' },
'report-boards': { page: tmpl.boardsPage, count: tmpl.boardsCount, search: tmpl.boardsSearch, pub: 'boardsReport', countMethod: 'getBoardsReportCount' },
'report-cards': { page: tmpl.cardsPage, count: tmpl.cardsCount, search: tmpl.cardsSearch, pub: 'cardsReport', countMethod: 'getCardsReportCount' },
'report-impersonation': { page: tmpl.impersonationPage, count: tmpl.impersonationCount, search: tmpl.impersonationSearch, pub: 'impersonationReport', countMethod: 'getImpersonationReportCount' },
};
}
@@ -71,6 +73,7 @@ Template.adminReports.onCreated(function () {
this.showRulesReport = new ReactiveVar(false);
this.showCardsReport = new ReactiveVar(false);
this.showBoardsReport = new ReactiveVar(false);
this.showImpersonationReport = new ReactiveVar(false);
this.sessionId = SessionData.getSessionId();
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
@@ -81,15 +84,18 @@ Template.adminReports.onCreated(function () {
this.rulesPage = new ReactiveVar(1);
this.boardsPage = new ReactiveVar(1);
this.cardsPage = new ReactiveVar(1);
this.impersonationPage = new ReactiveVar(1);
this.filesCount = new ReactiveVar(0);
this.rulesCount = new ReactiveVar(0);
this.boardsCount = new ReactiveVar(0);
this.cardsCount = new ReactiveVar(0);
this.impersonationCount = new ReactiveVar(0);
// Current search term per report (empty string = no filter).
this.filesSearch = new ReactiveVar('');
this.rulesSearch = new ReactiveVar('');
this.boardsSearch = new ReactiveVar('');
this.cardsSearch = new ReactiveVar('');
this.impersonationSearch = new ReactiveVar('');
// (Re)subscribe the given report for its current page and refresh its total
// count. Server-side search + limit/skip means only the matching page ever
@@ -149,6 +155,9 @@ Template.adminReports.helpers({
showBoardsReport() {
return Template.instance().showBoardsReport;
},
showImpersonationReport() {
return Template.instance().showImpersonationReport;
},
loading() {
return Template.instance().loading;
},
@@ -185,6 +194,14 @@ Template.adminReports.helpers({
const tpl = Template.instance();
return tpl.cardsPage.get() < Math.max(1, Math.ceil((tpl.cardsCount.get() || 0) / REPORTS_PER_PAGE));
},
impersonationCurrentPage() { return Template.instance().impersonationPage.get(); },
impersonationTotalPages() { return Math.max(1, Math.ceil((Template.instance().impersonationCount.get() || 0) / REPORTS_PER_PAGE)); },
hasImpersonationPrevPage() { return Template.instance().impersonationPage.get() > 1; },
hasImpersonationNextPage() {
const tpl = Template.instance();
return tpl.impersonationPage.get() < Math.max(1, Math.ceil((tpl.impersonationCount.get() || 0) / REPORTS_PER_PAGE));
},
});
Template.adminReports.events({
@@ -203,6 +220,9 @@ Template.adminReports.events({
'click a.js-report-boards'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-impersonation'(event) {
switchMenu(event, Template.instance());
},
// --- Pagination controls (buttons live inside the report sub-templates,
// their clicks bubble up to this parent template) ---
@@ -214,6 +234,8 @@ Template.adminReports.events({
'click .js-boards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-boards'); },
'click .js-cards-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-cards'); },
'click .js-cards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-cards'); },
'click .js-impersonation-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-impersonation'); },
'click .js-impersonation-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-impersonation'); },
// --- Search (one input + button per report, mirrors the People panel) ---
'click .js-files-search-button'(event, tmpl) { runSearch(tmpl, 'report-files', '.js-files-search-input'); },
@@ -224,6 +246,8 @@ Template.adminReports.events({
'keydown .js-boards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
'click .js-cards-search-button'(event, tmpl) { runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
'keydown .js-cards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
'click .js-impersonation-search-button'(event, tmpl) { runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
'keydown .js-impersonation-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
});
function goPrevPage(event, tmpl, reportId) {
@@ -267,6 +291,7 @@ function switchMenu(event, tmpl) {
tmpl.showRulesReport.set(false);
tmpl.showBoardsReport.set(false);
tmpl.showCardsReport.set(false);
tmpl.showImpersonationReport.set(false);
if (tmpl.subscription) {
tmpl.subscription.stop();
}
@@ -305,6 +330,11 @@ function switchMenu(event, tmpl) {
tmpl.cardsPage.set(1);
tmpl.cardsSearch.set('');
tmpl.loadReport('report-cards');
} else if ('report-impersonation' === targetID) {
tmpl.showImpersonationReport.set(true);
tmpl.impersonationPage.set(1);
tmpl.impersonationSearch.set('');
tmpl.loadReport('report-impersonation');
}
}
}
@@ -418,6 +448,36 @@ Template.cardsReport.helpers({
},
});
// --- impersonationReport template ---
Template.impersonationReport.helpers({
results() {
// The publication already paginates + sorts newest-first; mirror that sort.
return collectionResults(ImpersonatedUsers, { createdAt: -1 });
},
resultsCount() {
return collectionResultsCount(ImpersonatedUsers);
},
userName(userId) {
if (!userId) return '';
return ReactiveCache.getUser(userId)?.username || userId;
},
formatDate(date) {
return date ? new Date(date).toLocaleString() : '';
},
});
Template.impersonationReport.events({
// Clicking a username opens the same "Edit user" popup as Admin Panel / People.
'click .js-impersonation-edit-user'(event) {
event.preventDefault();
const userId = event.currentTarget.getAttribute('data-user-id');
if (userId) {
Popup.open('editUser').call({ userId }, event);
}
},
});
// --- brokenCardsReport template ---
Template.brokenCardsReport.onCreated(function () {
+154
View File
@@ -51,6 +51,22 @@ template(name="attachments")
span.emoji-icon
i.fa.fa-cloud
| {{_ 'gcs-storage'}}
li(class="{{#if isDatabaseMigrationActive}}active{{/if}}")
a.js-attachments-menu(data-id="database-migration")
span.emoji-icon
i.fa.fa-exchange
| {{_ 'database-migration'}}
li(class="{{#if isBackupActive}}active{{/if}}")
a.js-attachments-menu(data-id="backup")
span.emoji-icon
i.fa.fa-archive
| {{_ 'backup'}}
if isSandstorm
li(class="{{#if isSandstormActive}}active{{/if}}")
a.js-attachments-menu(data-id="sandstorm")
span.emoji-icon
i.fa.fa-hdd-o
| Sandstorm
.main-body
if loading.get
@@ -475,6 +491,144 @@ template(name="attachments")
if cloudTestError.gcs
p.form-text.text-danger {{_ 'cloud-connection-failed'}}: {{cloudTestError.gcs}}
else if isDatabaseMigrationActive
ul#attachment-database-migration-setting.setting-detail
li
h3 {{_ 'database-migration'}}
.description {{_ 'database-migration-description'}}
.form-group
button.js-migrate-to-ferretdb.btn.btn-primary(type="button" disabled=migrateRunning) {{_ 'database-migrate-to-ferretdb'}}
button.js-migrate-to-mongodb.btn.btn-primary(type="button" disabled=migrateRunning) {{_ 'database-migrate-to-mongodb'}}
if migrateStatus
.move-progress-section
.move-progress-counter
| {{_ 'database-migration-phase'}}: {{migrateStatus.phase}}
if migrateStatus.collection
| — {{migrateStatus.collection}} ({{migrateStatus.collDone}}/{{migrateStatus.collTotal}}), {{migrateStatus.collectionsDone}}/{{migrateStatus.collectionsTotal}}
if migrateStatus.error
p.form-text.text-danger {{migrateStatus.error}}
if migrateSuccess
p.form-text.text-success {{_ 'database-migration-done'}}
else if isBackupActive
ul#attachment-backup-setting.setting-detail
li
h3 {{_ 'backup'}}
.description {{_ 'backup-description'}}
.form-group
label
input.js-backup-attachments(type="checkbox" checked="checked")
| {{_ 'attachments'}}
label
input.js-backup-avatars(type="checkbox" checked="checked")
| {{_ 'avatars'}}
label
input.js-backup-data(type="checkbox" checked="checked")
| {{_ 'backup-data'}}
.form-group
label {{_ 'backup-storage'}}
select.wekan-form-control.js-backup-storage
option(value="filesystem") {{_ 'filesystem-storage'}}
option(value="s3") {{_ 's3-minio-storage'}}
option(value="azure") {{_ 'azure-blob-storage'}}
option(value="gcs") {{_ 'gcs-storage'}}
.form-group
button.js-run-backup.btn.btn-primary(type="button" disabled=backupRunning) {{_ 'backup-now'}}
if backupStatus
p.form-text {{_ 'database-migration-phase'}}: {{backupStatus.phase}} {{backupStatus.detail}}
if backupStatus.error
p.form-text.text-danger {{backupStatus.error}}
if backupSuccess
p.form-text.text-success {{_ 'backup-done'}}: {{backupStatus.file}}
h4 {{_ 'backup-schedule'}}
.form-group
label {{_ 'backup-frequency'}}
select.wekan-form-control.js-backup-frequency
option(value="off" selected=scheduleOff) {{_ 'backup-frequency-off'}}
option(value="daily" selected=scheduleDaily) {{_ 'backup-frequency-daily'}}
option(value="weekly" selected=scheduleWeekly) {{_ 'backup-frequency-weekly'}}
option(value="monthly" selected=scheduleMonthly) {{_ 'backup-frequency-monthly'}}
.form-group
label {{_ 'backup-time'}}
input.wekan-form-control.js-backup-time(type="time" value=scheduleTime)
.form-group
label {{_ 'backup-day-of-week'}}
input.wekan-form-control.js-backup-dow(type="text" value=scheduleDow placeholder="Sunday")
.form-group
label {{_ 'backup-day-of-month'}}
input.wekan-form-control.js-backup-dom(type="number" min="1" max="28" value=scheduleDom)
.form-group
button.js-save-backup-schedule.btn.btn-primary(type="button") {{_ 'save'}}
h4 {{_ 'backup-restore'}}
.form-group
button.js-list-backups.btn.btn-secondary(type="button") {{_ 'backup-list'}}
if backupList
table.gridfs-stats-table
tr
th
th {{_ 'backup-storage'}}
th {{_ 'backup-datetime'}}
th {{_ 'backup-path'}}
each backupList
tr
td
input.js-backup-select(type="radio" name="backup-select" value=path)
td {{storage}}
td {{datetime}}
td {{path}}
.form-group
label {{_ 'backup-restore-mode'}}
select.wekan-form-control.js-restore-mode
option(value="add-missing") {{_ 'backup-restore-add-missing'}}
option(value="replace-all") {{_ 'backup-restore-replace-all'}}
.form-group
button.js-restore-backup.btn.btn-warning(type="button" disabled=backupRunning) {{_ 'backup-restore'}}
else if isSandstormActive
ul#attachment-sandstorm-setting.setting-detail
li
h3 Sandstorm
.description {{_ 'sandstorm-migration-description'}}
if sandstormStatus
.form-group
strong {{_ 'sandstorm-migration-status'}}
if sandstormStatus.migrationSuccess
span.text-success {{_ 'sandstorm-migration-success'}}
else if sandstormStatus.migrationDone
span.text-danger {{_ 'sandstorm-migration-failed'}}
else
span.text-muted {{_ 'sandstorm-migration-pending'}}
if sandstormStatus.status
p.form-text.text-muted {{_ 'database-migration-phase'}}: {{sandstormStatus.status.phase}}
table.gridfs-stats-table
tr
th {{_ 'sandstorm-storage-item'}}
th {{_ 'sandstorm-disk-usage'}}
tr
td {{_ 'sandstorm-raw-mongodb'}}
td {{rawMongoSize}}
tr
td FerretDB (SQLite)
td {{ferretSize}}
tr
td {{_ 'attachments'}}
td {{attachmentsSize}}
tr
td {{_ 'avatars'}}
td {{avatarsSize}}
if sandstormStatus.rawMongoExists
.form-group
.description {{_ 'sandstorm-delete-raw-mongodb-description'}}
button.js-sandstorm-delete-raw-mongodb.btn.btn-warning(type="button" disabled=sandstormDeleteDisabled) {{_ 'sandstorm-delete-raw-mongodb'}}
if sandstormDeleteResult
p.form-text.text-success {{_ 'sandstorm-raw-mongodb-deleted'}} ({{sandstormDeleteFreed}})
if sandstormDeleteError
p.form-text.text-danger {{sandstormDeleteError}}
else
p.form-text.text-muted {{_ 'loading'}}
template(name="moveAttachments")
.move-storage-form
.move-storage-selects
+231
View File
@@ -267,6 +267,28 @@ Template.attachments.onCreated(function () {
this.cloudTestResults = new ReactiveVar({});
this.cloudTestErrors = new ReactiveVar({});
this.loading = new ReactiveVar(false);
this.migrateStatus = new ReactiveVar(null);
this.migratePoll = null;
this.backupStatus = new ReactiveVar(null);
this.backupList = new ReactiveVar(null);
this.backupSchedule = new ReactiveVar(null);
this.selectedBackup = new ReactiveVar('');
this.backupPoll = null;
Meteor.call('getBackupSchedule', (err, s) => { if (!err && s) this.backupSchedule.set(s); });
// Sandstorm: MongoDB 3 -> FerretDB migration status + raw-MongoDB disk usage.
this.sandstormStatus = new ReactiveVar(null);
this.sandstormDeleteResult = new ReactiveVar(null);
this.sandstormDeleteError = new ReactiveVar('');
// Fetch (once) when the Sandstorm tab is opened. Migration runs in start.js
// before the app boots, so the status is static by the time the UI is shown.
this.autorun(() => {
if (this.activeSection.get() === 'sandstorm') {
Meteor.call('sandstormMigrationStatus', (err, res) => {
if (!err && res) this.sandstormStatus.set(res);
});
}
});
this.autorun(() => {
const ready = this.storageSettingsSubscription.ready();
@@ -279,6 +301,54 @@ Template.attachments.onCreated(function () {
});
});
Template.attachments.onDestroyed(function () {
if (this.migratePoll) {
Meteor.clearTimeout(this.migratePoll);
this.migratePoll = null;
}
if (this.backupPoll) {
Meteor.clearTimeout(this.backupPoll);
this.backupPoll = null;
}
});
// Text-data database migration (MongoDB <-> FerretDB v1 SQLite) helpers.
function pollMigrateStatus(tpl) {
Meteor.call('migrateTextDatabaseStatus', (err, status) => {
if (err || !status) return;
tpl.migrateStatus.set(status);
if (status.running) {
tpl.migratePoll = Meteor.setTimeout(() => pollMigrateStatus(tpl), 2000);
}
});
}
function startDbMigration(tpl, direction) {
const which = direction === 'toFerretDB' ? 'FerretDB v1 (SQLite)' : 'MongoDB';
// eslint-disable-next-line no-alert
if (!window.confirm(TAPi18n.__('database-migration-confirm', { db: which }))) {
return;
}
tpl.migrateStatus.set({ running: true, phase: 'starting', direction });
Meteor.call('migrateTextDatabase', direction, error => {
if (error) {
tpl.migrateStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
} else {
pollMigrateStatus(tpl);
}
});
}
function pollBackupStatus(tpl) {
Meteor.call('backupStatus', (err, status) => {
if (err || !status) return;
tpl.backupStatus.set(status);
if (status.running) {
tpl.backupPoll = Meteor.setTimeout(() => pollBackupStatus(tpl), 2000);
}
});
}
Template.attachments.helpers({
loading() {
return Template.instance().loading;
@@ -298,6 +368,89 @@ Template.attachments.helpers({
isGcsActive() {
return Template.instance().activeSection.get() === 'gcs';
},
isDatabaseMigrationActive() {
return Template.instance().activeSection.get() === 'database-migration';
},
migrateStatus() {
return Template.instance().migrateStatus.get();
},
migrateRunning() {
const s = Template.instance().migrateStatus.get();
return !!(s && s.running);
},
migrateSuccess() {
const s = Template.instance().migrateStatus.get();
return !!(s && s.success === true);
},
isBackupActive() {
return Template.instance().activeSection.get() === 'backup';
},
isSandstorm() {
return !!(
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm
);
},
isSandstormActive() {
return Template.instance().activeSection.get() === 'sandstorm';
},
sandstormStatus() {
return Template.instance().sandstormStatus.get();
},
rawMongoSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.rawMongoBytes || 0) : '';
},
ferretSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.ferretBytes || 0) : '';
},
attachmentsSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.attachmentsBytes || 0) : '';
},
avatarsSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.avatarsBytes || 0) : '';
},
sandstormDeleteDisabled() {
const s = Template.instance().sandstormStatus.get();
// Only allow deleting the raw MongoDB files after a confirmed-successful
// migration, and only while they still exist.
return !(s && s.migrationSuccess && s.rawMongoExists);
},
sandstormDeleteResult() {
return Template.instance().sandstormDeleteResult.get();
},
sandstormDeleteFreed() {
const r = Template.instance().sandstormDeleteResult.get();
return r ? filesize(r.freedBytes || 0) : '';
},
sandstormDeleteError() {
return Template.instance().sandstormDeleteError.get();
},
backupStatus() {
return Template.instance().backupStatus.get();
},
backupRunning() {
const s = Template.instance().backupStatus.get();
return !!(s && s.running);
},
backupSuccess() {
const s = Template.instance().backupStatus.get();
return !!(s && s.success === true && s.phase === 'completed');
},
backupList() {
return Template.instance().backupList.get();
},
scheduleOff() { const s = Template.instance().backupSchedule.get(); return !s || !s.frequency || s.frequency === 'off'; },
scheduleDaily() { const s = Template.instance().backupSchedule.get(); return !!(s && s.frequency === 'daily'); },
scheduleWeekly() { const s = Template.instance().backupSchedule.get(); return !!(s && s.frequency === 'weekly'); },
scheduleMonthly() { const s = Template.instance().backupSchedule.get(); return !!(s && s.frequency === 'monthly'); },
scheduleTime() { const s = Template.instance().backupSchedule.get(); return (s && s.time) || '04:00'; },
scheduleDow() { const s = Template.instance().backupSchedule.get(); return (s && s.dayOfWeek) || 'Sunday'; },
scheduleDom() { const s = Template.instance().backupSchedule.get(); return (s && s.dayOfMonth) || 1; },
avatarsUploadBlocked() {
const settings = Template.instance().attachmentStorageSettings.get();
return settings?.limitSettings?.avatarsUploadBlocked === true;
@@ -561,6 +714,84 @@ Template.attachments.events({
tpl.activeSection.set(targetID);
},
'click .js-sandstorm-delete-raw-mongodb'(event, tpl) {
event.preventDefault();
if (!window.confirm(TAPi18n.__('sandstorm-delete-raw-mongodb-confirm'))) {
return;
}
tpl.sandstormDeleteError.set('');
Meteor.call('sandstormDeleteRawMongo', (error, result) => {
if (error) {
tpl.sandstormDeleteError.set(error.reason || error.message);
return;
}
tpl.sandstormDeleteResult.set(result);
// Refresh disk usage / status after deletion.
Meteor.call('sandstormMigrationStatus', (err, res) => {
if (!err && res) tpl.sandstormStatus.set(res);
});
});
},
'click .js-migrate-to-ferretdb'(event, tpl) {
event.preventDefault();
startDbMigration(tpl, 'toFerretDB');
},
'click .js-migrate-to-mongodb'(event, tpl) {
event.preventDefault();
startDbMigration(tpl, 'toMongoDB');
},
'click .js-run-backup'(event, tpl) {
event.preventDefault();
const opts = {
attachments: tpl.$('.js-backup-attachments').is(':checked'),
avatars: tpl.$('.js-backup-avatars').is(':checked'),
data: tpl.$('.js-backup-data').is(':checked'),
};
const storage = tpl.$('.js-backup-storage').val() || 'filesystem';
tpl.backupStatus.set({ running: true, phase: 'starting' });
Meteor.call('runBackup', opts, storage, error => {
if (error) tpl.backupStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
else pollBackupStatus(tpl);
});
},
'click .js-save-backup-schedule'(event, tpl) {
event.preventDefault();
const schedule = {
enabled: (tpl.$('.js-backup-frequency').val() || 'off') !== 'off',
frequency: tpl.$('.js-backup-frequency').val() || 'off',
time: tpl.$('.js-backup-time').val() || '04:00',
dayOfWeek: tpl.$('.js-backup-dow').val() || 'Sunday',
dayOfMonth: parseInt(tpl.$('.js-backup-dom').val(), 10) || 1,
attachments: tpl.$('.js-backup-attachments').is(':checked'),
avatars: tpl.$('.js-backup-avatars').is(':checked'),
data: tpl.$('.js-backup-data').is(':checked'),
storage: tpl.$('.js-backup-storage').val() || 'filesystem',
};
Meteor.call('saveBackupSchedule', schedule, (error, saved) => {
if (!error && saved) tpl.backupSchedule.set(saved);
});
},
'click .js-list-backups'(event, tpl) {
event.preventDefault();
Meteor.call('listBackups', (error, list) => {
if (!error) tpl.backupList.set(list || []);
});
},
'change .js-backup-select'(event, tpl) {
tpl.selectedBackup.set($(event.currentTarget).val());
},
'click .js-restore-backup'(event, tpl) {
event.preventDefault();
const zipPath = tpl.selectedBackup.get();
if (!zipPath) { window.alert(TAPi18n.__('backup-restore-select-first')); return; }
const mode = tpl.$('.js-restore-mode').val() || 'add-missing';
if (!window.confirm(TAPi18n.__('backup-restore-confirm'))) return;
tpl.backupStatus.set({ running: true, phase: 'restore' });
Meteor.call('restoreBackup', zipPath, mode, error => {
if (error) tpl.backupStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
else pollBackupStatus(tpl);
});
},
'change select.js-attachment-limit-unit'(event, tpl) {
const fieldName = event.currentTarget.dataset.field;
const selectedUnit = event.currentTarget.value;
@@ -29,15 +29,31 @@ template(name='statistics')
tr
th {{_ 'Node_version'}}
td {{statistics.process.nodeVersion}}
tr
th {{_ 'Database_type'}}
td {{statistics.mongo.databaseType}}
tr
th {{_ 'MongoDB_version'}}
td {{statistics.mongo.mongoVersion}}
tr
th {{_ 'Database_commit'}}
td {{statistics.mongo.databaseCommit}}
if statistics.mongo.ferretdbVersion
tr
th {{_ 'FerretDB_version'}}
td {{statistics.mongo.ferretdbVersion}}
tr
th {{_ 'FerretDB_commit'}}
td {{statistics.mongo.ferretdbCommit}}
tr
th {{_ 'MongoDB_storage_engine'}}
td {{statistics.mongo.mongoStorageEngine}}
tr
th {{_ 'MongoDB_Oplog_enabled'}}
td {{formatBoolean statistics.mongo.mongoOplogEnabled}}
tr
th {{_ 'Reactivity_mode'}}
td {{statistics.mongo.reactivity}}
tr
th {{_ 'OS_Type'}}
td {{statistics.os.type}}
+59
View File
@@ -378,3 +378,62 @@ table td:first-child {
.roles-settings .roles-description {
margin-bottom: 1rem;
}
/* Admin Panel > People > Domains table: search + column sort + pagination,
matching the Board Table view / other admin tables. */
.domain-controls {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.domain-controls input.js-domain-search {
flex: 1 1 auto;
height: 30px;
padding: 0 8px;
border: 1px solid #bbb;
border-radius: 0.4vw;
}
.domain-controls button.js-domain-search-button {
height: 30px;
padding: 0 12px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.domain-pagination {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.domain-pagination button {
min-width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.domain-pagination button.disabled {
opacity: 0.5;
pointer-events: none;
}
.domain-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
th.js-domain-sort {
cursor: pointer;
user-select: none;
}
+10 -3
View File
@@ -122,7 +122,7 @@ template(name="people")
else if templatesSetting.get
+templatesGeneral
else if domainSetting.get
+domainGeneral(domainList=domainList)
+domainGeneral
template(name="orgGeneral")
@@ -239,11 +239,18 @@ template(name="selectAllUser")
input.allUserChkBox(type="checkbox", id="chkSelectAll")
template(name="domainGeneral")
.domain-controls
input.js-domain-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
button.js-domain-search-button(type="button") {{_ 'search'}}
.domain-pagination
button.js-domain-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}") {{_ 'previous'}}
span.domain-page-info {{currentPage}} / {{totalPages}}
button.js-domain-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}") {{_ 'next'}}
table
thead
tr
th {{_ 'domain'}}
th {{_ 'domain-user-count'}}
th.js-domain-sort(scope="col" data-sort="domain") {{_ 'domain'}} {{domainSortIndicator 'domain'}}
th.js-domain-sort(scope="col" data-sort="count") {{_ 'domain-user-count'}} {{domainSortIndicator 'count'}}
tbody
if domainList.length
each domainList
+99 -11
View File
@@ -14,6 +14,7 @@ import InviteToBoardRolesSettings, {
const orgsPerPage = 25;
const teamsPerPage = 25;
const usersPerPage = 25;
const domainsPerPage = 25;
let userOrgsTeamsAction = ""; //poosible actions 'addOrg', 'addTeam', 'removeOrg' or 'removeTeam' when adding or modifying a user
let selectedUserChkBoxUserIds = [];
@@ -28,9 +29,9 @@ Template.people.onCreated(function () {
this.lockedUsersSetting = new ReactiveVar(false);
this.rolesSetting = new ReactiveVar(false);
this.templatesSetting = new ReactiveVar(false);
// #5850: Admin Panel > People > Domains tab.
// #5850: Admin Panel > People > Domains tab. The domains table itself
// (domainGeneral) now owns its data via getDomainsWithUserCountsPage.
this.domainSetting = new ReactiveVar(false);
this.domains = new ReactiveVar([]);
this.subscribe('inviteToBoardRolesSettings');
this.findOrgsOptions = new ReactiveVar({});
this.findTeamsOptions = new ReactiveVar({});
@@ -205,12 +206,9 @@ Template.people.onCreated(function () {
this.templatesSetting.set('templates-setting' === targetID);
this.domainSetting.set('domains-setting' === targetID);
// #5850: load the domains + per-domain user counts when the tab opens.
if ('domains-setting' === targetID) {
Meteor.call('getDomainsWithUserCounts', (err, res) => {
if (!err) this.domains.set(res || []);
});
}
// The Domains table (domainGeneral) now fetches its own single page from
// getDomainsWithUserCountsPage (server-side search / sort / pagination), so
// the parent no longer eagerly loads every domain into the browser.
// When switching to locked users tab, refresh the locked users list
if ('locked-users-setting' === targetID) {
@@ -292,9 +290,6 @@ Template.people.helpers({
domainSetting() {
return Template.instance().domainSetting;
},
domainList() {
return Template.instance().domains.get();
},
orgList() {
const tpl = Template.instance();
// The 'org' publication already returns only the current page (server-side
@@ -1897,3 +1892,96 @@ Template.settingsUserPopup.helpers({
return Template.instance().errorMessage.get();
},
});
// Admin Panel > People > Domains table. Self-contained (like the Board Table
// view): it keeps its own search / sort / page state and fetches only ONE page
// from the server method getDomainsWithUserCountsPage, so the whole domain list
// is never loaded into the browser.
Template.domainGeneral.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('domain'); // domain | count
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.pageData = new ReactiveVar({ rows: [], total: 0, totalPages: 1 });
this.autorun(() => {
const params = {
search: this.searchQuery.get(),
sortField: this.sortField.get(),
sortDirection: this.sortDirection.get(),
page: this.page.get(),
perPage: domainsPerPage,
};
Meteor.call('getDomainsWithUserCountsPage', params, (err, res) => {
if (!err && res) {
this.pageData.set(res);
// Server clamps the page into range; mirror that so the controls agree.
if (typeof res.page === 'number' && res.page !== this.page.get()) {
this.page.set(res.page);
}
}
});
});
});
Template.domainGeneral.helpers({
domainList() {
return Template.instance().pageData.get().rows;
},
currentPage() {
return Template.instance().page.get();
},
totalPages() {
return Template.instance().pageData.get().totalPages || 1;
},
hasPrevPage() {
return Template.instance().page.get() > 1;
},
hasNextPage() {
const tpl = Template.instance();
return tpl.page.get() < (tpl.pageData.get().totalPages || 1);
},
// Excel-like sort arrow on the active column, matching the Board Table view.
domainSortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.domainGeneral.events({
'click .js-domain-search-button'(event, tpl) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-domain-search').val() || '');
tpl.page.set(1);
},
'keydown .js-domain-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-domain-search').val() || '');
tpl.page.set(1);
}
},
'click .js-domain-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-domain-next-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current < (tpl.pageData.get().totalPages || 1)) tpl.page.set(current + 1);
},
'click .js-domain-sort'(event, tpl) {
event.preventDefault();
const field = event.currentTarget.dataset.sort;
if (!field) return;
if (tpl.sortField.get() === field) {
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
} else {
tpl.sortField.set(field);
tpl.sortDirection.set(1);
}
tpl.page.set(1);
},
});
@@ -14,6 +14,11 @@ template(name="settingHeaderBar")
i.fa.fa-users
span {{_ 'people'}}
a.setting-header-btn.informations(class=isFeaturesActive href="{{pathFor 'admin-features'}}")
span.emoji-icon
i.fa.fa-flask
span {{_ 'features'}}
a.setting-header-btn.informations(class=isAdminReportsActive href="{{pathFor 'admin-reports'}}")
span.emoji-icon
i.fa.fa-file-text-o
@@ -7,6 +7,9 @@ Template.settingHeaderBar.helpers({
isPeopleActive() {
return FlowRouter.getRouteName() === 'people' ? 'active' : '';
},
isFeaturesActive() {
return FlowRouter.getRouteName() === 'admin-features' ? 'active' : '';
},
isAdminReportsActive() {
return FlowRouter.getRouteName() === 'admin-reports' ? 'active' : '';
},
+15 -9
View File
@@ -55,9 +55,14 @@ template(name='homeSidebar')
i.fa.fa-comment-o
| {{_ 'activities'}}
a.flex.js-toggle-show-activities(title="{{_ 'show-activities'}}")
i.fa(class="{{#if showActivities}}fa-check{{else}}fa-square-o{{/if}}")
span {{_ 'show-activities'}}
if showActivities
a.flex.activities-eye-toggle.js-toggle-show-activities(title="{{_ 'hide-activities'}}" aria-label="{{_ 'hide-activities'}}")
i.fa.fa-eye.eye-icon
span {{_ 'hide-activities'}}
else
a.flex.activities-eye-toggle.js-toggle-show-activities(title="{{_ 'show-activities'}}" aria-label="{{_ 'show-activities'}}")
i.fa.fa-eye-slash.eye-slash-icon
span {{_ 'show-activities'}}
+activities(mode="board")
template(name="membersWidget")
@@ -80,7 +85,7 @@ template(name="membersWidget")
+userAvatar(userId=currentBoard.userId showStatus=true)
.clearfix
else
each currentBoard.activeMembers
each currentBoard.membersForSidebar
+userAvatar(userId=this.userId showStatus=true)
if isSandstorm
if currentUser.isBoardMember
@@ -696,10 +701,11 @@ template(name="boardMenuPopup")
hr
ul.pop-over-list
if withApi
li
a.js-export-board
i.fa.fa-upload
| {{_ 'export-board'}}
unless exportDisabled
li
a.js-export-board
i.fa.fa-upload
| {{_ 'export-board'}}
if currentUser.isBoardAdmin
li
a.js-outgoing-webhooks
@@ -823,7 +829,7 @@ template(name="labelsWidget")
| {{_ 'labels'}}
.board-widget-content
each currentBoard.labels
a.card-label(class="card-label-{{color}}"
a.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}"
class="{{#if currentUser.isNotCommentOnly}}{{#if currentUser.isNotWorker}}js-label{{/if}}{{/if}}")
span.card-label-name
+viewer
@@ -29,7 +29,7 @@ template(name="filterSidebar")
each currentBoard.labels
li
a.name.js-toggle-label-filter(data-filter-id="{{_id}}")
span.card-label.square(class="card-label-{{color}}")
span.card-label.square(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
span.sidebar-list-item-description
if name
= name
@@ -188,7 +188,7 @@ template(name="multiselectionSidebar")
each currentBoard.labels
li
a.name.js-toggle-label-multiselection
span.card-label.square(class="card-label-{{color}}")
span.card-label.square(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
span.sidebar-list-item-description
if name
= name
@@ -1,5 +1,5 @@
template(name="swimlaneHeader")
.swimlane-header-wrap.js-swimlane-header(class=colorClass title="{{_ 'rename'}}")
.swimlane-header-wrap.js-swimlane-header(class=colorClass style=colorStyle title="{{_ 'rename'}}")
if this.isTemplateContainer
+swimlaneFixedHeader(this)
else
@@ -120,6 +120,10 @@ template(name="setSwimlaneColorPopup")
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
// #5514: native color wheel for picking a custom RGB / hex color
.flush-left.color-wheel-wrap
label(for="swimlaneColorWheel") {{_ 'custom-color'}}
input.js-swimlane-color-wheel#swimlaneColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
// Buttons aligned left too
.flush-left
button.primary.confirm.js-submit(style="margin-inline-start:0") {{_ 'save'}}
@@ -1,6 +1,7 @@
import { TAPi18n } from '/imports/i18n';
import { ReactiveCache } from '/imports/reactiveCache';
import { SWIMLANE_COLORS } from '/models/metadata/colors';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import Swimlanes from '/models/swimlanes';
import { Utils } from '/client/lib/utils';
const { calculateIndexData } = Utils;
@@ -166,6 +167,10 @@ Template.setSwimlaneColorPopup.helpers({
isSelected(color) {
return Template.instance().currentColor.get() === color;
},
// #5514: current color as a '#rrggbb' hex for the native color wheel <input>.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#0079bf';
},
});
Template.setSwimlaneColorPopup.events({
@@ -178,6 +183,13 @@ Template.setSwimlaneColorPopup.events({
const paletteData = Blaze.getData(event.currentTarget);
tpl.currentColor.set(paletteData?.color);
},
// #5514: picking from the native color wheel stores a custom hex.
'input .js-swimlane-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
async 'click .js-submit'(event, tpl) {
event.preventDefault();
await tpl.currentSwimlane.setColor(tpl.currentColor.get());
+39
View File
@@ -118,3 +118,42 @@
height: clamp(40px, 5vw, 60px);
margin-inline-end: 10px;
}
/* Account-status distinction on the avatar itself (see userAvatar.js
memberAccountClass / isImportedMember / isInactiveMember):
- imported-member: an un-reconciled board-import placeholder that is not yet mapped
to a real account — dashed ring + amber "?" corner badge.
- inactive-member: deactivated on this board (e.g. a person not in LDAP after
reconciliation) — greyscale + dimmed, grey corner badge.
A placeholder is usually both; a real, active account gets neither. */
.member.imported-member .avatar {
border: 2px dashed #7f8c9a;
box-sizing: border-box;
opacity: 0.9;
}
.member.inactive-member .avatar {
filter: grayscale(1);
opacity: 0.55;
}
.member .member-account-badge {
position: absolute;
bottom: -2px;
right: -2px;
min-width: 14px;
height: 14px;
line-height: 13px;
padding: 0 1px;
border-radius: 7px;
font-size: 10px;
font-weight: bold;
text-align: center;
color: #fff;
box-shadow: 0 0 0 1.5px #fff;
pointer-events: none;
}
.member .member-account-badge.imported {
background: #f39c12;
}
.member .member-account-badge.inactive {
background: #95a5a6;
}
+6 -1
View File
@@ -1,5 +1,5 @@
template(name="userAvatar")
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{/if}}")
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
if userData
if userData.profile.avatarUrl
img.avatar.avatar-image(src="{{avatarUrl}}" alt="")
@@ -8,6 +8,11 @@ template(name="userAvatar")
else
+userAvatarInitials(userId=this.userId)
if isImportedMember
span.member-account-badge.imported(title="{{_ 'imported-member-no-account'}}") ?
else if isInactiveMember
span.member-account-badge.inactive(title="{{_ 'inactive-member'}}")
if showStatus
span.member-presence-status(class=presenceStatusClassName)
span.member-type(class=memberType)
+44
View File
@@ -11,11 +11,55 @@ Template.userAvatar.helpers({
fields: {
profile: 1,
username: 1,
authenticationMethod: 1,
isActive: 1,
},
});
return user;
},
// Distinguish, on the avatar itself, whether a real account backs this member:
// - imported-member: an inert placeholder created by a board import that has not been
// reconciled/mapped to a real account yet (dashed ring + "?" badge).
// - inactive-member: deactivated on this board (e.g. a person not in LDAP after
// reconciliation) (greyscale + dim).
// A placeholder is typically both. A real, active account gets neither.
memberAccountClass() {
const user = ReactiveCache.getUser(this.userId, {
fields: { authenticationMethod: 1, isActive: 1 },
});
if (!user) return '';
const classes = [];
if (user.authenticationMethod === 'imported') classes.push('imported-member');
let inactive = user.isActive === false;
if (!inactive) {
const board = Utils.getCurrentBoard && Utils.getCurrentBoard();
const m = board && Array.isArray(board.members)
? board.members.find(x => x.userId === this.userId)
: null;
if (m && m.isActive === false) inactive = true;
}
if (inactive) classes.push('inactive-member');
return classes.join(' ');
},
isImportedMember() {
const user = ReactiveCache.getUser(this.userId, { fields: { authenticationMethod: 1 } });
return !!(user && user.authenticationMethod === 'imported');
},
isInactiveMember() {
const user = ReactiveCache.getUser(this.userId, { fields: { authenticationMethod: 1, isActive: 1 } });
if (!user) return false;
if (user.authenticationMethod === 'imported') return false; // shown via the imported badge
if (user.isActive === false) return true;
const board = Utils.getCurrentBoard && Utils.getCurrentBoard();
const m = board && Array.isArray(board.members)
? board.members.find(x => x.userId === this.userId)
: null;
return !!(m && m.isActive === false);
},
avatarUrl() {
const user = ReactiveCache.getUser(this.userId, { fields: { profile: 1 } });
const base = (user && user.profile && user.profile.avatarUrl) || '';
+13
View File
@@ -98,6 +98,19 @@ body.mobile-mode .auth-layout .auth-dialog .at-form .password-input-container .p
margin-top: 2vh;
color: #4d4d4d;
}
/* #4394 (security): when registration / forgot-password are disabled in the
Admin Panel, keep the corresponding links hidden even after useraccounts
re-renders the form on a failed login. The class lives on the stable <body>
ancestor (set in layouts.js), so this rule re-hides every recreated copy of
the link — unlike a one-shot inline display:none that the re-render dropped. */
body.registration-disabled .at-signup-link,
body.registration-disabled .at-signUp {
display: none !important;
}
body.forgotpwd-disabled .at-pwd-link,
body.forgotpwd-disabled .at-forgotPwd {
display: none !important;
}
.auth-layout .auth-dialog .at-form .at-signup-link .at-signUp,
.auth-layout .auth-dialog .at-form .at-signin-link .at-signUp,
.auth-layout .auth-dialog .at-form .at-forgotPwd .at-signUp,
+41
View File
@@ -29,6 +29,27 @@ import {
calendar,
} from '/imports/lib/dateUtils';
import { Utils } from '/client/lib/utils';
import { isHexColor, contrastText } from '/models/lib/contrastColor';
// #5514: a color (label / list / swimlane / card) may be either a named palette
// color rendered via a CSS class (e.g. `card-label-green`) or a custom
// '#rrggbb' hex chosen from the color wheel. For a hex value there is no CSS
// class, so we emit an inline `background-color` plus an automatically readable
// text `color` (contrastText). These helpers let templates keep the class path
// for named colors and switch to inline styles for hex.
// Class for a label chip: `card-label-<name>` for named colors, empty for hex.
Blaze.registerHelper('labelColorClass', color =>
isHexColor(color) ? '' : `card-label-${color}`,
);
// Inline style for a label chip: background + readable text for a hex color,
// empty for a named color (its CSS class already sets both).
Blaze.registerHelper('labelColorStyle', color =>
isHexColor(color)
? `background-color:${color};color:${contrastText(color)};`
: '',
);
Blaze.registerHelper('currentBoard', () => {
const ret = Utils.getCurrentBoard();
@@ -87,6 +108,26 @@ Blaze.registerHelper('currentUser', () => {
return ret;
});
// Admin Panel / Features / Security master switches. Templates use these to hide
// the import / export menu options when an admin has disabled all import / export.
Blaze.registerHelper('importDisabled', () =>
!!(ReactiveCache.getCurrentSetting() || {}).disableAllImport,
);
Blaze.registerHelper('exportDisabled', () =>
!!(ReactiveCache.getCurrentSetting() || {}).disableAllExport,
);
// Admin Panel / Features / Notifications (#5820). Templates use these to hide
// the activity feed and the watch controls when an admin has disabled them.
Blaze.registerHelper('activitiesDisabled', () =>
!!(ReactiveCache.getCurrentSetting() || {}).disableActivities,
);
Blaze.registerHelper('watchDisabled', () =>
!!(ReactiveCache.getCurrentSetting() || {}).disableWatch,
);
Blaze.registerHelper('getUser', (userId) => ReactiveCache.getUser(userId));
Blaze.registerHelper('concat', (...args) => args.slice(0, -1).join(''));
+2
View File
@@ -1,3 +1,4 @@
import '/client/components/settings/adminFeatures.jade';
import '/client/components/settings/adminReports.jade';
import '/client/components/settings/attachments.jade';
import '/client/components/settings/attachmentSettings.jade';
@@ -11,6 +12,7 @@ import '/client/components/settings/settingBody.jade';
import '/client/components/settings/settingHeader.jade';
import '/client/components/settings/translationBody.jade';
import '/client/components/settings/adminFeatures.js';
import '/client/components/settings/adminReports.js';
import '/client/components/settings/attachments.js';
import '/client/components/settings/connectionMethod.js';
+84 -2
View File
@@ -18,6 +18,84 @@ window.ExportHtml = Popup => {
}
};
// The board is rendered with infinite scroll — each list only keeps ~10 cards
// in the DOM (client/components/lists/listBody.js). A naive DOM-clone export
// would therefore drop every card past the fold. Before cloning we lift every
// list's `cardlimit` so the whole board renders, then wait for Blaze to flush.
const expandAllCardsForExport = async () => {
let changed = false;
Array.from(document.querySelectorAll('.list-body')).forEach(el => {
try {
let view = Blaze.getView(el);
while (view) {
if (view.template && view.template.viewName === 'Template.listBody' && view.templateInstance) {
const inst = view.templateInstance();
if (inst && inst.cardlimit && inst.cardlimit.get() < 1e6) {
inst.cardlimit.set(1e6); // render all cards in this list
changed = true;
}
break;
}
view = view.parentView;
}
} catch (e) { /* best effort per list */ }
});
if (changed) {
// Let the reactive re-render settle before we snapshot the DOM.
await new Promise(resolve => Tracker.afterFlush(() => setTimeout(resolve, 800)));
}
};
// Write the zip to disk WITHOUT holding the whole archive in memory. When the
// browser supports the File System Access API we pipe JSZip's internal stream
// straight to the chosen file, chunk by chunk, applying backpressure so a
// board with many attachments/covers never buffers the entire .zip in RAM.
// Older browsers fall back to the previous in-memory blob download.
const saveZipStreaming = async (zip, filename) => {
const genOpts = {
type: 'uint8array',
streamFiles: true,
compression: 'DEFLATE',
compressionOptions: { level: 6 },
};
if (typeof window.showSaveFilePicker === 'function') {
let handle = null;
try {
handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'Zip archive', accept: { 'application/zip': ['.zip'] } }],
});
} catch (e) {
// User dismissed the save dialog — nothing to do.
if (e && e.name === 'AbortError') return;
handle = null; // any other failure → fall back to blob download
}
if (handle) {
const writable = await handle.createWritable();
await new Promise((resolve, reject) => {
const helper = zip.generateInternalStream(genOpts);
helper
.on('data', data => {
// Pause the zip stream until this chunk is flushed to disk.
helper.pause();
writable.write(data).then(() => helper.resume(), reject);
})
.on('error', reject)
.on('end', () => { writable.close().then(resolve, reject); })
.resume();
});
return;
}
}
// Fallback: build the blob in memory (older browsers only).
const content = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
saveAs(content, filename);
};
const getPageHtmlString = (clonedElement = null) => {
const element = clonedElement || window.document.querySelector('html');
return `<!doctype html>${element.outerHTML}`;
@@ -526,6 +604,10 @@ body { font-family: sans-serif !important; }
const zipFilename = boardTitle || boardSlug;
const zipDirName = zipFilename; // Directory name inside the ZIP
// Render EVERY card first (infinite scroll otherwise leaves most cards out
// of the DOM), then snapshot.
await expandAllCardsForExport();
// Clone the HTML element to process for export without modifying live DOM
const htmlElement = window.document.querySelector('html');
const clonedHtmlElement = htmlElement.cloneNode(true);
@@ -549,8 +631,8 @@ body { font-family: sans-serif !important; }
addBoardHTMLToZip(boardSlug, zip, clonedHtmlElement, zipDirName);
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, `${zipFilename}.zip`);
// Stream the .zip straight to disk (low memory) with a blob fallback.
await saveZipStreaming(zip, `${zipFilename}.zip`);
// No page reload - impersonation session is preserved!
};
};
+19 -29
View File
@@ -19,8 +19,9 @@ import {
now,
createDate,
fromNow,
calendar
calendar
} from '/imports/lib/dateUtils';
import { weekRange } from '/models/lib/weekStart';
// Sidebar is imported late to avoid circular dependency (sidebar.js needs its
// jade template loaded first, but router.js → filter.js would load it too early)
let _Sidebar;
@@ -119,44 +120,33 @@ class DateFilter {
// weeks up to today +/- offset. This considers the user's preferred
// start of week day (as defined by Meteor).
relativeWeek(offset, week) {
if (this._filterState == 'thisweek') {
// #4881: toggle per-week so clicking the same button again clears it, but
// clicking the OTHER week switches to it instead of just resetting (the old
// code reset on either state, so you could not go straight from "this week"
// to "next week").
const targetState = week === 'next' ? 'nextweek' : 'thisweek';
if (this._filterState === targetState) {
this.reset();
return;
}
if (this._filterState == 'nextweek') {
this.reset();
return;
}
// getStartDayOfWeek returns the offset from Sunday of the user's
// preferred starting day of the week. This date should be added
// to the moment start of week to get the real start of week date.
// The default is 1, meaning Monday.
// #4881: the old window came from startOf(now(), 'week'), but native
// dateUtils.startOf() has no 'week' case, so it returned "now" unchanged and
// the window started at today + startDayOfWeek — i.e. it selected NEXT
// week's cards for "this week" and ignored the configured start weekday.
// weekRange() computes the correct window that CONTAINS today for the user's
// configured start day of week (0=Sunday..6=Saturday, default Monday).
const currentUser = ReactiveCache.getCurrentUser();
const weekStartDay = currentUser ? currentUser.getStartDayOfWeek() : 1;
const weekOffset = week === 'next' ? 1 : 0;
const { start: WeekStart, end: WeekEnd } = weekRange(now(), weekStartDay, weekOffset);
if (week === 'this') {
// Create week start and end dates
var WeekStart = startOf(add(startOf(now(), 'week'), weekStartDay, 'days'), 'day');
var WeekEnd = endOf(add(WeekStart, 6, 'days'), 'day');
this._updateState('thisweek');
} else if (week === 'next') {
// Create next week start and end dates
var WeekStart = startOf(add(startOf(now(), 'week'), weekStartDay + 7, 'days'), 'day');
var WeekEnd = endOf(add(WeekStart, 6, 'days'), 'day');
this._updateState('nextweek');
}
var startDate = WeekStart;
var endDate = WeekEnd;
this._updateState(targetState);
if (offset >= 0) {
this._filter = { $gte: startDate, $lte: endDate };
this._filter = { $gte: WeekStart, $lte: WeekEnd };
} else {
this._filter = { $lte: startDate, $gte: endDate };
this._filter = { $lte: WeekStart, $gte: WeekEnd };
}
}
+11 -8
View File
@@ -56,15 +56,18 @@ Meteor.startup(async () => {
navigator.userLanguage,
].filter(Boolean);
if (language) {
// Try with potentially complex language tag
if (TAPi18n.isLanguageSupported(language)) {
TAPi18n.setLanguage(language);
} else if (language.includes('-')) {
// Fallback to a general language
const [general] = language.split('-');
if (TAPi18n.isLanguageSupported(general)) {
TAPi18n.setLanguage(general);
// Match the browser tag, then progressively strip trailing subtags until a supported
// language is found: e.g. 'zh-Hans-CN' -> 'zh-Hans', 'zh-Hant-TW' -> 'zh-Hant',
// 'ja-JP' -> 'ja-JP', bare 'zh' -> 'zh-Hans' (via alias). isLanguageSupported is
// case-insensitive, so 'zh-hant' / 'JA-JP' etc. also match.
let candidate = language;
while (candidate) {
if (TAPi18n.isLanguageSupported(candidate)) {
TAPi18n.setLanguage(candidate);
break;
}
const cut = candidate.lastIndexOf('-');
candidate = cut > 0 ? candidate.slice(0, cut) : '';
}
}
});
+56
View File
@@ -0,0 +1,56 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import { Filter } from '/client/lib/filter';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
const { windowCountId: windowCountIdImpl, resolveCardsLoadingMode } = require('/models/lib/cardsLoading');
// Client-only mirror of the server `boardListCardCounts` docs published by the
// 'boardListCardCount' publication (one doc per list/swimlane window, holding
// the total card count so the board knows whether more cards remain to scroll
// in). Only used in lazy card-loading mode.
export const BoardListCardCounts = new Mongo.Collection('boardListCardCounts');
// Effective card-loading mode. Prefers the admin Setting doc (reactive, so an
// Admin Panel change is picked up), falls back to the public env value, then to
// 'all'. See server/cards-loading.js and Admin Panel / Features.
export function cardsLoadingMode() {
const setting = ReactiveCache.getCurrentSetting();
const fromDoc = setting && setting.cardsLoading;
const fromEnv =
Meteor.settings && Meteor.settings.public && Meteor.settings.public.cardsLoading;
return resolveCardsLoadingMode(fromDoc || fromEnv);
}
export function isLazyCards() {
return cardsLoadingMode() === 'lazy';
}
// Stable id for one (list, swimlane) window's count doc (shared, unit-tested).
export function windowCountId(listId, swimlaneId) {
return windowCountIdImpl(listId, swimlaneId);
}
// Accurate total card count for `list.cards(swimlaneId)` in lazy mode, where
// minimongo holds only the loaded window. Builds the SAME selector
// list.cards(swimlaneId) uses (listCardsSelector + the board Filter), subscribes
// (deduped) to the server-side count for it, and returns that count. Returns null
// when not in lazy mode or the count has not arrived yet, so callers can fall
// back to the loaded-window length. Because the selector is identical to the one
// list.cards() runs against minimongo, the server count is exactly the accurate
// version of whatever list.cards(swimlaneId).length would report.
export function lazyListCardCount(list, swimlaneId) {
if (!isLazyCards() || !list || !list.boardId) return null;
const selector = listCardsSelector(
list._id,
swimlaneId,
list.orphanedCardsSwimlaneIds ? list.orphanedCardsSwimlaneIds(swimlaneId) : undefined,
);
const mongoSelector = Filter.mongoSelector(selector);
const id = windowCountId(list._id, swimlaneId);
const inst = typeof Template !== 'undefined' && Template.instance ? Template.instance() : null;
if (inst && inst.subscribe) {
inst.subscribe('boardListCardCount', id, list.boardId, mongoSelector);
}
const doc = BoardListCardCounts.findOne(id);
return doc ? doc.count : null;
}
+27 -101
View File
@@ -4,6 +4,7 @@ import { Tracker } from 'meteor/tracker';
import { TAPi18n } from '/imports/i18n';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { computePopupOffset } from '/client/lib/popupOffset';
window.Popup = new (class {
constructor() {
@@ -222,108 +223,33 @@ window.Popup = new (class {
return () => {
Utils.windowResizeDep.depend();
if (Utils.isMiniScreen()) return { left: 0, top: 0 };
// #5667: read the current page scroll and the opener's document offset,
// then delegate the geometry to the pure, unit-tested computePopupOffset.
// It lays the popup out in VIEWPORT coordinates — so a date-picker opened
// low on a scrolled page stays fully visible instead of extending past the
// visible area — and returns document coordinates for the absolute style.
const hasOpener = !!($element && $element.length);
const offset = hasOpener ? $element.offset() : null;
const opener = hasOpener
? { top: offset.top, left: offset.left, height: $element.outerHeight() }
: null;
const viewportWidth = $(window).width();
const viewportHeight = $(window).height();
const viewportPadding = 10;
// Calculate actual popup width based on CSS: min(380px, 55vw)
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: dock it to the top of the viewport so it overlays
// the global and board header bars (its z-index already sits above them)
// instead of opening downward from the clicked minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
// top is forced to 0 in CSS (.pop-over[data-popup="cardDetailsPopup"])
// so the card overlays the header bars; use the full viewport height.
return {
left: Math.max(viewportPadding, centeredLeft),
top: 0,
maxHeight: viewportHeight,
};
}
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
if (!$element || $element.length === 0) {
return {
left: viewportPadding,
top: viewportPadding,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const offset = $element.offset();
const openerTop = offset.top;
const openerBottom = offset.top + $element.outerHeight();
const clampedLeft = Math.max(
viewportPadding,
Math.min(offset.left, viewportWidth - popupWidth - viewportPadding),
);
// Check if this is an admin panel edit popup
const isAdminEditPopup = $element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team');
if (isAdminEditPopup) {
// Center the popup horizontally and use full height
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft), // Ensure popup doesn't go off screen
top: viewportPadding, // Start from top with small margin
maxHeight: viewportHeight - viewportPadding * 2, // Use full height minus small margins
};
}
// Calculate available heights around the opener
const spaceBelow = viewportHeight - openerBottom - viewportPadding;
const spaceAbove = openerTop - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// For language popup, don't use dynamic height to avoid overlapping board
const isLanguagePopup = $element.hasClass('js-change-language');
let availableHeight;
if (isLanguagePopup) {
// For language popup, position content area below right vertical scrollbar
const languageTop = openerBottom;
const languageAvailable = Math.max(
0,
viewportHeight - languageTop - viewportPadding,
);
const calculatedHeight = Math.min(
languageAvailable,
viewportHeight * 0.5,
); // Max 50% of viewport
return {
left: clampedLeft,
top: languageTop,
maxHeight: Math.max(calculatedHeight, 0),
};
} else {
// For other popups, choose the side with more space and keep popup fully in viewport.
if (preferBelow) {
availableHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return {
left: clampedLeft,
top: openerBottom,
maxHeight: availableHeight,
};
}
availableHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const top = Math.max(viewportPadding, openerTop - availableHeight);
return {
left: clampedLeft,
top,
maxHeight: availableHeight,
};
}
return computePopupOffset({
viewportWidth: $(window).width(),
viewportHeight: $(window).height(),
scrollTop: $(window).scrollTop() || 0,
scrollLeft: $(window).scrollLeft() || 0,
opener,
popupName,
isMiniScreen: Utils.isMiniScreen(),
isAdminEditPopup:
hasOpener &&
($element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team')),
isLanguagePopup: hasOpener && $element.hasClass('js-change-language'),
viewportPadding: 10,
});
};
}
+118
View File
@@ -0,0 +1,118 @@
'use strict';
// Pure geometry for Popup._getOffset — extracted so the positioning math is
// unit-testable without a DOM / Meteor (see tests/popupOffset.test.cjs).
//
// #5667 ("Problem when scrolling in a map with date fields"): a date-picker
// calendar (due/start/end date, or a date custom field) opened low on the screen
// appeared partly outside the visible area, and — because the pop-over is
// `position: absolute` (document coordinates) — trying to scroll it into view
// moved it along with the page, so the calendar could never be seen fully.
//
// Root cause: _getOffset computed the space above / below the opener (and the
// clamped `top`) from the opener's DOCUMENT offset (`$el.offset()`) mixed with
// the VIEWPORT height, ignoring the page scroll. On a scrolled page that placed
// the anchored popup outside the visible viewport.
//
// Fix: do the space / clamp math in VIEWPORT coordinates (subtract
// scrollTop/scrollLeft), then convert the chosen top/left back to DOCUMENT
// coordinates (add the scroll back) for the absolute style. The popup box is then
// always laid out fully within the visible viewport. When the page is not
// scrolled (scrollTop === scrollLeft === 0) the output is identical to the
// previous behaviour, so nothing regresses in the common case.
function computePopupOffset(params) {
const {
viewportWidth,
viewportHeight,
scrollTop = 0,
scrollLeft = 0,
// Opener document offset { top, left, height }, or null for a programmatic
// open with no reference element.
opener = null,
popupName,
isMiniScreen = false,
isAdminEditPopup = false,
isLanguagePopup = false,
viewportPadding = 10,
} = params;
if (isMiniScreen) return { left: 0, top: 0 };
// Actual popup width from CSS: min(380px, 55vw).
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: docked to the top of the viewport (CSS also forces
// top:0) so it overlays the header bars instead of opening from the minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: scrollTop, // viewport top in document coords
maxHeight: viewportHeight,
};
}
// No opener element: fall back to the top-left of the VISIBLE viewport.
if (!opener) {
return {
left: viewportPadding + scrollLeft,
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
// Opener position in viewport coordinates (this is the #5667 fix: the previous
// code used the raw document offset here).
const openerTopVp = opener.top - scrollTop;
const openerBottomVp = openerTopVp + opener.height;
const openerLeftVp = opener.left - scrollLeft;
const clampedLeftVp = Math.max(
viewportPadding,
Math.min(openerLeftVp, viewportWidth - popupWidth - viewportPadding),
);
const clampedLeft = clampedLeftVp + scrollLeft;
// Admin panel edit popups: horizontally centered, anchored to the viewport top.
if (isAdminEditPopup) {
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const spaceBelow = viewportHeight - openerBottomVp - viewportPadding;
const spaceAbove = openerTopVp - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// Language popup: fixed-ish height below the opener, capped at 50% viewport.
if (isLanguagePopup) {
const languageTopVp = openerBottomVp;
const languageAvailable = Math.max(
0,
viewportHeight - languageTopVp - viewportPadding,
);
const calculatedHeight = Math.min(languageAvailable, viewportHeight * 0.5);
return {
left: clampedLeft,
top: languageTopVp + scrollTop,
maxHeight: Math.max(calculatedHeight, 0),
};
}
// Other popups: open on the side with more room and stay fully in the viewport.
if (preferBelow) {
const maxHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return { left: clampedLeft, top: openerBottomVp + scrollTop, maxHeight };
}
const maxHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const topVp = Math.max(viewportPadding, openerTopVp - maxHeight);
return { left: clampedLeft, top: topVp + scrollTop, maxHeight };
}
module.exports = { computePopupOffset };
+16 -5
View File
@@ -1,8 +1,12 @@
import DOMPurify from 'dompurify';
// Centralized secure DOMPurify configuration to prevent XSS and CSS injection attacks
export function getSecureDOMPurifyConfig() {
return {
// Centralized secure DOMPurify configuration to prevent XSS and CSS injection attacks.
//
// options.stripLinks — when true, anchor (<a>) tags are removed while their visible
// text is kept, so every link renders as plain, non-clickable text. Used by the
// Admin Panel / Features "render links as plain text" toggle.
export function getSecureDOMPurifyConfig(options = {}) {
const config = {
// Allow common markdown elements including anchor tags
ALLOWED_TAGS: ['a', 'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'div', 'span', 's'],
// Allow safe attributes including href for anchor tags
@@ -122,11 +126,18 @@ export function getSecureDOMPurifyConfig() {
}
}
};
if (options.stripLinks) {
// Forbid <a> but keep its inner text, so links become plain, non-clickable text.
config.FORBID_TAGS = config.FORBID_TAGS.concat(['a']);
}
return config;
}
// Convenience function for secure sanitization
export function sanitizeHTML(html) {
return DOMPurify.sanitize(html, getSecureDOMPurifyConfig());
export function sanitizeHTML(html, options = {}) {
return DOMPurify.sanitize(html, getSecureDOMPurifyConfig(options));
}
// Convenience function for sanitizing text (no HTML)
@@ -0,0 +1,39 @@
/* eslint-env mocha */
import { expect } from 'chai';
import { Markdown } from 'meteor/wekan-markdown';
// Tests for the Admin Panel / Features / Security "Always show all code as plain
// text" toggle. When enabled, the markdown helper renders the raw source escaped
// (via escapeHtmlSource) so hidden content — HTML comments, the target of markdown
// links, JavaScript and any other code — is shown literally, not executed. Here we
// pin escapeHtmlSource, the escaper that makes that safe.
describe('markdown — always show code as plain text (escapeHtmlSource)', function() {
const esc = Markdown.escapeHtmlSource;
it('escapes <script> so it is shown, not executed', function() {
expect(esc("<script>alert('x')</script>")).to.equal(
"&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;",
);
});
it('reveals HTML comments instead of hiding them', function() {
expect(esc('<!-- secret -->')).to.equal('&lt;!-- secret --&gt;');
});
it('escapes ampersand first so entities are not double-decoded', function() {
expect(esc('a & b')).to.equal('a &amp; b');
expect(esc('&lt;')).to.equal('&amp;lt;');
});
it('escapes quotes used in markup attributes', function() {
expect(esc('<a href="x">')).to.equal('&lt;a href=&quot;x&quot;&gt;');
});
it('leaves plain text without HTML-significant characters unchanged (negative)', function() {
expect(esc('just plain text 123')).to.equal('just plain text 123');
});
it('leaves an empty string unchanged (negative)', function() {
expect(esc('')).to.equal('');
});
});
+19 -1
View File
@@ -17,7 +17,25 @@ const fs = require('fs');
const path = require('path');
const { expect } = require('chai');
const repoRoot = path.resolve(__dirname, '..', '..', '..');
// Find the repo root by walking up from process.cwd()/PWD, NOT from the bare `__dirname`
// global: under `meteor test` referencing `__dirname` makes Meteor inject a
// `const __dirname` that collides with the one its CommonJS module wrapper already
// provides, crashing the whole server bundle at boot with "Identifier '__dirname' has
// already been declared" (same fix as server/lib/tests/dependencies.openapi.tests.js).
function findRepoRoot(marker) {
const seeds = [process.cwd(), process.env.PWD, process.env.OLDPWD].filter(Boolean);
for (const seed of seeds) {
let dir = seed;
for (let i = 0; i < 12; i += 1) {
try { fs.accessSync(path.join(dir, marker)); return dir; } catch (e) { /* walk up */ }
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
return process.cwd();
}
const repoRoot = findRepoRoot(path.join('client', 'components', 'rules', 'triggers', 'boardTriggers.jade'));
const jadePath = path.join(
repoRoot,
'client',
@@ -0,0 +1,55 @@
/* eslint-env mocha */
import { expect } from 'chai';
import { getSecureDOMPurifyConfig, sanitizeHTML } from '/imports/lib/secureDOMPurify';
// Tests for the Admin Panel / Features / Security "Render links as plain text"
// toggle and the underlying XSS hardening. Runs client-side, so DOMPurify has a
// real DOM and sanitizeHTML() exercises the actual sanitizer, not just its config.
describe('secureDOMPurify — render links as plain text + XSS hardening', function() {
// Config-level assertions need no DOM, so they hold in any environment.
describe('getSecureDOMPurifyConfig(options)', function() {
it('forbids the <a> tag only when stripLinks is set', function() {
expect(getSecureDOMPurifyConfig({ stripLinks: true }).FORBID_TAGS).to.include('a');
});
it('does NOT forbid <a> by default (negative)', function() {
expect(getSecureDOMPurifyConfig().FORBID_TAGS).to.not.include('a');
});
it('keeps inner content so a stripped link leaves its visible text', function() {
expect(getSecureDOMPurifyConfig({ stripLinks: true }).KEEP_CONTENT).to.equal(true);
});
});
describe('sanitizeHTML() link handling', function() {
it('drops the <a> wrapper but keeps its visible text when stripLinks is true', function() {
const out = sanitizeHTML('<a href="http://example.com">Company Board</a>', { stripLinks: true });
expect(out).to.not.match(/<a\b/i);
expect(out).to.contain('Company Board');
});
it('keeps a clickable <a> by default (negative)', function() {
const out = sanitizeHTML('<a href="http://example.com">Company Board</a>');
expect(out).to.match(/<a\b/i);
expect(out).to.contain('Company Board');
});
});
describe('sanitizeHTML() XSS hardening (independent of stripLinks)', function() {
it('strips a javascript: href', function() {
const out = sanitizeHTML('<a href="javascript:alert(1)">x</a>').toLowerCase();
expect(out).to.not.contain('javascript:');
});
it('strips event-handler attributes', function() {
const out = sanitizeHTML('<img src="x" onerror="alert(1)">').toLowerCase();
expect(out).to.not.contain('onerror');
});
it('removes <script> tags but keeps surrounding text', function() {
const out = sanitizeHTML('<script>alert(1)</script>hello');
expect(out.toLowerCase()).to.not.contain('<script');
expect(out).to.contain('hello');
});
it('keeps a plain safe paragraph unchanged in spirit (negative)', function() {
const out = sanitizeHTML('<p>just <strong>text</strong></p>');
expect(out).to.contain('just');
expect(out).to.match(/<strong\b/i);
});
});
});
+16 -6
View File
@@ -4,16 +4,26 @@ import { Tracker } from 'meteor/tracker';
import { findWhere, where, uniqBy, groupBy, indexBy, debounce, once } from '/imports/lib/collectionHelpers';
import Settings from '/models/settings';
import Users from '/models/users';
import { computeBoardBackground } from '/models/lib/boardBackground';
export const Utils = {
async setBackgroundImage(url) {
const currentBoard = Utils.getCurrentBoard();
if (currentBoard.backgroundImageURL !== undefined) {
$(".board-wrapper").css({"background":"url(" + currentBoard.backgroundImageURL + ")","background-size":"cover"});
$(".swimlane,.swimlane .list,.swimlane .list .list-body,.swimlane .list:first-child .list-body").css({"background-color":"transparent"});
$(".minicard").css({"opacity": "0.9"});
} else if (currentBoard["background-color"]) {
await currentBoard.setColor(currentBoard["background-color"]);
const bg = computeBoardBackground(currentBoard);
const swimlaneSelector =
".swimlane,.swimlane .list,.swimlane .list .list-body,.swimlane .list:first-child .list-body";
if (bg.type === "image") {
$(".board-wrapper").css({ "background": "url(" + bg.url + ")", "background-size": "cover" });
$(swimlaneSelector).css({ "background-color": "transparent" });
$(".minicard").css({ "opacity": "0.9" });
} else {
// #4978: clear any inline background left over from a previously shown
// board, so switching image -> color or image -> plain (e.g. via the
// favorites bar) actually removes the old image. The board's color, when
// any, is applied reactively via the `.board-wrapper` colorClass.
$(".board-wrapper").css({ "background": "", "background-size": "" });
$(swimlaneSelector).css({ "background-color": "" });
$(".minicard").css({ "opacity": "" });
}
},
/** returns the current board id
+69 -21
View File
@@ -10,6 +10,27 @@ import { Utils } from '/client/lib/utils';
let previousPath;
// On Sandstorm the grain is authenticated by the platform, not by a local WeKan
// account. That login is delivered asynchronously over the DDP connection (via
// connection.setUserId(), which — unlike a normal password login — does NOT set
// Meteor.loggingIn()), so for the first moments after a grain opens Meteor.userId()
// is still null even though the user is, and always will be, authenticated. There
// is no local sign-in page in a grain. useraccounts' ensureSignedIn would bounce the
// grain straight to the (nonexistent) atSignIn route on that brief null window,
// stranding the user on a "Must be logged in" page instead of opening All Boards.
const isSandstorm = Meteor.settings?.public?.sandstorm;
// Drop-in replacement for AccountsTemplates.ensureSignedIn used as a route
// triggersEnter: a no-op on Sandstorm (the platform guarantees authentication and
// the route repopulates reactively once Meteor.userId() lands), and the normal
// useraccounts guard everywhere else.
function ensureSignedInUnlessSandstorm(context, redirect, stop) {
if (isSandstorm) {
return undefined;
}
return AccountsTemplates.ensureSignedIn(context, redirect, stop);
}
FlowRouter.triggers.exit([
({ path }) => {
previousPath = path;
@@ -21,8 +42,11 @@ FlowRouter.triggers.exit([
// redirected to; the chosen view is passed to boardList through the
// `boardListMenu` Session value.
function renderBoardList(ctx, menu) {
// Redirect to sign-in immediately if user is not logged in
if (!Meteor.userId()) {
// Redirect to sign-in immediately if user is not logged in — except on Sandstorm,
// where the platform authenticates asynchronously (see isSandstorm note above) and
// there is no sign-in page. There we render the All Boards list, which reactively
// fills in once the grain login lands, instead of bouncing to atSignIn.
if (!Meteor.userId() && !isSandstorm) {
FlowRouter.go('atSignIn');
return;
}
@@ -48,7 +72,7 @@ function renderBoardList(ctx, menu) {
FlowRouter.route('/', {
name: 'home',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
renderBoardList(this, 'starred');
},
@@ -56,7 +80,7 @@ FlowRouter.route('/', {
FlowRouter.route('/templates', {
name: 'allboards-templates',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
renderBoardList(this, 'templates');
},
@@ -64,7 +88,7 @@ FlowRouter.route('/templates', {
FlowRouter.route('/remaining', {
name: 'allboards-remaining',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
renderBoardList(this, 'remaining');
},
@@ -72,7 +96,7 @@ FlowRouter.route('/remaining', {
FlowRouter.route('/public', {
name: 'public',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -96,7 +120,7 @@ FlowRouter.route('/public', {
FlowRouter.route('/accessibility', {
name: 'accessibility',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -120,7 +144,7 @@ FlowRouter.route('/accessibility', {
FlowRouter.route('/support', {
name: 'support',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -144,7 +168,7 @@ FlowRouter.route('/support', {
FlowRouter.route('/b/:id/:slug/rules', {
name: 'board-rules',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action(params) {
const currentBoard = params.id;
Session.set('currentBoard', currentBoard);
@@ -272,7 +296,7 @@ FlowRouter.route('/shortcuts', {
FlowRouter.route('/b/templates', {
name: 'template-container',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -296,7 +320,7 @@ FlowRouter.route('/b/templates', {
FlowRouter.route('/my-cards', {
name: 'my-cards',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Filter.reset();
Session.set('sortBy', '');
@@ -316,7 +340,7 @@ FlowRouter.route('/my-cards', {
FlowRouter.route('/due-cards', {
name: 'due-cards',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Filter.reset();
Session.set('sortBy', '');
@@ -336,7 +360,7 @@ FlowRouter.route('/due-cards', {
FlowRouter.route('/global-search', {
name: 'global-search',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Filter.reset();
Session.set('sortBy', '');
@@ -371,7 +395,7 @@ FlowRouter.route('/global-search', {
// Mobile Bookmarks page
FlowRouter.route('/bookmarks', {
name: 'bookmarks',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
Filter.reset();
Session.set('sortBy', '');
@@ -409,7 +433,7 @@ FlowRouter.route('/broken-cards', {
FlowRouter.route('/import/:source', {
name: 'import',
triggersEnter: [AccountsTemplates.ensureSignedIn],
triggersEnter: [ensureSignedInUnlessSandstorm],
action(params) {
if (Session.get('currentBoard')) {
Session.set('fromBoard', Session.get('currentBoard'));
@@ -434,7 +458,7 @@ FlowRouter.route('/import/:source', {
FlowRouter.route('/setting', {
name: 'setting',
triggersEnter: [
AccountsTemplates.ensureSignedIn,
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -459,7 +483,7 @@ FlowRouter.route('/setting', {
FlowRouter.route('/information', {
name: 'information',
triggersEnter: [
AccountsTemplates.ensureSignedIn,
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -483,7 +507,7 @@ FlowRouter.route('/information', {
FlowRouter.route('/people', {
name: 'people',
triggersEnter: [
AccountsTemplates.ensureSignedIn,
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -507,7 +531,7 @@ FlowRouter.route('/people', {
FlowRouter.route('/admin-reports', {
name: 'admin-reports',
triggersEnter: [
AccountsTemplates.ensureSignedIn,
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -531,7 +555,7 @@ FlowRouter.route('/admin-reports', {
FlowRouter.route('/attachments', {
name: 'attachments',
triggersEnter: [
AccountsTemplates.ensureSignedIn,
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -555,7 +579,7 @@ FlowRouter.route('/attachments', {
FlowRouter.route('/translation', {
name: 'translation',
triggersEnter: [
AccountsTemplates.ensureSignedIn,
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
@@ -576,6 +600,30 @@ FlowRouter.route('/translation', {
},
});
FlowRouter.route('/admin-features', {
name: 'admin-features',
triggersEnter: [
ensureSignedInUnlessSandstorm,
() => {
Session.set('currentBoard', null);
Session.set('currentList', null);
Session.set('currentCard', null);
Session.set('popupCardId', null);
Session.set('popupCardBoardId', null);
Filter.reset();
Session.set('sortBy', '');
EscapeActions.executeAll();
},
],
action() {
this.render('defaultLayout', {
headerBar: 'settingHeaderBar',
content: 'adminFeatures',
});
},
});
FlowRouter.route('*', {
action() {
this.render('defaultLayout', { content: 'notFound' });
+780
View File
@@ -0,0 +1,780 @@
# WeKan with FerretDB 2 + PostgreSQL (DocumentDB extension)
#
# Use this compose file instead of the default docker-compose.yml when you
# prefer PostgreSQL as the backing store. For the lighter, single-container,
# no-PostgreSQL option, see the default docker-compose.yml (FerretDB v1 with the
# embedded SQLite backend, using the wekan/FerretDB release binaries). For the
# classic MongoDB 7 option, see docker-compose-mongodb-v7.yml.
#
# Supported on all platforms where Node.js 22 runs:
# linux/amd64, linux/arm64, linux/arm/v7, linux/ppc64le, linux/s390x
#
# Based on: docs/Databases/FerretDB2-PostgreSQL.md
#
# Setup:
# docker compose -f docker-compose-ferretdb-v2-postgresql.yml up -d
#
# To stop:
# docker compose -f docker-compose-ferretdb-v2-postgresql.yml stop
services:
# PostgreSQL 17 with the DocumentDB extension (FerretDB 2 backing store).
postgres-documentdb:
image: ghcr.io/ferretdb/postgres-documentdb:17
container_name: wekan-postgres
restart: always
environment:
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}
POSTGRES_DB: ferretdb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- wekan-tier
# FerretDB 2 - MongoDB-compatible protocol layer over PostgreSQL (on :27017).
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2
container_name: wekan-ferretdb
restart: always
environment:
FERRETDB_POSTGRESQL_URL: postgres://ferretdb:${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}@postgres-documentdb/ferretdb
depends_on:
- postgres-documentdb
networks:
- wekan-tier
# Expose only on localhost by default; remove to keep it internal to the network.
ports:
- 127.0.0.1:27017:27017
wekan:
#-------------------------------------------------------------------------------------
# ==== WEKAN FROM GITHUB/QUAY/DOCKER HUB ====
# All of GitHub, Quay and Docker Hub have latest, but because
# latest tag changes when is newest release,
# when upgrading would be better to use version tag.
# a) Using specific version tag is better:
# image: ghcr.io/wekan/wekan:v6.89
# image: quay.io/wekan/wekan:v6.89
# image: wekanteam/wekan:v6.89
# b) GitHub Container registry.
# b1) Latest release tag:
image: ghcr.io/wekan/wekan:latest
# b2) Newest git commit automatic build:
#image: ghcr.io/wekan/wekan:main
# c) Quay:
#image: quay.io/wekan/wekan:latest
# d) Docker Hub:
#image: wekanteam/wekan:latest
#-------------------------------------------------------------------------------------
container_name: wekan-app
# On CentOS 7 there is seccomp issue with glibc 6,
# so CentOS 7 users shoud use these security_opt seccomp:unconfined
# settings to get WeKan working. See:
# - https://github.com/wekan/wekan/issues/4585
# - https://github.com/wekan/wekan/issues/4587
#security_opt:
# - seccomp:unconfined
restart: always
networks:
- wekan-tier
#-------------------------------------------------------------------------------------
# ==== BUILD wekan-app DOCKER CONTAINER FROM SOURCE, if you uncomment these ====
# ==== and use commands: docker compose up -d --build
#build:
# context: .
# dockerfile: Dockerfile
#-------------------------------------------------------------------------------------
ports:
# Docker outsideport:insideport. Do not add anything extra here.
# For example, if you want to have wekan on port 3001,
# use 3001:8080 . Do not add any extra address etc here, that way it does not work.
# remove port mapping if you use nginx reverse proxy, port 8080 is already exposed to wekan-tier network
- 80:8080
environment:
#-------------------- USING MONGODB CHANGE STREAMS WITH REPLICA SETS AT CURRENT DATABASE --------------------
# If you would not like to use Change Streams and replica set for improving speed, change to use polling:
#- METEOR_REACTIVITY_ORDER=polling
# https://forums.meteor.com/t/meteor-3-5-beta-change-streams-performance-improvements/64461#change-streams-setup-3
# https://github.com/meteor/meteor/blob/release-3.5/v3-docs/docs/performance/change-streams-observer-driver.md#choosing-the-reactivity-driver-order
# Keep oplog/polling fallback available for deployments that cannot use Change Streams:
# https://github.com/wekan/wekan/issues/6307#issuecomment-4299349231
# FerretDB has no MongoDB change streams / replica-set oplog, so use polling:
- METEOR_REACTIVITY_ORDER=polling
- DDP_TRANSPORT=uws
#- DDP_TRANSPORT=sockjs
#-----------------------------------------------------------------
# ==== WRITEABLE PATH FOR FILE UPLOADS ====
- WRITABLE_PATH=/data
#-----------------------------------------------------------------
# ==== AWS S3 FOR FILES ====
# Any region. For example:
# us-standard,us-west-1,us-west-2,
# eu-west-1,eu-central-1,
# ap-southeast-1,ap-northeast-1,sa-east-1
#
#- S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "region": "xxx"}}'
#- S3_SECRET_FILE=/run/secrets/s3_secret
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://ferretdb:${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}@ferretdb:27017/wekan?authMechanism=PLAIN
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
# Change ROOT_URL to your real Wekan URL, for example:
# If you have Caddy/Nginx/Apache providing SSL
# - https://example.com
# - https://boards.example.com
# This can be problematic with avatars https://github.com/wekan/wekan/issues/1776
# - https://example.com/wekan
# If without https, can be only wekan node, no need for Caddy/Nginx/Apache if you don't need them
# - http://example.com
# - http://boards.example.com
# - http://192.168.1.100 <=== using at local LAN
- ROOT_URL=http://localhost # <=== using only at same laptop/desktop where Wekan is installed
#---------------------------------------------------------------
# ==== EMAIL SETTINGS ====
# Email settings are only at MAIL_URL and MAIL_FROM.
# Admin Panel has test button, but it's not used for settings.
# see https://github.com/wekan/wekan/blob/main/docs/Email/Troubleshooting-Mail.md
# For SSL in email, change smtp:// to smtps://
# NOTE: Special characters need to be url-encoded in MAIL_URL.
# You can encode those characters for example at: https://www.urlencoder.org
#- MAIL_URL=smtp://user:pass@mailserver.example.com:25/
- MAIL_URL=smtp://<mail_url>:25/?ignoreTLS=true&tls={rejectUnauthorized:false}
- MAIL_FROM=Wekan Notifications <noreply.wekan@mydomain.com>
# Currently MAIL_SERVICE is not in use.
#- MAIL_SERVICE=Outlook365
#- MAIL_SERVICE_USER=firstname.lastname@hotmail.com
#- MAIL_SERVICE_PASSWORD=SecretPassword
#- MAIL_SERVICE_PASSWORD_FILE=/run/secrets/mail_service_password
#---------------------------------------------------------------
# https://github.com/wekan/wekan/issues/3585#issuecomment-1021522132
# Add more Node heap, this is done by default at Dockerfile:
# - NODE_OPTIONS="--max_old_space_size=4096"
# Add more stack, this is done at Dockerfile:
# bash -c "ulimit -s 65500; exec node --stack-size=65500 main.js"
#---------------------------------------------------------------
# ==== OPTIONAL: MONGO OPLOG SETTINGS =====
# https://github.com/wekan/wekan-mongodb/issues/2#issuecomment-378343587
# HIGHLY RECOMMENDED for pub/sub performance!
# MongoDB oplog is used by Meteor for real-time data synchronization.
# Without oplog, Meteor falls back to polling which increases:
# - CPU usage by 3-5x
# - Network traffic significantly
# - Latency from 50ms to 2000ms
# Must configure MongoDB replica set first
# See: https://blog.meteor.com/tuning-meteor-mongo-livedata-for-scalability-13fe9deb8908
# FerretDB emulates OpLog via --replication-set-name=rs0.
# HIGHLY RECOMMENDED — enables real-time DDP updates (sub-100ms latency).
#- MONGO_OPLOG_URL=mongodb://wekan-ferretdb:27017/local?replicaSet=rs0
# MONGO_OPLOG_URL intentionally unset: FerretDB v1 has no replica-set oplog (WeKan uses polling).
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
# https://github.com/meteor/meteor-apm-agent
# https://blog.meteor.com/kadira-apm-is-now-open-source-490469ffc85f
#- APM_OPTIONS_ENDPOINT=http://<kadira-ip>:11011
#- APM_APP_ID=
#- APM_APP_SECRET=
#---------------------------------------------------------------
# ==== OPTIONAL: LOGS AND STATS ====
# https://github.com/wekan/wekan/blob/main/docs/Features/Logs.md
#
# Daily export of Wekan changes as JSON to Logstash and ElasticSearch / Kibana (ELK)
# https://github.com/wekan/wekan-logstash
#
# Statistics Python script for Wekan Dashboard
# https://github.com/wekan/wekan-stats
#
# Console, file, and zulip logger on database changes https://github.com/wekan/wekan/pull/1010
# with fix to replace console.log by winston logger https://github.com/wekan/wekan/pull/1033
# but there could be bug https://github.com/wekan/wekan/issues/1094
#
# There is Feature Request: Logging date and time of all activity with summary reports,
# and requesting reason for changing card to other column https://github.com/wekan/wekan/issues/1598
#---------------------------------------------------------------
# ==== NUMBER OF SEARCH RESULTS PER PAGE BY DEFAULT ====
#- RESULTS_PER_PAGE=20
#---------------------------------------------------------------
# ==== AFTER OIDC LOGIN, ADD USERS AUTOMATICALLY TO THIS BOARD ID ====
# https://github.com/wekan/wekan/pull/5098
#- DEFAULT_BOARD_ID=abcd1234
#---------------------------------------------------------------
# ==== WEKAN API AND EXPORT BOARD ====
# Wekan Export Board works when WITH_API=true.
# https://github.com/wekan/wekan/blob/main/docs/API/REST-API.md
# https://github.com/wekan/wekan-gogs
# If you disable Wekan API with false, Export Board does not work.
- WITH_API=true
#---------------------------------------------------------------
# ==== PASSWORD BRUTE FORCE PROTECTION ====
#https://atmospherejs.com/lucasantoniassi/accounts-lockout
#Defaults below. Uncomment to change. wekan/server/accounts-lockout.js
#- ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE=3
#- ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD=60
#- ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW=15
#- ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE=3
#- ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD=60
#- ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW=15
#---------------------------------------------------------------
# ==== ACCOUNT OPTIONS ====
# https://docs.meteor.com/api/accounts-multi.html#AccountsCommon-config
# Defaults below. Uncomment to change. wekan/server/accounts-common.js
# - ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90
#---------------------------------------------------------------
# ==== RICH TEXT EDITOR IN CARD COMMENTS ====
# https://github.com/wekan/wekan/pull/2560
- RICHER_CARD_COMMENT_EDITOR=false
#---------------------------------------------------------------
# ==== CARD LOADING: all (default) or lazy ====
# 'all' = load every card of a board into the browser (default).
# 'lazy' = each list loads only the cards currently visible
# (infinite scroll) plus a live count, so boards with
# thousands of cards use less memory/bandwidth.
# Also changeable at runtime in Admin Panel / Features.
- CARDS_LOADING=all
#---------------------------------------------------------------
# ==== CARD OPENED, SEND WEBHOOK MESSAGE ====
# https://github.com/wekan/wekan/issues/2518
- CARD_OPENED_WEBHOOK_ENABLED=false
#---------------------------------------------------------------
# ==== Allow configuration to validate uploaded attachments ====
#-ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM=/usr/local/bin/avscan {file}
#-ATTACHMENTS_UPLOAD_MIME_TYPES=image/*,text/*
#-ATTACHMENTS_UPLOAD_MAX_SIZE=5000000
#---------------------------------------------------------------
# ==== Allow configuration to validate uploaded avatars ====
#-AVATARS_UPLOAD_EXTERNAL_PROGRAM=/usr/local/bin/avscan {file}
#-AVATARS_UPLOAD_MIME_TYPES=image/*
#-AVATARS_UPLOAD_MAX_SIZE=500000
#---------------------------------------------------------------
# ==== Allow to shrink attached/pasted image ====
# https://github.com/wekan/wekan/pull/2544
#- MAX_IMAGE_PIXEL=1024
#- IMAGE_COMPRESS_RATIO=80
#---------------------------------------------------------------
# ==== NOTIFICATION TRAY AFTER READ DAYS BEFORE REMOVE =====
# Number of days after a notification is read before we remove it.
# Default: 2
#- NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE=2
#---------------------------------------------------------------
# ==== BIGEVENTS DUE ETC NOTIFICATIONS =====
# https://github.com/wekan/wekan/pull/2541
# Introduced a system env var BIGEVENTS_PATTERN default as "NONE",
# so any activityType matches the pattern, system will send out
# notifications to all board members no matter they are watching
# or tracking the board or not. Owner of the wekan server can
# disable the feature by setting this variable to "NONE" or
# change the pattern to any valid regex. i.e. '|' delimited
# activityType names.
# a) Example
#- BIGEVENTS_PATTERN=due
# b) All
#- BIGEVENTS_PATTERN=received|start|due|end
# c) Disabled
- BIGEVENTS_PATTERN=NONE
#---------------------------------------------------------------
# ==== NOTIFY ON ASSIGN =====
# Notify the user directly when they are added as a card member or
# assignee. Set false to disable.
#- NOTIFY_ON_ASSIGN=true
#---------------------------------------------------------------
# ==== EMAIL DUE DATE NOTIFICATION =====
# https://github.com/wekan/wekan/pull/2536
# System timelines will be showing any user modification for
# dueat startat endat receivedat, also notification to
# the watchers and if any card is due, about due or past due.
#
# Notify due days, default is None, 2 days before and on the event day
#- NOTIFY_DUE_DAYS_BEFORE_AND_AFTER=2,0
#
# Notify due at hour of day. Default every morning at 8am. Can be 0-23.
# If env variable has parsing error, use default. Notification sent to watchers.
#- NOTIFY_DUE_AT_HOUR_OF_DAY=8
#-----------------------------------------------------------------
# ==== EMAIL NOTIFICATION TIMEOUT, ms =====
# Default: 30000 ms = 30s
#- EMAIL_NOTIFICATION_TIMEOUT=30000
#-----------------------------------------------------------------
# ==== CORS =====
# CORS: Set Access-Control-Allow-Origin header.
#- CORS=*
# CORS_ALLOW_HEADERS: Set Access-Control-Allow-Headers header. "Authorization,Content-Type" is required for cross-origin use of the API.
#- CORS_ALLOW_HEADERS=Authorization,Content-Type
# CORS_EXPOSE_HEADERS: Set Access-Control-Expose-Headers header. This is not needed for typical CORS situations
#- CORS_EXPOSE_HEADERS=*
#-----------------------------------------------------------------
# ==== MATOMO INTEGRATION ====
# Optional: Integration with Matomo https://matomo.org that is installed to your server
# The address of the server where Matomo is hosted.
#- MATOMO_ADDRESS=https://example.com/matomo
# The value of the site ID given in Matomo server for Wekan
#- MATOMO_SITE_ID=1
# The option do not track which enables users to not be tracked by matomo
#- MATOMO_DO_NOT_TRACK=true
# The option that allows matomo to retrieve the username:
#- MATOMO_WITH_USERNAME=true
#-----------------------------------------------------------------
# ==== BROWSER POLICY AND TRUSTED IFRAME URL ====
# Enable browser policy and allow one trusted URL that can have iframe that has Wekan embedded inside.
# Setting this to false is not recommended, it also disables all other browser policy protections
# and allows all iframing etc. See wekan/server/policy.js
- BROWSER_POLICY_ENABLED=true
# When browser policy is enabled, HTML code at this Trusted URL can have iframe that embeds Wekan inside.
#- TRUSTED_URL=https://intra.example.com
#-----------------------------------------------------------------
# ==== METRICS ALLOWED IP ADDRESSES ====
# https://github.com/wekan/wekan/blob/main/docs/Features/Metrics.md
#- METRICS_ALLOWED_IP_ADDRESSES=192.168.0.100,192.168.0.200
#-----------------------------------------------------------------
# ==== OUTGOING WEBHOOKS ====
# What to send to Outgoing Webhook, or leave out. If commented out the default values will be: cardId,listId,oldListId,boardId,comment,user,card,commentId,swimlaneId,customerField,customFieldValue
#- WEBHOOKS_ATTRIBUTES=cardId,listId,oldListId,boardId,comment,user,card,board,list,swimlane,commentId
#-----------------------------------------------------------------
# ==== Debug OIDC OAuth2 etc ====
#- DEBUG=true
#---------------------------------------------
# ==== AUTOLOGIN WITH OIDC/OAUTH2 ====
# https://github.com/wekan/wekan/blob/main/docs/Login/autologin.md
#- OIDC_REDIRECTION_ENABLED=true
# OIDC RP-initiated logout endpoint (end_session_endpoint). When set, the
# "Log Out" menu redirects to this endpoint so the identity provider session
# is also ended and the user is returned to Wekan (ROOT_URL) via
# post_logout_redirect_uri, instead of landing on the provider home page
# (which errors for non-admin users). See https://github.com/wekan/wekan/issues/6158
# Keycloak example:
#- OAUTH2_LOGOUT_ENDPOINT=/realms/<keycloak realm>/protocol/openid-connect/logout
#-----------------------------------------------------------------
# ==== OAUTH2 ORACLE on premise identity manager OIM ====
#- ORACLE_OIM_ENABLED=true
#-----------------------------------------------------------------
# ==== OAUTH2 AZURE ====
# https://github.com/wekan/wekan/blob/main/docs/Login/Azure/Azure.md
# 1) Register the application with Azure. Make sure you capture
# the application ID as well as generate a secret key.
# 2) Configure the environment variables. This differs slightly
# by installation type, but make sure you have the following:
#- OAUTH2_ENABLED=true
# Optional OAuth2 CA Cert, see https://github.com/wekan/wekan/issues/3299
#- OAUTH2_CA_CERT=ABCD1234
# Use OAuth2 ADFS additional changes. Also needs OAUTH2_ENABLED=true setting.
#- OAUTH2_ADFS_ENABLED=false
# Azure AD B2C. https://github.com/wekan/wekan/issues/5242
#- OAUTH2_B2C_ENABLED=false
# SECURITY (GHSA-mp7g-hj5q-gxhq): Link an OIDC login to a pre-existing
# Wekan account (password/LDAP) with the same email. OFF by default; when
# false an OIDC login whose email already exists is rejected instead of
# merged, preventing account takeover via spoofed email claims. Only
# enable if you fully trust your OIDC provider's email claims, and even
# then the provider must send email_verified=true for the merge to happen.
#- OAUTH2_MERGE_EXISTING_USERS=false
# When false, OAuth2/OIDC login is refused for users that do not already
# have a Wekan account (matched by verified email).
#- OAUTH2_AUTO_REGISTRATION=true
# Comma/space separated OAuth2/OIDC group names whose members become Wekan
# admins. Empty = off.
#- OAUTH2_ADMIN_GROUPS=
# OAuth2 login style: popup or redirect.
#- OAUTH2_LOGIN_STYLE=redirect
# Application GUID captured during app registration:
#- OAUTH2_CLIENT_ID=xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx
# Secret key generated during app registration:
#- OAUTH2_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#- OAUTH2_SECRET_FILE=/run/secrets/oauth2_secret
#- OAUTH2_SERVER_URL=https://login.microsoftonline.com/
#- OAUTH2_AUTH_ENDPOINT=/oauth2/v2.0/authorize
#- OAUTH2_USERINFO_ENDPOINT=https://graph.microsoft.com/oidc/userinfo
#- OAUTH2_TOKEN_ENDPOINT=/oauth2/v2.0/token
# The claim name you want to map to the unique ID field:
#- OAUTH2_ID_MAP=email
# The claim name you want to map to the username field:
#- OAUTH2_USERNAME_MAP=email
# The claim name you want to map to the full name field:
#- OAUTH2_FULLNAME_MAP=name
# The claim name you want to map to the email field:
#- OAUTH2_EMAIL_MAP=email
#-----------------------------------------------------------------
# ==== OAUTH2 Nextcloud ====
# 1) Register the application with Nextcloud: https://your.nextcloud/index.php/settings/admin/security
# Make sure you capture the application ID as well as generate a secret key.
# Use https://your.wekan/_oauth/oidc for the redirect URI.
# 2) Configure the environment variables. This differs slightly
# by installation type, but make sure you have the following:
#- OAUTH2_ENABLED=true
# OAuth2 login style: popup or redirect.
#- OAUTH2_LOGIN_STYLE=redirect
# Application GUID captured during app registration:
#- OAUTH2_CLIENT_ID=xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx
# Secret key generated during app registration:
#- OAUTH2_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#- OAUTH2_SECRET_FILE=/run/secrets/oauth2_secret
#- OAUTH2_SERVER_URL=https://your-nextcloud.tld
#- OAUTH2_AUTH_ENDPOINT=/index.php/apps/oauth2/authorize
#- OAUTH2_USERINFO_ENDPOINT=/ocs/v2.php/cloud/user?format=json
#- OAUTH2_TOKEN_ENDPOINT=/index.php/apps/oauth2/api/v1/token
# The claim name you want to map to the unique ID field:
#- OAUTH2_ID_MAP=id
# The claim name you want to map to the username field:
#- OAUTH2_USERNAME_MAP=id
# The claim name you want to map to the full name field:
#- OAUTH2_FULLNAME_MAP=display-name
# The claim name you want to map to the email field:
#- OAUTH2_EMAIL_MAP=email
#-----------------------------------------------------------------
# ==== OAUTH2 KEYCLOAK ====
# https://github.com/wekan/wekan/blob/main/docs/Login/Keycloak/Keycloak.md <== MAPPING INFO, REQUIRED
#- OAUTH2_ENABLED=true
# OAuth2 login style: popup or redirect.
#- OAUTH2_LOGIN_STYLE=redirect
#- OAUTH2_CLIENT_ID=<Keycloak create Client ID>
#- OAUTH2_SERVER_URL=<Keycloak server url - https://keycloak.example.com>
#- OAUTH2_AUTH_ENDPOINT=/realms/<keycloak realm>/protocol/openid-connect/auth
#- OAUTH2_USERINFO_ENDPOINT=/realms/<keycloak realm>/protocol/openid-connect/userinfo
#- OAUTH2_TOKEN_ENDPOINT=/realms/<keycloak realm>/protocol/openid-connect/token
#- OAUTH2_SECRET=<keycloak client secret>
#- OAUTH2_SECRET_FILE=/run/secrets/oauth2_secret
#- OAUTH2_ID_MAP=sub
#- OAUTH2_USERNAME_MAP=preferred_username
#- OAUTH2_EMAIL_MAP=email
#- OAUTH2_FULLNAME_MAP=name
#-----------------------------------------------------------------
# ==== OAUTH2 DOORKEEPER ====
# https://github.com/wekan/wekan/issues/1874
# https://github.com/wekan/wekan/blob/main/docs/Login/OAuth2.md2
# Enable the OAuth2 connection
#- OAUTH2_ENABLED=true
# OAuth2 docs: https://github.com/wekan/wekan/blob/main/docs/Login/OAuth2.md
# OAuth2 login style: popup or redirect.
#- OAUTH2_LOGIN_STYLE=redirect
# OAuth2 Client ID.
#- OAUTH2_CLIENT_ID=abcde12345
# OAuth2 Secret.
#- OAUTH2_SECRET=54321abcde
#- OAUTH2_SECRET_FILE=/run/secrets/oauth2_secret
# OAuth2 Server URL.
#- OAUTH2_SERVER_URL=https://chat.example.com
# OAuth2 Authorization Endpoint.
#- OAUTH2_AUTH_ENDPOINT=/oauth/authorize
# OAuth2 Userinfo Endpoint.
#- OAUTH2_USERINFO_ENDPOINT=/oauth/userinfo
# OAuth2 Token Endpoint.
#- OAUTH2_TOKEN_ENDPOINT=/oauth/token
# OAUTH2 ID Token Whitelist Fields.
#- OAUTH2_ID_TOKEN_WHITELIST_FIELDS=""
# OAUTH2 Request Permissions.
#- OAUTH2_REQUEST_PERMISSIONS=openid profile email
# OAuth2 ID Mapping
#- OAUTH2_ID_MAP=
# OAuth2 Username Mapping
#- OAUTH2_USERNAME_MAP=
# OAuth2 Fullname Mapping
#- OAUTH2_FULLNAME_MAP=
# OAuth2 Email Mapping
#- OAUTH2_EMAIL_MAP=
#-----------------------------------------------------------------
# ==== LDAP: UNCOMMENT ALL TO ENABLE LDAP ====
# https://github.com/wekan/wekan/blob/main/docs/Login/LDAP.md
# For Snap settings see https://github.com/wekan/wekan/blob/main/docs/Platforms/FOSS/Snap/Supported-settings-keys.md
# Most settings work both on Snap and Docker below.
# Note: Do not add single quotes '' to variables. Having spaces still works without quotes where required.
#
# The default authentication method used if a user does not exist to create and authenticate. Can be set as ldap.
# (this is set properly in the Admin Panel, changing this item does not remove Password login option)
#- DEFAULT_AUTHENTICATION_METHOD=ldap
#
# Enable or not the connection by the LDAP
#- LDAP_ENABLE=true
#
# The port of the LDAP server
#- LDAP_PORT=389
#
# The host server for the LDAP server
#- LDAP_HOST=localhost
#
#-----------------------------------------------------------------
# ==== LDAP AD Simple Auth ====
#
# Set to true, if you want to connect with Active Directory by Simple Authentication.
# When using AD Simple Auth, LDAP_BASEDN is not needed.
#
# Example:
#- LDAP_AD_SIMPLE_AUTH=true
#
# === LDAP User Authentication ===
#
# a) Option to login to the LDAP server with the user's own username and password, instead of
# an administrator key. Default: false (use administrator key).
#
# b) When using AD Simple Auth, set to true, when login user is used for binding,
# and LDAP_BASEDN is not needed.
#
# Example:
#- LDAP_USER_AUTHENTICATION=true
#
# Which field is used to find the user for the user authentication. Default: uid.
#- LDAP_USER_AUTHENTICATION_FIELD=uid
#
# === LDAP Default Domain ===
#
# a) In case AD SimpleAuth is configured, the default domain is appended to the given
# loginname for creating the correct username for the bind request to AD.
#
# b) The default domain of the ldap it is used to create email if the field is not map
# correctly with the LDAP_SYNC_USER_DATA_FIELDMAP
#
# Example :
#- LDAP_DEFAULT_DOMAIN=mydomain.com
#
#-----------------------------------------------------------------
# ==== LDAP BASEDN Auth ====
#
# The base DN for the LDAP Tree
#- LDAP_BASEDN=ou=user,dc=example,dc=org
#
#-----------------------------------------------------------------
# Fallback on the default authentication method
#- LDAP_LOGIN_FALLBACK=false
#
# Reconnect to the server if the connection is lost
#- LDAP_RECONNECT=true
#
# Overall timeout, in milliseconds
#- LDAP_TIMEOUT=10000
#
# Specifies the timeout for idle LDAP connections in milliseconds
#- LDAP_IDLE_TIMEOUT=10000
#
# Connection timeout, in milliseconds
#- LDAP_CONNECT_TIMEOUT=10000
#
# If the LDAP needs a user account to search
#- LDAP_AUTHENTIFICATION=true
#
# The search user DN - You need quotes when you have spaces in parameters
# 2 examples:
#- LDAP_AUTHENTIFICATION_USERDN=CN=ldap admin,CN=users,DC=domainmatter,DC=lan
#- LDAP_AUTHENTIFICATION_USERDN=CN=wekan_adm,OU=serviceaccounts,OU=admin,OU=prod,DC=mydomain,DC=com
#
# The password for the search user
#- LDAP_AUTHENTIFICATION_PASSWORD=pwd
#- LDAP_AUTHENTIFICATION_PASSWORD_FILE=/run/secrets/ldap_auth_password
#
# Enable logs for the module
#- LDAP_LOG_ENABLED=true
#
# If the sync of the users should be done in the background
#- LDAP_BACKGROUND_SYNC=false
#
# LDAP_BACKGROUND_SYNC_INTERVAL : At which interval does the background task sync in milliseconds
# The format must be as specified in:
# https://bunkat.github.io/later/parsers.html#text
#- LDAP_BACKGROUND_SYNC_INTERVAL=every 1 hours
# At which interval does the background task sync in milliseconds.
# Leave this unset, so it uses default, and does not crash.
# https://github.com/wekan/wekan/issues/2354#issuecomment-515305722
- LDAP_BACKGROUND_SYNC_INTERVAL=''
#
#- LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false
#
#- LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false
#
# LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS : When true, the LDAP
# background sync disables users no longer present in LDAP (and re-enables
# them when they reappear).
#- LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false
#
# If using LDAPS: LDAP_ENCRYPTION=ssl
#- LDAP_ENCRYPTION=false
#
# The certification for the LDAPS server. Certificate needs to be included in this docker compose.yml file.
#- LDAP_CA_CERT=-----BEGIN CERTIFICATE-----MIIE+G2FIdAgIC...-----END CERTIFICATE-----
#
# Reject Unauthorized Certificate
#- LDAP_REJECT_UNAUTHORIZED=false
#
# Optional extra LDAP filters. Don't forget the outmost enclosing parentheses if needed
#- LDAP_USER_SEARCH_FILTER=
#
# base (search only in the provided DN), one (search only in the provided DN and one level deep), or sub (search the whole subtree)
#- LDAP_USER_SEARCH_SCOPE=one
#
# Which field is used to find the user, like uid / sAMAccountName
#- LDAP_USER_SEARCH_FIELD=sAMAccountName
#
# Used for pagination (0=unlimited)
#- LDAP_SEARCH_PAGE_SIZE=0
#
# The limit number of entries (0=unlimited)
#- LDAP_SEARCH_SIZE_LIMIT=0
#
# Enable the login restriction group filter. Note the authenticated ldap user must be able to query all relevant group data with own login data from ldap.
# When true, only members of LDAP_GROUP_FILTER_GROUP_NAME are allowed to log in.
# NOTE: This flag ONLY controls the login restriction. Admin status sync
# (LDAP_SYNC_ADMIN_STATUS / LDAP_SYNC_ADMIN_GROUPS) and group->role sync
# (LDAP_SYNC_GROUP_ROLES) query LDAP groups independently and do NOT require
# this flag to be true. The group filter metadata below
# (LDAP_GROUP_FILTER_OBJECTCLASS, LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE,
# LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT, LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE)
# must still be configured for any group search to work.
#- LDAP_GROUP_FILTER_ENABLE=false
#
# The object class for filtering. Example: group
#- LDAP_GROUP_FILTER_OBJECTCLASS=
#
# The attribute of a group identifying it. Example: cn
#- LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE=
#
# The attribute inside a group object listing its members. Example: member
#- LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE=
#
# The format of the value of LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE. Example: 'dn' if the users dn is saved as value into the attribute.
#- LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT=
#
# The group name (id) that matches all users.
#- LDAP_GROUP_FILTER_GROUP_NAME=
#
# LDAP_UNIQUE_IDENTIFIER_FIELD : This field is sometimes class GUID (Globally Unique Identifier). Example: guid
#- LDAP_UNIQUE_IDENTIFIER_FIELD=
#
# LDAP_UTF8_NAMES_SLUGIFY : Convert the username to utf8
#- LDAP_UTF8_NAMES_SLUGIFY=true
#
# LDAP_USERNAME_FIELD : Which field contains the ldap username. username / sAMAccountName
#- LDAP_USERNAME_FIELD=sAMAccountName
#
# LDAP_FULLNAME_FIELD : Which field contains the ldap fullname. fullname / sAMAccountName
#- LDAP_FULLNAME_FIELD=fullname
#
#- LDAP_MERGE_EXISTING_USERS=false
#
# Allow existing account matching by e-mail address when username does not match
#- LDAP_EMAIL_MATCH_ENABLE=true
#
# LDAP_EMAIL_MATCH_REQUIRE : require existing account matching by e-mail address when username does match
#- LDAP_EMAIL_MATCH_REQUIRE=true
#
# LDAP_EMAIL_MATCH_VERIFIED : require existing account email address to be verified for matching
#- LDAP_EMAIL_MATCH_VERIFIED=true
#
# LDAP_EMAIL_FIELD : which field contains the LDAP e-mail address
#- LDAP_EMAIL_FIELD=mail
#-----------------------------------------------------------------
#- LDAP_SYNC_USER_DATA=false
#
#- LDAP_SYNC_USER_DATA_FIELDMAP={"cn":"name", "mail":"email"}
#
#- LDAP_SYNC_GROUP_ROLES=
#
# The default domain of the ldap it is used to create email if the field is not map correctly
# with the LDAP_SYNC_USER_DATA_FIELDMAP is defined in setting LDAP_DEFAULT_DOMAIN above.
#
# Enable/Disable syncing of admin status based on ldap groups:
# NOTE: Admin status sync and group->role sync (LDAP_SYNC_GROUP_ROLES) query
# LDAP groups on their own. They no longer require LDAP_GROUP_FILTER_ENABLE=true,
# which only controls the login restriction filter. The group filter metadata
# (LDAP_GROUP_FILTER_OBJECTCLASS, LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE,
# LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT, LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE)
# must still be configured for the group search to work.
#- LDAP_SYNC_ADMIN_STATUS=true
#
# Comma separated list of admin group names to sync.
#- LDAP_SYNC_ADMIN_GROUPS=group1,group2
#
# When true, sync a user's LDAP groups as Wekan Organizations.
#- LDAP_SYNC_ORGANIZATIONS=false
#
# Comma separated allowlist of LDAP group names to sync as Organizations. Empty = all of the user's groups.
#- LDAP_SYNC_ORGANIZATIONS_GROUPS=
#
# When true, sync a user's LDAP groups as Wekan Teams.
#- LDAP_SYNC_TEAMS=false
#
# Comma separated allowlist of LDAP group names to sync as Teams. Empty = all of the user's groups.
#- LDAP_SYNC_TEAMS_GROUPS=
#---------------------------------------------------------------------
# Login to LDAP automatically with HTTP header.
# In below example for siteminder, at right side of = is header name.
#- HEADER_LOGIN_ID=HEADERUID
#- HEADER_LOGIN_FIRSTNAME=HEADERFIRSTNAME
#- HEADER_LOGIN_LASTNAME=HEADERLASTNAME
#- HEADER_LOGIN_EMAIL=HEADEREMAILADDRESS
# SECURITY (GHSA-jggc-qvfc-jr6x): comma-separated allowlist of source IPs
# permitted to use header login. The source IP is the real TCP peer of the
# connection (your reverse proxy), NOT the spoofable X-Forwarded-For header.
# REQUIRED when header login is enabled: if empty/unset, header login fails
# CLOSED and authenticates no one.
#- HEADER_LOGIN_TRUSTED_IPS=127.0.0.1,10.0.0.2
# Optional: if WeKan sits behind MULTIPLE proxy hops, list the intermediate
# proxy IPs here. X-Forwarded-For is then honored ONLY when the immediate
# TCP peer is one of these trusted proxies, and the right-most hop that is
# not itself a trusted proxy (the real client) is matched against
# HEADER_LOGIN_TRUSTED_IPS above.
#- HEADER_LOGIN_TRUSTED_PROXIES=10.0.0.1,10.0.0.2
#---------------------------------------------------------------------
# ==== LOGOUT TIMER, probably does not work yet ====
# LOGOUT_WITH_TIMER : Enables or not the option logout with timer
# example : LOGOUT_WITH_TIMER=true
#- LOGOUT_WITH_TIMER=
#
# LOGOUT_IN : The number of days
# example : LOGOUT_IN=1
#- LOGOUT_IN=
#
# LOGOUT_ON_HOURS : The number of hours
# example : LOGOUT_ON_HOURS=9
#- LOGOUT_ON_HOURS=
#
# LOGOUT_ON_MINUTES : The number of minutes
# example : LOGOUT_ON_MINUTES=55
#- LOGOUT_ON_MINUTES=
#-------------------------------------------------------------------
# Hide password login form
# - PASSWORD_LOGIN_ENABLED=true
#-------------------------------------------------------------------
#- CAS_ENABLED=true
#- CAS_BASE_URL=https://cas.example.com/cas
#- CAS_LOGIN_URL=https://cas.example.com/login
#- CAS_VALIDATE_URL=https://cas.example.com/cas/p3/serviceValidate
#---------------------------------------------------------------------
#- SAML_ENABLED=true
#- SAML_PROVIDER=
#- SAML_ENTRYPOINT=
#- SAML_ISSUER=
#- SAML_CERT=
#- SAML_IDPSLO_REDIRECTURL=
#- SAML_PRIVATE_KEYFILE=
#- SAML_PUBLIC_CERTFILE=
#- SAML_IDENTIFIER_FORMAT=
#- SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE=
#- SAML_ATTRIBUTES=
#---------------------------------------------------------------------
# Wait spinner to use
# - WAIT_SPINNER=Bounce
#---------------------------------------------------------------------
# Wait for MongoDB to be healthy (replica-set primary elected) before
# starting WeKan, so index creation does not crash with "Topology is closed".
# If you use FerretDB instead, comment out the wekandb block below and
# uncomment the wekan-ferretdb block.
depends_on:
ferretdb:
condition: service_started
# wekan-migrate:
# condition: service_completed_successfully
# wekan-ferretdb:
# condition: service_healthy
volumes:
- /etc/localtime:/etc/localtime:ro
- wekan-files:/data:rw
volumes:
wekan-files:
driver: local
postgres-data:
driver: local
networks:
wekan-tier:
driver: bridge
-82
View File
@@ -1,82 +0,0 @@
# WeKan with FerretDB 2 + PostgreSQL (DocumentDB extension)
#
# Use this compose file instead of docker-compose.yml on platforms where
# MongoDB Community Edition is not available, or when you prefer PostgreSQL
# as the backing store.
#
# Supported on all platforms where Node.js 22 runs:
# linux/amd64, linux/arm64, linux/arm/v7, linux/ppc64le, linux/s390x
#
# Based on: docs/Databases/FerretDB2-PostgreSQL.md
#
# Setup:
# docker compose -f docker-compose-ferretdb.yml up -d
#
# To stop:
# docker compose -f docker-compose-ferretdb.yml stop
version: '3.8'
services:
# PostgreSQL 17 with DocumentDB extension (FerretDB 2 backing store)
# ghcr.io/ferretdb/postgres-documentdb is the official PostgreSQL image
# pre-installed with the pg_documentdb extension required by FerretDB 2.
postgres-documentdb:
image: ghcr.io/ferretdb/postgres-documentdb:17
restart: always
environment:
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}
POSTGRES_DB: ferretdb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- wekan-ferretdb
# FerretDB 2 — MongoDB-compatible protocol layer over PostgreSQL
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2
restart: always
environment:
FERRETDB_POSTGRESQL_URL: postgres://ferretdb:${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}@postgres-documentdb/ferretdb
ports:
# Expose the MongoDB-compatible port only on localhost for security.
# Remove "127.0.0.1:" prefix if WeKan runs on a different host.
- 127.0.0.1:27017:27017
depends_on:
- postgres-documentdb
networks:
- wekan-ferretdb
# WeKan application
wekan-app:
image: ghcr.io/wekan/wekan:latest
restart: always
networks:
- wekan-ferretdb
ports:
- 8080:8080
environment:
# FerretDB speaks the MongoDB wire protocol on port 27017.
# The database name "wekan" is created automatically on first run.
MONGO_URL: mongodb://ferretdb:${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}@ferretdb:27017/wekan?authMechanism=PLAIN
ROOT_URL: ${ROOT_URL:-http://localhost:8080}
PORT: 8080
WITH_API: true
WRITABLE_PATH: /data
# Mail settings (optional)
MAIL_URL: ${MAIL_URL:-}
MAIL_FROM: ${MAIL_FROM:-}
volumes:
- wekan-data:/data
depends_on:
- ferretdb
volumes:
postgres-data:
wekan-data:
networks:
wekan-ferretdb:
driver: bridge
File diff suppressed because it is too large. Load diff
-2
View File
@@ -36,8 +36,6 @@
# to a fresh value (5003, 5004, …). The MongoDB service and the
# reverse proxy in front of WeKan don't need any change.
version: '3.8'
services:
# ---------------------------------------------------------------------
+87 -356
View File
@@ -1,278 +1,77 @@
# Note: Do not add single quotes '' to variables. Having spaces still works without quotes where required.
#---------------------------------------------------------------------------------------------------------
# ==== CREATING USERS AND LOGGING IN TO WEKAN ====
# https://github.com/wekan/wekan/blob/main/docs/Login/Adding-users.md
#---------------------------------------------------------------------------------------------------------
# ==== FORGOT PASSWORD ====
# https://github.com/wekan/wekan/blob/main/docs/Login/Forgot-Password.md
#---------------------------------------------------------------------------------------------------------
# ==== Upgrading Wekan to new version =====
# NOTE: MongoDB has changed from 3.x to 4.x, in that case you need backup/restore with --noIndexRestore
# see https://github.com/wekan/wekan/tree/main/docs/Backup
# 1) Stop Wekan:
# docker compose stop
# 2) Remove old Wekan app (wekan-app only, not that wekan-db container that has all your data)
# docker rm wekan-app
# 3) Get newest docker-compose.yml from https://github.com/wekan/wekan to have correct image,
# for example: "image: quay.io/wekan/wekan" or version tag "image: quay.io/wekan/wekan:v4.52"
# 4) Start Wekan:
# docker compose up -d
#----------------------------------------------------------------------------------
# ==== OPTIONAL: DEDICATED DOCKER USER ====
# 1) Optionally create a dedicated user for Wekan, for example:
# sudo useradd -d /home/wekan -m -s /bin/bash wekan
# 2) Add this user to the docker group, then logout+login or reboot:
# sudo usermod -aG docker wekan
# 3) Then login as user wekan.
# 4) Create this file /home/wekan/docker-compose.yml with your modifications.
#----------------------------------------------------------------------------------
# ==== RUN DOCKER AS SERVICE ====
# 1a) Running Docker as service, on Systemd like Debian 9, Ubuntu 16.04, CentOS 7:
# sudo systemctl enable docker
# sudo systemctl start docker
# 1b) Running Docker as service, on init.d like Debian 8, Ubuntu 14.04, CentOS 6:
# sudo update-rc.d docker defaults
# sudo service docker start
# ----------------------------------------------------------------------------------
# ==== USAGE OF THIS docker-compose.yml ====
# 1) For seeing does Wekan work, try this and check with your web browser:
# docker compose up
# 2) Stop Wekan and start Wekan in background:
# docker compose stop
# docker compose up -d
# 3) See running Docker containers:
# docker ps
# 4) Stop Docker containers:
# docker compose stop
# ----------------------------------------------------------------------------------
# ===== INSIDE DOCKER CONTAINERS, AND BACKUP/RESTORE ====
# https://github.com/wekan/wekan/tree/main/docs/Backup
# If really necessary, repair MongoDB: https://github.com/wekan/wekan-mongodb/issues/6#issuecomment-424004116
# 1) Going inside containers:
# a) Wekan app, does not contain data
# docker exec -it wekan-app bash
# b) MongoDB, contains all data
# docker exec -it wekan-db bash
# 2) Copying database to outside of container:
# docker exec -it wekan-db bash
# cd /data
# mongodump
# exit
# docker cp wekan-db:/data/dump .
# 3) Restoring database
# # 1) Stop wekan
# docker stop wekan-app
# # 2) Go inside database container
# docker exec -it wekan-db bash
# # 3) and data directory
# cd /data
# # 4) Remove previous dump
# rm -rf dump
# # 5) Exit db container
# exit
# # 6) Copy dump to inside docker container
# docker cp dump wekan-db:/data/
# # 7) Go inside database container
# docker exec -it wekan-db bash
# # 8) and data directory
# cd /data
# # 9) Restore
# mongorestore --drop
# # 10) Exit db container
# exit
# # 11) Start wekan
# docker start wekan-app
#-------------------------------------------------------------------------
# This docker-compose.yml uses FerretDB v1 with SQLite from
# https://github.com/wekan/FerretDB by default.
#
# WeKan with FerretDB v1 + embedded SQLite (no PostgreSQL, no MongoDB)
#
# This is the DEFAULT WeKan compose file (docker-compose.yml): a light,
# self-contained database — FerretDB v1 (the wekan/FerretDB fork,
# https://github.com/wekan/FerretDB) with its embedded pure-Go SQLite backend,
# which speaks the MongoDB wire protocol. There is no separate PostgreSQL or
# MongoDB server — FerretDB stores everything in SQLite files on a volume, and
# WeKan stores attachments/avatars on the filesystem.
# For the classic MongoDB 7 option instead, see docker-compose-mongodb-v7.yml;
# for the PostgreSQL-backed FerretDB 2 option, see
# docker-compose-ferretdb-v2-postgresql.yml.
#
# The FerretDB binary is downloaded (once, then cached on the volume) from the
# newest wekan/FerretDB release: https://github.com/wekan/FerretDB/releases
# Each platform is a separate ferretdb-<arch> asset there; the container downloads
# only the one matching its architecture (amd64, arm64, ppc64le, s390x, riscv64, ...).
#
# Setup (this is the default docker-compose.yml, so no -f is needed):
# docker compose up -d
#
# To stop:
# docker compose stop
services:
#-------------------------------------------------------------------------------------
# ==== DATABASE: FerretDB 2 + PostgreSQL 16 ====
# FerretDB is a MongoDB-compatible proxy that stores data in PostgreSQL.
# It supports the full MongoDB wire protocol used by Meteor/WeKan,
# including OpLog via replication-set emulation (--replication-set-name=rs0).
#
# Upgrade path from MongoDB:
# The wekan-migrate service below runs ONCE before wekan starts.
# It copies all data from an existing MongoDB instance (if reachable at
# SOURCE_MONGO_URL) to FerretDB, upgrades the schema to v8.43, and
# moves GridFS / CollectionFS binary files to the filesystem.
# Progress is visible in a browser while migration runs.
#
# If you are upgrading from a MongoDB-based install:
# 1. Keep your old MongoDB container running (or start it separately).
# 2. Set SOURCE_MONGO_URL in the wekan-migrate environment below.
# 3. Run: docker compose up -d
# 4. Watch migration at http://localhost:8080/ until "completed".
# 5. Shut down old MongoDB once satisfied.
#
# If this is a fresh install, wekan-migrate exits instantly (nothing to migrate).
#-------------------------------------------------------------------------------------
#
#wekan-postgres:
# image: postgres:16-alpine
# container_name: wekan-postgres
# restart: always
# networks:
# - wekan-tier
# expose:
# - 5432
# environment:
# - POSTGRES_DB=wekan
# - POSTGRES_USER=wekan
# - POSTGRES_PASSWORD=wekan
# # Tune for a kanban workload (low connection count, moderate write rate)
# - POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C
# volumes:
# - /etc/localtime:/etc/localtime:ro
# - wekan-postgres:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U wekan -d wekan"]
# interval: 5s
# timeout: 5s
# retries: 10
#
#wekan-ferretdb:
# image: ghcr.io/ferretdb/ferretdb:2
# container_name: wekan-ferretdb
# restart: always
# networks:
# - wekan-tier
# expose:
# - 27017
# environment:
# # FerretDB stores everything in PostgreSQL
# - FERRETDB_POSTGRESQL_URL=postgresql://wekan:wekan@wekan-postgres:5432/wekan
# # Emulate MongoDB replica-set for Meteor OpLog (real-time updates)
# - FERRETDB_REPLICATION_SET_NAME=rs0
# # Suppress verbose debug output
# - FERRETDB_LOG_LEVEL=warn
# depends_on:
# wekan-postgres:
# condition: service_healthy
# healthcheck:
# test: ["CMD", "ferretdb", "--version"]
# interval: 5s
# timeout: 5s
# retries: 10
#
#-------------------------------------------------------------------------------------
# ==== MIGRATION SERVICE ====
# Runs once, before WeKan starts. Exits 0 on success, 1 on fatal error.
# WeKan only starts after this service exits successfully
# (depends_on condition: service_completed_successfully).
#
# On a FRESH install (no SOURCE_MONGO_URL or source unreachable) it exits
# immediately — no penalty for new deployments.
#
# SOURCE_MONGO_URL: point to your old MongoDB container/host.
# e.g. mongodb://wekandb:27017/wekan
# mongodb://192.168.1.10:27017/wekan
# Leave it unset or empty to skip migration.
#-------------------------------------------------------------------------------------
#wekan-migrate:
# image: node:24.17.0-slim
# container_name: wekan-migrate
# restart: "no"
# networks:
# - wekan-tier
# working_dir: /migrate
# # Install the mongodb driver then run the migration script.
# # npm ci is used if package-lock.json exists; npm install otherwise.
# command: >
# sh -c "
# cd /migrate &&
# npm install --omit=dev --prefer-offline 2>/dev/null || npm install --omit=dev &&
# node migrate-mongodb-to-ferretdb.mjs
# "
# environment:
# # ---- Source (old MongoDB) ----
# # Set to your old MongoDB URL to trigger data migration.
# # Leave empty for a fresh install (migration is skipped automatically).
# - SOURCE_MONGO_URL=
# #- SOURCE_MONGO_URL=mongodb://wekandb:27017/wekan
#
# # ---- Target (FerretDB) ----
# - TARGET_MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
#
# # ---- File storage ----
# # Must match the WRITABLE_PATH used by the wekan service.
# - WRITABLE_PATH=/data
#
# # ---- Progress dashboard ----
# # Browse to http://localhost:8080/ while migration runs.
# - MIGRATION_PORT=8080
#
# # ---- Tuning (optional) ----
# #- BATCH_SIZE=200
# #- DRY_RUN=true # Log only, no writes — useful for testing
# #- FORCE_MIGRATE=true # Re-run even if already marked complete
# ports:
# - 8080:8080
# depends_on:
# wekan-ferretdb:
# condition: service_healthy
# volumes:
# - /etc/localtime:/etc/localtime:ro
# - wekan-files:/data:rw
# - ./releases:/migrate:ro
#-------------------------------------------------------------------------------------
# ==== LEGACY MONGODB (commented out) ====
# Uncomment to keep old MongoDB running alongside FerretDB during migration.
# After migration is verified, remove this block entirely.
#-------------------------------------------------------------------------------------
wekandb:
image: mongo:7
container_name: wekan-db
# FerretDB v1 with the embedded SQLite backend (MongoDB-compatible on :27017).
# A tiny Debian container fetches the wekan/FerretDB v1 release binary for its
# architecture (cached on the volume so restarts do not re-download) and runs it.
# SQLite data lives in /data/sqlite on the ferretdb-data volume.
ferretdb:
image: debian:bookworm-slim
container_name: wekan-ferretdb
restart: always
#command: mongod --logpath /dev/null --storageEngine wiredTiger --wiredTigerCacheSizeGB 32 --timeZoneInfo /usr/share/zoneinfo --setParameter logicalSessionRefreshMillis=900000 --setParameter localLogicalSessionTimeoutMinutes=45 --oplogSize 20480 --replSet rs0 --quiet
#command: mongod --storageEngine wiredTiger --wiredTigerCacheSizeGB 32 --timeZoneInfo /usr/share/zoneinfo --setParameter logicalSessionRefreshMillis=900000 --setParameter localLogicalSessionTimeoutMinutes=45 --oplogSize 20480 --replSet rs0 --quiet
command: >
sh -c '
mongod --storageEngine wiredTiger --wiredTigerCacheSizeGB 32 --timeZoneInfo /usr/share/zoneinfo --setParameter logicalSessionRefreshMillis=900000 --setParameter localLogicalSessionTimeoutMinutes=45 --oplogSize 20480 --replSet rs0 --bind_ip_all --quiet &
until mongosh --host 127.0.0.1 --quiet --eval "
try {
const status = rs.status();
quit(status.ok === 1 ? 0 : 1);
} catch (e) {
if (
e.codeName === \"NotYetInitialized\" ||
e.message.includes(\"no replset config has been received\") ||
e.message.includes(\"not yet initialized\")
) {
rs.initiate({
_id: \"rs0\",
members: [{ _id: 0, host: \"wekandb:27017\" }]
});
quit(1);
}
quit(1);
}
" >/dev/null 2>&1; do
sleep 2
done
wait
'
environment:
DO_NOT_TRACK: "1"
FERRETDB_TELEMETRY: disable
# Pin a specific release instead of the newest by setting e.g.
# FERRETDB_RELEASE=download/v1.24.2
FERRETDB_RELEASE: ${FERRETDB_RELEASE:-latest/download}
command:
- sh
- -c
- |
set -eu
BIN=/data/bin/ferretdb
if [ ! -x "$$BIN" ]; then
echo "Fetching FerretDB v1 from the wekan/FerretDB release ..."
apt-get update -qq
apt-get install -y -qq --no-install-recommends curl ca-certificates >/dev/null
arch="$$(dpkg --print-architecture)"
case "$$arch" in ppc64el) arch=ppc64le ;; esac
mkdir -p /data/bin
# Download only this arch's binary (per-arch release assets, no ferretdb.zip).
curl -fSL --retry 5 -o "$$BIN" \
"https://github.com/wekan/FerretDB/releases/$${FERRETDB_RELEASE}/ferretdb-$${arch}"
chmod +x "$$BIN"
fi
mkdir -p /data/files/db
exec "$$BIN" \
--handler=sqlite \
--sqlite-url=file:/data/files/db/ \
--listen-addr=0.0.0.0:27017 \
--telemetry=disable \
--log-level=error
volumes:
- ferretdb-data:/data
networks:
- wekan-tier
expose:
- 27017
# Healthcheck: report healthy only once the replica set has an elected
# primary, so the wekan app (depends_on: condition: service_healthy) does
# not start before MongoDB is ready and crash with "Topology is closed".
# start_period gives a large database time to replay its journal after an
# upgrade before failures are counted.
healthcheck:
test: ["CMD", "mongosh", "--host", "127.0.0.1", "--port", "27017", "--quiet", "--eval", "try { quit(rs.status().ok === 1 && db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
interval: 10s
timeout: 10s
retries: 30
start_period: 60s
volumes:
- /etc/localtime:/etc/localtime:ro
- wekan-db:/data/db
- wekan-db-dump:/dump
# Expose only on localhost by default; remove to keep it internal to the network.
ports:
- 127.0.0.1:27017:27017
wekan:
#-------------------------------------------------------------------------------------
@@ -326,9 +125,12 @@ services:
# https://github.com/meteor/meteor/blob/release-3.5/v3-docs/docs/performance/change-streams-observer-driver.md#choosing-the-reactivity-driver-order
# Keep oplog/polling fallback available for deployments that cannot use Change Streams:
# https://github.com/wekan/wekan/issues/6307#issuecomment-4299349231
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
- DDP_TRANSPORT=uws
#- DDP_TRANSPORT=sockjs
# FerretDB has no MongoDB change streams / replica-set oplog, so use polling:
- DEFAULT_METEOR_REACTIVITY_ORDER=polling
- METEOR_REACTIVITY_ORDER=polling
# uws does not work at s390x. sockjs works.
#- DDP_TRANSPORT=uws
- DDP_TRANSPORT=sockjs
#-----------------------------------------------------------------
# ==== WRITEABLE PATH FOR FILE UPLOADS ====
- WRITABLE_PATH=/data
@@ -344,7 +146,7 @@ services:
#-----------------------------------------------------------------
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
#- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
- MONGO_URL=mongodb://wekandb:27017/wekan
- MONGO_URL=mongodb://ferretdb:27017/wekan
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
#---------------------------------------------------------------
# ==== ROOT_URL SETTING ====
@@ -395,7 +197,7 @@ services:
# FerretDB emulates OpLog via --replication-set-name=rs0.
# HIGHLY RECOMMENDED — enables real-time DDP updates (sub-100ms latency).
#- MONGO_OPLOG_URL=mongodb://wekan-ferretdb:27017/local?replicaSet=rs0
- MONGO_OPLOG_URL=mongodb://wekandb:27017/local?replicaSet=rs0
# MONGO_OPLOG_URL intentionally unset: FerretDB v1 has no replica-set oplog (WeKan uses polling).
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
@@ -454,6 +256,14 @@ services:
# https://github.com/wekan/wekan/pull/2560
- RICHER_CARD_COMMENT_EDITOR=false
#---------------------------------------------------------------
# ==== CARD LOADING: all (default) or lazy ====
# 'all' = load every card of a board into the browser (default).
# 'lazy' = each list loads only the cards currently visible
# (infinite scroll) plus a live count, so boards with
# thousands of cards use less memory/bandwidth.
# Also changeable at runtime in Admin Panel / Features.
- CARDS_LOADING=all
#---------------------------------------------------------------
# ==== CARD OPENED, SEND WEBHOOK MESSAGE ====
# https://github.com/wekan/wekan/issues/2518
- CARD_OPENED_WEBHOOK_ENABLED=false
@@ -976,8 +786,8 @@ services:
# If you use FerretDB instead, comment out the wekandb block below and
# uncomment the wekan-ferretdb block.
depends_on:
wekandb:
condition: service_healthy
ferretdb:
condition: service_started
# wekan-migrate:
# condition: service_completed_successfully
# wekan-ferretdb:
@@ -985,92 +795,13 @@ services:
volumes:
- /etc/localtime:/etc/localtime:ro
- wekan-files:/data:rw
# Secrets are optional. Uncomment below and ensure files exist to use them.
#secrets:
# - ldap_auth_password
# - oauth2_secret
# - mail_service_password
# - mongo_password
# - s3_secret
#---------------------------------------------------------------------------------
# ==== OPTIONAL: SHARE DATABASE TO OFFICE LAN AND REMOTE VPN ====
# When using Wekan both at office LAN and remote VPN:
# 1) Have above Wekan docker container config with LAN IP address
# 2) Copy all of above wekan container config below, look above of this part above and all config below it,
# before above depends_on: part:
#
# wekan:
# #-------------------------------------------------------------------------------------
# # ==== MONGODB AND METEOR VERSION ====
# # a) For Wekan Meteor 1.8.x version at meteor-1.8 branch, .....
#
#
# and change name to different name like wekan2 or wekanvpn, and change ROOT_URL to server VPN IP
# address.
# 3) This way both Wekan containers can use same MongoDB database
# and see the same Wekan boards.
# 4) You could also add 3rd Wekan container for 3rd network etc.
# EXAMPLE:
# wekan2:
# ....COPY CONFIG FROM ABOVE TO HERE...
# environment:
# - ROOT_URL='http://10.10.10.10'
# ...COPY CONFIG FROM ABOVE TO HERE...
#---------------------------------------------------------------------------------
# OPTIONAL NGINX CONFIG FOR REVERSE PROXY
# nginx:
# image: nginx
# container_name: nginx
# restart: always
# networks:
# - wekan-tier
# depends_on:
# - wekan
# ports:
# - 80:80
# - 443:443
# volumes:
# - ./nginx/ssl:/etc/nginx/ssl/:ro
# - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
## Alternative volume config:
## volumes:
## - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
## - ./nginx/ssl/ssl.conf:/etc/nginx/conf.d/ssl/ssl.conf:ro
## - ./nginx/ssl/testvm-ehu.crt:/etc/nginx/conf.d/ssl/certs/mycert.crt:ro
## - ./nginx/ssl/testvm-ehu.key:/etc/nginx/conf.d/ssl/certs/mykey.key:ro
## - ./nginx/ssl/pphrase:/etc/nginx/conf.d/ssl/pphrase:ro
volumes:
wekan-files:
driver: local
#wekan-postgres:
# driver: local
# Legacy MongoDB volumes — kept so existing data survives until migration is done.
# Remove once migration is verified and old MongoDB is decommissioned.
wekan-db:
driver: local
wekan-db-dump:
ferretdb-data:
driver: local
networks:
wekan-tier:
driver: bridge
# Docker Compose Secrets
# Create secret files on the host system before running docker-compose up
# Example: echo "your_password_here" > ldap_auth_password.txt
# Then use: docker-compose up -d
# Secrets are optional. Uncomment to enable and ensure files exist at provided paths.
#secrets:
# ldap_auth_password:
# file: ./secrets/ldap_auth_password.txt
# oauth2_secret:
# file: ./secrets/oauth2_secret.txt
# mail_service_password:
# file: ./secrets/mail_service_password.txt
# mongo_password:
# file: ./secrets/mongo_password.txt
# s3_secret:
# file: ./secrets/s3_secret.txt
+1 -1
View File
@@ -4,7 +4,7 @@
## WeKan
- Alternatively, use wekan-app Docker container from https://raw.githubusercontent.com/wekan/wekan/refs/heads/main/docker-compose.yml
- Alternatively, use the ready-made FerretDB 2 + PostgreSQL compose file from https://raw.githubusercontent.com/wekan/wekan/refs/heads/main/docker-compose-ferretdb-v2-postgresql.yml (the default docker-compose.yml uses FerretDB v1 with SQLite instead)
```
git clone --branch main --depth 1 https://github.com/wekan/wekan.git
@@ -1,5 +1,3 @@
version: '2'
# Note: Do not add single quotes '' to variables. Having spaces still works without quotes where required.
#---------------------------------------------------------------------------------------------------------
# ==== CREATING USERS AND LOGGING IN TO WEKAN ====
@@ -0,0 +1,209 @@
# Alternative Architectures, JavaScript Engines and Rewrite Options
This page documents which CPU architectures WeKan can run on, **why** some
architectures are not supported, and what the realistic options are for
supporting more of them — alternative JavaScript engines and full server
rewrites (Go, Tcl/Tk, QuickJS, …).
It exists because the question keeps coming up: *"WeKan won't run on my 32-bit
ARM board / old x86 / ReactOS — can we fix that?"* The short answer is that the
limit is **Node.js**, not the database, and getting past it means either
compiling Node from source (where still possible) or rewriting the server in a
portable language.
## TL;DR
- WeKan's **server** is Meteor 3, which runs **only on Node.js 24**. The browser
client is architecture-independent. So the supported-architecture list is
exactly *"where is there a Node.js 24 build?"*.
- The **database is not the bottleneck**: [FerretDB v1](https://github.com/wekan/FerretDB)
(pure Go, SQLite backend, MongoDB wire protocol) cross-compiles to far more
architectures than Node does.
- **Node.js 24 dropped 32-bit**: no `armv7l`/`armhf` binaries (downgraded to
*Experimental*, source-build only), no 32-bit x86 Linux (`i386`) at all.
- **Other JS runtimes do not help**: Deno and Bun cover *fewer* architectures
than Node (x64/arm64 only). Portable engines (QuickJS, JavaScriptCore,
SpiderMonkey, JVM-based Rhino/GraalJS) *do* run on 32-bit, but **none of them
run Meteor/WeKan** — Meteor is locked to Node.
- If universal architecture coverage is the goal, a **Go server** (matching
FerretDB) is the best target; **Tcl/Tk and QuickJS are worse fits** for a web
app. All of these are large rewrites, not drop-in swaps.
## The two layers
Keep these separate — they have very different portability:
| Layer | Technology | Portability |
| --- | --- | --- |
| Client | HTML/CSS/JS in the browser | Architecture-independent (runs wherever a browser runs) |
| **Server** | **Meteor 3 → Node.js 24** | **Only where a Node.js 24 build exists** |
| Database | MongoDB *or* FerretDB v1 (Go) | MongoDB: amd64/arm64 only. FerretDB: almost everything |
Only the **server binary** (the Node.js runtime) constrains the architecture.
## Architecture support matrix
"Node 24" is what limits WeKan; "FerretDB v1" shows the database is available
well beyond that. Sources:
[nodejs.org/dist/latest-v24.x](https://nodejs.org/dist/latest-v24.x/) (official),
[unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/)
(extra arches), and [wekan/FerretDB releases](https://github.com/wekan/FerretDB/releases).
| Arch | Node.js 24 | MongoDB | FerretDB v1 | WeKan runs? |
| --- | --- | --- | --- | --- |
| amd64 (x64) | ✅ official | ✅ | ✅ | ✅ |
| arm64 (aarch64) | ✅ official | ✅ | ✅ | ✅ |
| ppc64le | ✅ official | ❌ | ✅ | ✅ (FerretDB) |
| s390x | ✅ official | ❌ | ✅ | ✅ (FerretDB) |
| riscv64 | ✅ unofficial (glibc) | ❌ | ✅ | ✅ (FerretDB) |
| **loong64** | ✅ unofficial (glibc) | ❌ | ✅ | ⚠️ binaries exist, but **not buildable in CI** — see below |
| **armhf / armv7l** | ❌ (source-build only, Experimental) | ❌ | ✅ | ❌ (no Node binary) |
| **armel (armv5)** | ❌ | ❌ | ✅ | ❌ (no Node binary) |
| **i386 (32-bit x86 Linux)** | ❌ (port removed) | ❌ | ✅ | ❌ (no Node binary) |
### Why `armhf` / `armv7l` is not supported
`armhf` = `armv7l` = 32-bit ARMv7 hard-float (the same thing; the `l` is
little-endian; Debian calls it `armhf`, Docker calls it `linux/arm/v7`).
Node.js **downgraded ARMv7 to "Experimental" in v24** ("Downgraded as of Node.js
24" in Node's `BUILDING.md`). That means the source still compiles for ARMv7, but
**no binaries are published or tested**. Neither nodejs.org nor
unofficial-builds ship an `armv7l` tarball for v24, and NodeSource does not ship
`armhf` for the 24.x line. Distributions (Debian, Alpine) build Node from source
for `armhf`, but their versions lag behind 24.x and are their own (often musl)
builds, not the portable glibc tarball WeKan's self-contained bundle embeds.
→ The only way to get an `armhf`/`armv7l` WeKan bundle is to **compile Node 24
from source** in CI (slow under emulation, Experimental tier, unsupported).
### Why `i386` (32-bit x86 Linux) is not supported
Node.js **removed the 32-bit x86 Linux port** years ago. There is no binary and
no practical source build. So 32-bit x86 Linux cannot run current WeKan at all,
regardless of effort. (FerretDB has an `i386` binary, but there is nothing to run
the WeKan server with.)
### Why `loong64` is not built in CI (even though binaries exist)
LoongArch64 has both a Node 24 unofficial-builds binary **and** a FerretDB v1
binary, so in principle WeKan could run on it. But the release workflow cannot
**build** the bundle/Docker image for it:
1. **QEMU cannot emulate LoongArch.** `docker/setup-qemu-action`
([tonistiigi/binfmt](https://github.com/tonistiigi/binfmt)) supports
`amd64, arm64, riscv64, ppc64le, s390x, 386, arm/v7, arm/v6` — **no
loong64**. The extra-arch bundles rebuild native modules (e.g. bcrypt) by
running an emulated container of the target arch; with no emulator, that step
is impossible.
2. **No official base image.** Ubuntu/Debian do not publish an official
`linux/loong64` container image (LoongArch is a debian-ports / community
architecture), so there is nothing to build inside.
FerretDB's own image builds for `linux/loong64` only because Go **cross-compiles**
from the amd64 builder (it never runs loong64 code at build time). WeKan's native
`.node` addons would instead need a real LoongArch build environment — a
`loongarch64-linux-gnu` cross-toolchain plus a (non-mainstream) loong64 runtime
image. That is significantly more work than the riscv64 path and is why loong64
is currently left out.
## Alternative JavaScript engines
The engine itself (V8) *can* target 32-bit — the problem is that the **Node.js
project stopped shipping those binaries** and **Meteor is bound to Node**.
Swapping the engine does not run Meteor.
| Engine / runtime | armhf / armv7 | i386 | Runs Meteor/WeKan? |
| --- | --- | --- | --- |
| **Node.js 24** (V8) | source-build only (exp.) | ❌ | — (current) |
| **Deno** (V8, Rust) | ❌ x64/arm64 only | ❌ | ❌ |
| **Bun** (JavaScriptCore, Zig) | ❌ x64/arm64 only | ❌ | ❌ |
| **QuickJS** (Bellard, pure C) | ✅ any arch | ✅ | ❌ (no Node API / npm) |
| **Duktape / MuJS / JerryScript** | ✅ | ✅ | ❌ (ES5, embeddable) |
| **JavaScriptCore / SpiderMonkey** | ✅ (WebKit/Firefox build there) | ✅ | ❌ |
| **Rhino / GraalJS** (on a JVM) | ✅ (OpenJDK armhf/i386) | ✅ | ❌ |
Two takeaways:
- The modern Node alternatives, **Deno and Bun, are *worse*** for architecture
coverage (x64/arm64 only) — they do not help with 32-bit.
- Several engines (**QuickJS, JavaScriptCore, SpiderMonkey, JVM-based**) *do* run
on `armhf`/`i386`, but **none run Meteor/WeKan**. Meteor requires Node.js
specifically (V8 + libuv + Node's native APIs + Meteor's Node-based build), so
using another engine means rewriting the server for that runtime — as large a
job as a language rewrite.
## Full server rewrite options
If the goal is to run on architectures Node cannot reach, the server has to be
rewritten in a portable language. The browser client stays as-is; the new server
just serves the SPA, the API, reactive sync, accounts and methods, talking to
FerretDB/MongoDB.
### Go (recommended for portability)
- **Cross-compiles everywhere** — `i386`, `armv7`, `armhf`, `armel`, `loong64`,
`mips`, … exactly as [FerretDB](https://github.com/wekan/FerretDB) already does,
as a single static binary with no Node-ABI or native-module problems.
- Speaks the **MongoDB wire protocol natively** to FerretDB/MongoDB.
- Mature HTTP/WebSocket ecosystem for the API and reactive sync.
- **Cost:** this is effectively a **different product** — Meteor's DDP, reactive
pub/sub, accounts, methods and client sync all have to be rebuilt. Very large
effort.
### QuickJS (the "keep JavaScript" option)
- Pure C, compiles on virtually **any architecture**, tiny footprint.
- **Cost:** no npm ecosystem and no Node API, so the server is written against a
minimal runtime — about as much work as the Go rewrite, with a smaller
ecosystem and weaker performance. Niche.
### Tcl/Tk (not recommended for this)
- **Tcl** is extremely portable (pure C, builds on any arch: `i386`, `armv7`,
`armhf`, `armv5`, `mips`, …).
- **But Tk is a desktop GUI toolkit**, not a web-server stack. WeKan is a
**web application** (browser SPA + server + database); Tk would produce a
native desktop window — a completely different product and UX.
- Tcl *can* run a web server (NaviServer/AOLserver, tclhttpd, Wapp), but it is a
niche stack with no reactive-SPA story. For a web app this is a **step
backwards** compared to Go.
### Comparison
| Option | Arch coverage | Effort | Fit for WeKan (web app) |
| --- | --- | --- | --- |
| Compile Node 24 from source (armv7 only) | + armv7 (not i386) | Medium, fragile, Experimental | Keeps Meteor; no rewrite |
| **Go server** | Universal (like FerretDB) | Very large (full rewrite) | Best; static binary, native FerretDB |
| QuickJS server | Universal | Very large | OK; keeps JS, small ecosystem |
| Tcl/Tk | Universal (Tcl) | Very large | Poor; GUI/scripting, not a web stack |
## Hardware / OS notes
These are about the **FerretDB (Go) binary** on specific targets; remember the
WeKan *server* still needs a Node.js 24 build, which is the actual blocker above.
- **ODroid-U3 (ARMv7-A, Linux)** — FerretDB `armhf` (GOARM=7) runs fine. The
board has enough RAM for FerretDB v1; only the missing Node 24 `armhf` binary
stops WeKan itself.
- **Raspberry Pi OS 32-bit** — FerretDB `armhf` (v7) runs on Pi 2/3/4 (ARMv7);
Pi 1/Zero (ARMv6) need the `armel` (GOARM=5) binary. Again, the WeKan server is
blocked only by Node.
- **ReactOS 32-bit** — the matching binary would be FerretDB `win32`
(windows/386), but it is **doubtful**: Go 1.21+ targets Windows 10 / Server
2016-level APIs, while ReactOS is around XP/2003, so Go's runtime may use APIs
ReactOS does not implement. (On 32-bit *Linux* the `linux/386` FerretDB binary
works.)
## Conclusion
- The architecture limit is **Node.js**, not FerretDB. FerretDB (Go) already runs
on 32-bit ARM, 32-bit x86 and more.
- With current tooling, the practical WeKan set is **amd64, arm64, ppc64le,
s390x, riscv64**. `loong64` is blocked by tooling (no QEMU emulation, no base
image), not by Node/FerretDB. `armhf`/`armel`/`i386` are blocked by Node.
- Alternative JS engines do **not** unblock this — Deno/Bun cover fewer arches,
and portable engines cannot run Meteor.
- The only way to universal architecture coverage is a **portable-language server
rewrite**, for which **Go is the best fit** (matching FerretDB), QuickJS the
"keep JS" niche option, and Tcl/Tk a poor fit for a web application.
@@ -55,28 +55,32 @@ cd wekan
Because it downgrades @meteorjs/rspack and breaks builds.
### 3. Select option 1 to install dependencies, and then Enter.
### 3. Install dependencies, build WeKan, and run the dev server
`rebuild-wekan.sh` shows a two-level menu. The top level is `1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit`; pick a category number, then the item number inside it (each submenu also has a `Back` entry).
```
# Setup -> Install dependencies
./rebuild-wekan.sh
1 # Setup
1 # Install dependencies
1
# Setup -> Build WeKan
./rebuild-wekan.sh
1 # Setup
2 # Build WeKan
2
# Dev server -> localhost:3000
./rebuild-wekan.sh
3
2 # Dev server
1 # localhost:3000
```
That does:
- 1 install dependencies,
- 2 builds wekan
- 3 starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically.
- **Setup -> Install dependencies** installs dependencies,
- **Setup -> Build WeKan** builds wekan,
- **Dev server -> localhost:3000** starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically. If a dev server is already running on that port, it is stopped first and a fresh one started on the same port.
And then [register and login](../Login/Adding-users.md) with webbrowser at http://localhost:4000
And then [register and login](../Login/Adding-users.md) with webbrowser at http://localhost:3000
### 4. Make and test your changes
+37 -20
View File
@@ -78,23 +78,40 @@ git clone git@github.com:YourGithubUsername/wekan.git
cd wekan
```
### 3. Select option 1 to install dependencies, and then Enter.
### 3. Run the menu: Setup -> Install dependencies, then Setup -> Build WeKan, then Dev server -> localhost:3000
`rebuild-wekan.sh` shows a two-level menu. The top level is:
```
./rebuild-wekan.sh
1
./rebuild-wekan.sh
2
./rebuild-wekan.sh
3
1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit
```
That does: 1 install dependencies, 2 builds wekan, 3 starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically and reload webbroser. But, still sometimes, it may need stopping with Ctrl-c and full build with option 2.
And then [register and login](../Login/Adding-users.md) at http://localhost:4000
Pick a category number, then the item number inside it (each submenu also has a `Back` entry to return to the top level). To build from source and run it:
```
# Install dependencies: Setup -> Install dependencies
./rebuild-wekan.sh
1 # Setup
1 # Install dependencies
# Build WeKan: Setup -> Build WeKan
./rebuild-wekan.sh
1 # Setup
2 # Build WeKan
# Run in development mode: Dev server -> localhost:3000
./rebuild-wekan.sh
2 # Dev server
1 # localhost:3000
```
The **Setup -> Build WeKan** step compiles WeKan, and **Dev server -> localhost:3000** starts it in development mode with the `meteor` command, so it detects file changes and tries to rebuild automatically and reload the web browser. Still, sometimes it may need stopping with Ctrl-c and a full **Setup -> Build WeKan** again.
If a dev server is already running, the **Dev server** options now stop it automatically before starting a fresh one — freeing **both** the app port and the rspack dev server's port `8080` (a leftover rspack dev server on 8080 otherwise makes the new server crash with `Error: listen EADDRINUSE ... :8080`), so you don't have to kill the old processes yourself.
The other **Dev server** options run WeKan the same way on different addresses/ports: `localhost:3000 + trace warnings`, `localhost:3000 + bundle visualizer`, `CURRENT-IP:3000`, `CURRENT-IP:3000 + MONGO_URL 27019`, and `CUSTOM-IP:PORT` (which asks you for the IP and port). There is also a **Kill all dev servers** option that frees every dev/test port the script uses at once (3000/3001, 3100/3101, 4000/4001 and 8080) — handy if a previous run left something behind.
And then [register and login](../Login/Adding-users.md) at http://localhost:3000
### OPTIONAL, NOT NEEDED: 5. Add new plugin package
```
@@ -145,19 +162,19 @@ Change to Meteor 3 branch:
```
git checkout feature-meteor3
```
Build wekan:
Build wekan (Setup -> Build WeKan):
```
./rebuild-wekan.sh
2
1 # Setup
2 # Build WeKan
```
If there are errors, try to fix them.
Or try to run wekan:
Or try to run wekan (Dev server -> localhost:3000):
```
./rebuild-wekan.sh
3
2 # Dev server
1 # localhost:3000
```
# Updating
+63
View File
@@ -65,6 +65,69 @@ prevents board admins from editing or deleting other users' comments — only a
author may edit or delete it. Enforced server-side. See
[Comment replies and editing restriction](../Comment-Replies-And-Editing-Restriction.md).
## Features tab
**Admin Panel → Features** groups optional, instance-wide capabilities into
categories in a side menu. Every toggle is a global setting, saved immediately when
clicked, and defaults to **off** (current behaviour) unless noted.
### Performance
- **Card loading** — `all` (every card is loaded into the browser, the default) or
`lazy` (each list loads only the currently visible cards plus a live count, for
very large boards). Also set by the `CARDS_LOADING` environment variable.
### Security
Rich-text rendering hardening:
- **Render links as plain text** (`renderLinksAsPlainText`) — all links (markdown
`[label](url)` and raw HTML `<a href>`) render as plain, non-clickable text in
every rich-text field (board and card titles, descriptions, comments, checklists,
…), so a link can never be clicked or hide a misleading target.
- **Always show all code as plain text** (`alwaysShowCodeAsText`) — rich text is
never rendered as markdown/HTML; the whole source is shown escaped, revealing HTML
comments (`<!-- -->`), the target of markdown links, JavaScript and any other code.
All code stays visible, not clickable, not running.
Import / export privacy (all enforced server-side, so they cannot be bypassed from
the client):
- **Disable all import** (`disableAllImport`) / **Disable all export**
(`disableAllExport`) — master switches that turn off every import / export feature
(WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject,
GitHub/GitLab/Gitea/Forgejo, board clone, single-attachment export). The matching
import / export menu options are also hidden in the UI.
- **Disable import avatars** (`disableImportAvatars`) — avatars are never imported,
from WeKan JSON import, Trello import, or external identity-provider sync on login
(LDAP, OIDC/OAuth2). Gated at the single `localizeAvatarFromBuffer` choke point.
- **Disable export avatars** (`disableExportAvatars`) — avatars are never included
when exporting (WeKan JSON and CSV export).
- **Anonymize import users** (`anonymizeImportUsers`) / **Anonymize export users**
(`anonymizeExportUsers`) — replace every user's username, full name and initials
with counter placeholders (`user1`, `user2`, …), drop their avatar, and rewrite
`@username` mentions plus the requested-by / assigned-by fields inside card and
comment content. The imported board / exported file then carries no real user
identity. The placeholder word "user" follows the language of the person
importing / exporting (e.g. `käyttäjä1` in Finnish). Both export paths — the
in-memory `build()` and the streaming `buildStream()` — are covered.
### Notifications
Privacy controls for deployments that must limit activity tracking, notifications
or watching (e.g. to comply with local law or an organization's policy)
([#5820](https://github.com/wekan/wekan/issues/5820)), enforced server-side:
- **Disable all activities** (`disableActivities`) — activity-feed entries are
neither recorded nor shown anywhere (board sidebar and card activity tab). No
history of who did what is kept.
- **Disable all notifications** (`disableNotifications`) — WeKan never sends watch
notifications for any activity. Activities can still be recorded (unless also
disabled); only the notifications are suppressed.
- **Disable watch** (`disableWatch`) — the watch feature is turned off: users cannot
subscribe to boards, lists or cards, any watch-level change is rejected, and the
watch button is hidden.
## Sandstorm platform
On Sandstorm, authentication (LDAP, passwordless email, SAML, GitHub and Google
+15 -9
View File
@@ -78,23 +78,29 @@ git clone git@github.com:YourGithubUsername/wekan.git
cd wekan
```
### 3. Select option 1 to install dependencies, and then Enter.
### 3. Run the menu: Setup -> Install dependencies, Setup -> Build WeKan, Dev server -> localhost:3000
`rebuild-wekan.sh` shows a two-level menu (`1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit`); pick a category number, then the item number inside it.
```
# Setup -> Install dependencies
./rebuild-wekan.sh
1 # Setup
1 # Install dependencies
1
# Setup -> Build WeKan
./rebuild-wekan.sh
1 # Setup
2 # Build WeKan
2
# Dev server -> localhost:3000
./rebuild-wekan.sh
3
2 # Dev server
1 # localhost:3000
```
That does: 1 install dependencies, 2 builds wekan, 3 starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically and reload webbroser. But, still sometimes, it may need stopping with Ctrl-c and full build with option 2.
That does: **Setup -> Install dependencies** installs dependencies, **Setup -> Build WeKan** builds wekan, **Dev server -> localhost:3000** starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically and reload the web browser. But, still sometimes, it may need stopping with Ctrl-c and a full **Setup -> Build WeKan** again. If a dev server is already running on that port, the Dev server options stop it first and start a fresh one on the same port.
And then [register and login](../Login/Adding-users.md) at http://localhost:4000
And then [register and login](../Login/Adding-users.md) at http://localhost:3000
### OPTIONAL, NOT NEEDED: 5. Add new plugin package
```
+11
View File
@@ -1,3 +1,14 @@
## WeKan Docker compose files (which database?)
Since the default database changed, the compose files in https://github.com/wekan/wekan are:
- **docker-compose.yml** — the default: **FerretDB v1 with embedded SQLite** (from https://github.com/wekan/FerretDB). Light and self-contained, no separate database server. Just `docker compose up -d`.
- **docker-compose-mongodb-v7.yml** — the classic **MongoDB 7** setup. Use it with `docker compose -f docker-compose-mongodb-v7.yml up -d`, or rename/copy it to `docker-compose.yml`.
- **docker-compose-ferretdb-v2-postgresql.yml** — **FerretDB 2 with PostgreSQL** (DocumentDB extension).
- **docker-compose-multitenancy.yml** — MongoDB multitenancy setup.
Note: the older instructions below were written for the MongoDB setup; where they say `docker-compose.yml` for MongoDB-specific things (and the `#L...` line-number links), they now refer to **docker-compose-mongodb-v7.yml**.
## Building custom Docker image
This is only for developers. For normal users, do not add these options, just start with `docker compose up -d`
@@ -118,6 +118,8 @@ sudo kill -9 PID-NUMBER-HERE
## 11) Rebuild Wekan
In the menu, pick **Setup -> Build WeKan** (press `1` then `2`):
```
cd ~/repos
./rebuild-wekan.sh
@@ -69,9 +69,11 @@ git merge upstream/master
cd ~/repos
./rebuild-wekan.sh
```
First with option 1, to install deps.
The menu is two levels: pick a category (`1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit`), then an item inside it.
Then same `./rebuild-wekan.sh` again with option 2, to build Wekan.
First **Setup -> Install dependencies** (press `1` then `1`), to install deps.
Then run `./rebuild-wekan.sh` again and pick **Setup -> Build WeKan** (press `1` then `2`), to build Wekan.
## 5) Developing with Standalone Wekan
@@ -81,7 +83,7 @@ meteor --port 4000
```
Wekan is at http://localhost:4000
When you make changes to files, Meteor automatically notices that, and rebuilds Wekan. If Meteor does not notice change, you need to Ctrl-c and `./rebuild-wekan.sh` option 2.
When you make changes to files, Meteor automatically notices that, and rebuilds Wekan. If Meteor does not notice change, you need to Ctrl-c and run `./rebuild-wekan.sh` -> **Setup -> Build WeKan** (press `1` then `2`).
## 6) Using MongoDB
@@ -106,7 +108,7 @@ https://www.nosqlbooster.com/downloads
When your feature works at Standalone Wekan, you can also test it at Sandstorm.
First build wekan with option 2:
First build wekan with **Setup -> Build WeKan** (press `1` then `2`):
```
cd ~/repos/wekan
./rebuild-wekan.sh
@@ -0,0 +1,395 @@
# WeKan on Sandstorm — Meteor 3.5 / Node.js 24 build + MongoDB 3 → FerretDB migration
Status: **design / not yet implemented**. Nothing here has been built or run end‑to‑end
in a grain yet — every step marked **TEST** must be verified against a real Sandstorm
install (ideally a copy of an existing WeKan grain) before shipping.
This document describes how to build a modern WeKan `.spk` (Meteor 3.5, Node.js 24) that
replaces the packaged MongoDB 3.0 database with **FerretDB v1 (embedded SQLite)**, migrating
any existing grain's MongoDB 3.0 data on first launch — reusing the exact migration logic the
WeKan **snap** already ships.
---
## 1. Why
The current Sandstorm build uses **meteor‑spk 0.6.0** (`https://dl.sandstorm.io/meteor-spk-0.6.0.tar.xz`,
built 2021‑10‑23). Its `meteor-spk.deps` payload is a 2015‑era design:
| Component | meteor‑spk 0.6.0 ships | WeKan needs |
|---|---|---|
| `bin/node` | Node **14.17.5** (stock, from nodejs.org) | **Node 24.x** (Meteor 3.5) |
| `bin/mongod` | MongoDB **3.0.7** (WiredTiger) | keep — read‑only migration source only |
| `bin/niscud` | MongoDB **2.x** (Kenton's Niscu fork) | keep (niscu→3.0 migration) |
| `lib/` | glibc **2.31** (Ubuntu 20.04) | regenerate on 22.04 (glibc 2.35) for node24/ferretdb |
| `start.js` | launches Mongo 3.0 + niscu→3.0 migration | rewrite for FerretDB |
`start.js` in 0.6.0 is functionally identical to 0.5.1 (only reformatted). It still launches
mongod 3.0 with `--nohttpinterface` (a flag **removed in MongoDB 3.6+**), so a newer mongod
can't even be dropped in without changing it.
Note on the Node fork: `sandstorm-io/node` is **not** needed. It is frozen at Node 8.11.4
(2018) and its only real patch is a V8 thread‑table optimization for **node‑fibers** — and
Meteor 3 removed fibers entirely. meteor‑spk already ships **stock** upstream Node (0.6.0's
`bin/node` is the official nodejs.org 14.17.5 build). Use stock **Node 24**.
---
## 2. Target architecture
Steady state after migration: WeKan (Node 24) talks to **FerretDB v1 (SQLite)** over the
MongoDB wire protocol — no MongoDB server runs at all. mongod 3.0 is used **once**, only to
read an existing grain's old data during the one‑time migration.
```
grain (single sandbox, writable = /var)
├─ /var/wiredTigerDb ← EXISTING MongoDB 3.0 data (old grains only; read-only source)
├─ /var/files/ ← WRITABLE_PATH (new)
│ ├─ attachments/ ← GridFS attachments extracted here (Meteor-Files on fs)
│ ├─ avatars/ ← GridFS avatars extracted here
│ └─ db/ ← FerretDB SQLite dir (wekan.sqlite) ← steady-state DB
└─ /var/.migration-to-ferretdb-done ← idempotency marker
```
### Ports (loopback inside the grain)
| Port | Role |
|---|---|
| 4000 | app HTTP (behind `sandstorm-http-bridge`) |
| 4001 | FerretDB v1 (steady state) — `MONGO_URL=mongodb://127.0.0.1:4001/wekan` |
| 4003 | temporary mongod 3.0 (migration read only) |
---
## 3. Sandbox compatibility (verified from the sandstorm source)
Checked `sandstorm/src/sandstorm/seccomp-bpf/filter.s`. The grain seccomp filter is an
allowlist whose **default action is `ENOSYS`, not SIGSYS‑kill**, so Go/glibc gracefully fall
back on missing syscalls. Relevant results:
- **Allowed** (needed by Node 24 / Go FerretDB): `getrandom`, `statfs`/`fstatfs`,
`sched_getaffinity`, `clone`, `futex`, `epoll_*`, `eventfd2`, `fork`, `mmap`, `mprotect`.
- **Denied → ENOSYS** (tolerated via fallback): `membarrier`, `rseq`, `clone3`, `restart_syscall`.
Conclusion: a grain can run Node 24 + FerretDB (Go) + mongod 3.0 concurrently — the same
multi‑process model the current build already uses for mongod+node. Nothing else is needed
from the sandstorm platform repo.
**TEST (highest risk):** Node 24's glibc uses `clone3` for thread creation and relies on the
ENOSYS→`clone` fallback (glibc ≥ 2.34). Verify a trivial Node 24 grain spawns threads before
investing in the rest.
---
## 4. `meteor-spk.deps` payload changes
Start from the 0.6.0 base, then:
- **Replace** `bin/node` with **Node 24** — use the exact build from Meteor 3.5's dev bundle
(`meteor node -e "console.log(process.version)"`) so native‑addon ABIs match the WeKan bundle.
- **Keep** `bin/mongod` (3.0.7) — read‑only migration source.
- **Keep** `bin/niscud` (MongoDB 2.x) **and** the old bundled `node_modules/{mongodb,bson,
mongodb-core,es6-promise,readable-stream}` — used by `start.js`'s **niscu → MongoDB 3.0**
stage (Stage 1) for very old grains. Migration support for old versions is permanent and is
not dropped. (0.6.0 already ships both; nothing extra to add.)
- **Add** `migratemongo/bin/{mongoexport,mongo}` + `migratemongo/lib/` (the old glibc those
3.x CLIs need). The importer reads the 3.0 data with these — the modern Node driver cannot
speak to a 3.0/3.2 server. Reuse the same tools the snap's
[`migratemongo`](https://github.com/wekan/migratemongo) provides (amd64, x86_64).
- **Add** `ferretdb` — the `ferretdb-amd64` binary downloaded directly
(newest release of [wekan/FerretDB](https://github.com/wekan/FerretDB/releases); the same
`ferretdb-amd64` asset `release-all.yml` embeds).
- **Add** `migrate-mongo3-to-ferretdb.mjs` — copy verbatim from
[`snap-src/bin/migrate-mongo3-to-ferretdb.mjs`](../../../../../snap-src/bin/migrate-mongo3-to-ferretdb.mjs);
it is entirely env/path‑driven and needs no changes.
- The **importer** (Stage 2) resolves its `mongodb`/`bson` from the WeKan bundle via
`NODE_PATH=<bundle>/programs/server/node_modules` (the modern driver, separate process from
`start.js`, so it does not clash with the old driver Stage 1 uses).
- **Regenerate `lib/`, `lib64/`, `usr/lib`** on Ubuntu 22.04/24.04 (glibc 2.35) for node24 +
ferretdb. Keep the **old** libs separately under `migratemongo/lib` for mongod3/mongoexport
(dual‑lib pattern, exactly as the snap does with `MM_LIB`).
> The 0.5.1 fork's `lib/x86_64-linux-gnu/*` were **dangling symlinks into
> `/home/user/Lataukset/...`** (a Downloads folder). Do not carry those forward — the tree
> must be self‑contained and regenerated with `gather-deps` on a clean modern base.
amd64 only. (arm64 Sandstorm is out of scope here; the migratemongo 3.x binaries are x86_64.)
---
## 5. `start.js` (rewritten launcher)
The canonical launcher is committed at
[`sandstorm-src/start.js`](../../../../../sandstorm-src/start.js); the spk build copies it into
`meteor-spk.deps/start.js`. It is a CJS launcher, spawned by the pkgdef as `... node start.js`.
On first launch it runs the migration chain for whatever the grain holds —
**niscu (2.x) → MongoDB 3.0** (Stage 1, the preserved legacy path, for very old grains) then
**MongoDB 3.0 → FerretDB** (Stage 2) — then runs WeKan on FerretDB. Fresh grains and
already-migrated grains skip straight to running on FerretDB.
Migration logic is a re‑pathed port of the snap's
[`migration-control`](../../../../../snap-src/bin/migration-control) +
[`migrate-mongo3-to-ferretdb.mjs`](../../../../../snap-src/bin/migrate-mongo3-to-ferretdb.mjs).
All app-payload paths resolve from `__dirname` (the grain mount root), and it waits for
mongod 3.0 to answer a ping before running the importer. Abridged shape:
```js
// meteor-spk.deps/start.js (see sandstorm-src/start.js for the full file)
const { spawn, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const APP_PORT = process.env.PORT || '4000';
const DB_PORT = '4001'; // FerretDB steady-state
const SRC_PORT = '4003'; // temp mongod 3.0 (migration read)
const FILES_DIR = '/var/files';
const SQLITE_DIR = path.join(FILES_DIR, 'db');
const OLD_MONGO = '/var/wiredTigerDb';
const MARKER = '/var/.migration-to-ferretdb-done';
const STATUS = path.join(FILES_DIR, 'db', 'migration-status.json');
const APPROOT = __dirname; // where .meteor-spk/bundle is mounted
const MM_LIB = path.join(APPROOT, 'migratemongo/lib');
for (const d of [FILES_DIR, SQLITE_DIR,
path.join(FILES_DIR, 'attachments'), path.join(FILES_DIR, 'avatars')]) {
fs.mkdirSync(d, { recursive: true });
}
function startFerret(port) {
return spawn(path.join(APPROOT, 'ferretdb'),
['--handler=sqlite', `--sqlite-url=file:${SQLITE_DIR}/`,
`--listen-addr=127.0.0.1:${port}`, '--telemetry=disable'],
{ stdio: 'inherit',
env: { ...process.env, DO_NOT_TRACK: '1', FERRETDB_TELEMETRY: 'disable' } });
}
function migrateIfNeeded() {
const hasOld = fs.existsSync(path.join(OLD_MONGO, 'WiredTiger'));
if (fs.existsSync(MARKER) || !hasOld) return; // fresh install or already migrated
console.log('** Migrating MongoDB 3 -> FerretDB (one-time) ...');
const oldLibEnv = { ...process.env,
LD_LIBRARY_PATH: `${MM_LIB}:${process.env.LD_LIBRARY_PATH || ''}` };
// 1. old mongod 3.0 serving the existing data (read-only source)
spawnSync('/bin/mongod',
['--dbpath', OLD_MONGO, '--bind_ip', '127.0.0.1', '--port', SRC_PORT,
'--storageEngine', 'wiredTiger', '--fork', '--logpath', '/var/migration-mongod.log'],
{ stdio: 'inherit', env: oldLibEnv });
// 2. FerretDB target (the PERMANENT SQLite dir — no temp/switch needed)
const ferret = startFerret(DB_PORT);
// 3. importer: CLI-read 3.0 -> driver-insert FerretDB; GridFS -> /var/files/{attachments,avatars}
const rc = spawnSync(path.join(APPROOT, 'node'),
[path.join(APPROOT, 'migrate-mongo3-to-ferretdb.mjs')], {
stdio: 'inherit',
env: { ...process.env,
NODE_PATH: path.join(APPROOT, 'programs/server/node_modules'),
MONGO_BIN_DIR: path.join(APPROOT, 'migratemongo/bin'),
MONGO_LIB: MM_LIB,
SRC_PORT, SRC_DB: 'wekan',
TARGET_MONGO_URL: `mongodb://127.0.0.1:${DB_PORT}/wekan`,
FILES_DIR,
MIGRATION_PORT: APP_PORT, // progress dashboard shown at the grain URL
STATUS_FILE: STATUS }, // importer writes final state here (see §6)
}).status;
try { ferret.kill(); } catch {}
spawnSync('/bin/mongo', ['--port', SRC_PORT, '--quiet', '--eval',
'db.getSiblingDB("admin").shutdownServer()'], { env: oldLibEnv });
if (rc === 0) {
fs.writeFileSync(MARKER, new Date().toISOString()); // never re-migrate; old data KEPT
} else {
console.error('** Migration failed; will retry on next grain start. Old data kept.');
process.exit(1);
}
}
function runApp() {
const ferret = startFerret(DB_PORT); // steady-state DB
process.env.MONGO_URL = `mongodb://127.0.0.1:${DB_PORT}/wekan`;
process.env.ROOT_URL = process.env.ROOT_URL || `http://127.0.0.1:${APP_PORT}`;
process.env.PORT = APP_PORT;
process.env.WRITABLE_PATH = FILES_DIR;
process.on('exit', () => { try { ferret.kill(); } catch {} });
setTimeout(() => require('./main.js'), 1500); // let FerretDB start listening
}
migrateIfNeeded();
runApp();
```
Design choices vs the snap:
- **No temp‑then‑switch**: the migration writes straight into the permanent FerretDB SQLite
dir (`/var/files/db`), because in a grain there is only one database going forward.
- **Idempotent**: the marker prevents re‑migration; the old `/var/wiredTigerDb` is **never
deleted automatically** — the admin deletes it explicitly from the UI (§7).
---
## 6. What the migration does (importer)
`migrate-mongo3-to-ferretdb.mjs` (unchanged from the snap):
- **Text collections** — `mongoexport` each (Extended JSON) from mongod 3.0, `insertMany`
into FerretDB via the modern Node driver (batch 200, upsert on conflict).
- **Attachments + avatars** — from CollectionFS GridFS (`cfs_gridfs.<bucket>.{files,chunks}`
+ `cfs.<bucket>.filerecord`): each file is reassembled from its chunks and written to
`/var/files/attachments` or `/var/files/avatars`, and a **Meteor‑Files** record pointing at
that filesystem path is inserted into FerretDB. This is exactly WeKan's "MongoDB → filesystem"
attachment move, done inline.
- **Schema** is left as‑is; WeKan's own Board Settings / Migrations upgrade it on first use.
- **Progress** is served over HTTP at `MIGRATION_PORT` (= the app port), so a user opening the
grain during migration sees a live dashboard.
**Add for the UI (§7):** have the importer also write a final status JSON to
`STATUS_FILE` (`/var/files/db/migration-status.json`) — the `state` object it already builds:
`{ startedAt, finishedAt, phase, success, collections, files, errors }`. One `fs.writeFileSync`
at the end of `run()`.
---
## 7. Admin Panel / Attachments / **Sandstorm** (new, `isSandstorm` only)
**Implemented** — server: [`server/methods/sandstormMigration.js`](../../../../../server/methods/sandstormMigration.js);
client: the **Sandstorm** tab in [`client/components/settings/attachments.jade`](../../../../../client/components/settings/attachments.jade) /
[`.js`](../../../../../client/components/settings/attachments.js); strings in
[`imports/i18n/data/en.i18n.json`](../../../../../imports/i18n/data/en.i18n.json). The importer
writes `migration-status.json` (STATUS_FILE) that the panel reads.
When `isSandstorm === true` (`Meteor.settings.public.sandstorm`, see
[models/settings.js](../../../../../models/settings.js#L19)), a new **Sandstorm** section appears
in Admin Panel / Attachments (next to Backup), showing:
1. **Migration status** — Success / Pending / Failed + timestamp and per‑collection / file
counts, read from `/var/files/db/migration-status.json` and the marker.
2. **Raw MongoDB disk usage** — bytes currently used by the old raw MongoDB 3.0 files under
`/var/wiredTigerDb` (the reclaimable space), plus FerretDB SQLite size and attachments/avatars
size for context.
3. **Delete raw MongoDB files** — a guarded button that removes `/var/wiredTigerDb` to free
grain disk. Enabled **only** when migration succeeded (marker present, `success:true`) and
FerretDB actually holds data.
### Server methods
New file `server/methods/sandstormMigration.js` (server‑only; admin‑gated exactly like
[`server/methods/backup.js`](../../../../../server/methods/backup.js) via `ReactiveCache.getCurrentUser()` + `isAdmin`):
```js
const OLD_MONGO = '/var/wiredTigerDb';
const STATUS = (process.env.WRITABLE_PATH || '/var/files') + '/db/migration-status.json';
const MARKER = '/var/.migration-to-ferretdb-done';
function dirBytes(dir) { // recursive size; tolerant of missing dir
let total = 0;
const walk = d => { for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else { try { total += fs.statSync(p).size; } catch {} }
} };
try { if (fs.existsSync(dir)) walk(dir); } catch {}
return total;
}
Meteor.methods({
async sandstormMigrationStatus() {
const user = await ReactiveCache.getCurrentUser();
if (!user || !user.isAdmin) return false;
let status = null;
try { status = JSON.parse(fs.readFileSync(STATUS, 'utf8')); } catch {}
return {
isSandstorm: true,
migrationDone: fs.existsSync(MARKER),
status,
rawMongoBytes: dirBytes(OLD_MONGO),
rawMongoExists: fs.existsSync(OLD_MONGO),
ferretBytes: dirBytes(path.dirname(STATUS)),
};
},
async sandstormDeleteRawMongo() {
const user = await ReactiveCache.getCurrentUser();
if (!user || !user.isAdmin) throw new Meteor.Error('not-authorized');
// Safety: only after a confirmed-successful migration.
if (!fs.existsSync(MARKER)) throw new Meteor.Error('migration-not-done');
let ok = false;
try { ok = JSON.parse(fs.readFileSync(STATUS, 'utf8')).success === true; } catch {}
if (!ok) throw new Meteor.Error('migration-not-successful');
const freed = dirBytes(OLD_MONGO);
fs.rmSync(OLD_MONGO, { recursive: true, force: true });
return { deleted: true, freedBytes: freed };
},
});
```
### Client
New tab in [client/components/settings/attachments.{jade,js}](../../../../../client/components/settings/attachments.js),
rendered only when `isSandstorm`. Poll `sandstormMigrationStatus` (like the Backup tab polls
`backupStatus`), render the status + a human‑readable size (`filesize`), and a
**Delete raw MongoDB files** button behind a confirm dialog that calls `sandstormDeleteRawMongo`
and refreshes.
> Deleting the raw files is irreversible. Gate it behind: migration `success:true`, an explicit
> confirm, and (recommended) a typed confirmation. The data still exists in FerretDB + on the
> filesystem after migration, so this only reclaims the now‑redundant Mongo 3.0 copy.
---
## 8. pkgdef / env changes ([sandstorm-pkgdef.capnp](../../../../../sandstorm-pkgdef.capnp))
- `WRITABLE_PATH`: `/var/wekan-uploads` → **`/var/files`**.
- Do **not** set `MONGO_URL` in `environ` (start.js sets it to the FerretDB port).
- Bump `appVersion` and `appMarketingVersion`.
- Keep `argv = ["/sandstorm-http-bridge", "4000", "--", "node", "start.js"]`.
- `SANDSTORM=1` and `METEOR_SETTINGS={"public":{"sandstorm":true}}` are already present — that
drives `isSandstorm` for the new admin panel.
---
## 9. Build / CI ([.github/workflows/sandstorm.yml](../../../../../.github/workflows/sandstorm.yml))
- **Base = upstream meteor‑spk 0.6.0**, downloaded from `https://dl.sandstorm.io/meteor-spk-0.6.0.tar.xz`.
It already contains `bin/mongod` (3.0.7), `bin/niscud` (2.x) and the old `node_modules` the
niscu→3.0 stage needs. **Nothing is needed from the old `projects.7z`** (it only holds
0.4.1/0.5.0/0.5.1, and its swapped node is ancient — we install Node 24 anyway). The dead
`releases.wekan.team/dev/meteor-spk/projects.7z` fetch is removed.
- The assembly script ([`sandstorm-src/build-deps.sh`](../../../../../sandstorm-src/build-deps.sh))
modernizes `meteor-spk.deps`: swap in Node 24, add `ferretdb-amd64` (per-arch asset from the wekan/FerretDB release), add
`migratemongo/{bin,lib}` (`mongoexport` + `mongo` + old libs), copy
[`sandstorm-src/start.js`](../../../../../sandstorm-src/start.js) → `meteor-spk.deps/start.js`
and [`snap-src/bin/migrate-mongo3-to-ferretdb.mjs`](../../../../../snap-src/bin/migrate-mongo3-to-ferretdb.mjs)
→ `meteor-spk.deps/`, **keep `niscud`**, and regenerate the lib tree with `gather-deps` on ubuntu‑24.04.
- Fetch the extra binaries (Node 24, `ferretdb-amd64`, migratemongo CLIs) from **GitHub release
assets** — `releases.wekan.team` no longer exists, so any required build files live on GitHub
releases (e.g. `wekan/wekan` or a dedicated release).
---
## 10. Risks & test checklist
- [ ] **TEST** Node 24 threads under seccomp (clone3→clone ENOSYS fallback). *Highest risk.*
- [ ] **TEST** mongod 3.0.7 opens `/var/wiredTigerDb` inside the grain with the old libs.
- [ ] **TEST** FerretDB (Go) starts and listens in the grain.
- [ ] **TEST** full migration on a **copy** of a real old WeKan grain (text + attachments + avatars).
- [ ] **TEST** fresh install (no `/var/wiredTigerDb`): skips migration, runs on empty FerretDB.
- [ ] **TEST** grain disk headroom — migration transiently holds old Mongo + new SQLite + extracted
files at once, within the grain quota.
- [ ] **TEST** `LD_LIBRARY_PATH` split — node24/ferretdb on the fresh 2.35 libs; mongo‑3.0 CLIs on
the old libs; no cross‑contamination.
- [ ] **TEST** Admin Panel / Attachments / Sandstorm: status accuracy, disk numbers, guarded delete.
---
## 11. Open questions
- FerretDB v1 SQLite filename: **confirmed** — for db `wekan` the backend writes
`<sqlite-dir>/wekan.sqlite` (plus SQLite WAL sidecars `wekan.sqlite-shm` / `wekan.sqlite-wal`).
So the disk-usage sum of the `db/` dir already covers it.
- Bundle mount root in the grain: `sandstorm-src/start.js` resolves app-payload paths from
`__dirname` (so `bin/mongod`, `ferretdb`, `migratemongo/`, `main.js` are all relative to the
launcher). Confirm from the packed spk that the added binaries land where `start.js` expects
(e.g. `migratemongo/` and `ferretdb` at the mount root, CLIs under `migratemongo/bin`).
- Migration support from old versions (niscu 2.x and MongoDB 3.0) is **permanent** — it is not
dropped, so `niscud`, mongod 3.0 and the CLI-based importer stay in the package.
+65 -1
View File
@@ -2,6 +2,69 @@
# Sandstorm at Debian and Ubuntu
### New Sandstorm WeKan at 2026-07
Backup your current Sandstorm first !
1. Please first backup your Sandstorm https://docs.sandstorm.io/en/latest/administering/backups/
2. Please first download all of your grains to .zip files from top menu row down arrow button, just in case.
This new Sandstorm WeKan uses fork of FerretDB v1 SQLite at https://github.com/wekan/FerretDB
- FerretDB is drop-in replacement for MongoDB 7 Server
- FerretDB implements MongoDB 7 server wire protocol
- FerretDB converts MongoDB Javascript queries to SQLite SQL queries
- FerretDB saves data to SQLite database
- Fork has additional MongoDB features
- Fork is maintained by xet7
Migrating old MongoDB 3 data
- If there is old WeKan data, it is automatically migrated when opening old WeKan grain.
Attachments are migrated from MongoDB to files/attachments.
Text data is migrated from MongoDB to SQLite at files/db/wekan.sqlite .
- After migrating, you may get error, that browser can not open that page embedded to other page.
So you need to close WeKan grain and open it again. Sorry, trying to fix it later.
All Boards page
- WeKan starts at All Boards page Favorites
- All your boards are at first at Remaining, until you Star to have your board at Favorites
- You can move boards from Remaining to your personal Workspaces or Sub-Workspaces
After migrating, free some disk space
- If after migrating you would like to free disk space of WeKan grain
by deleting old MongoDB raw database files
- At right top corner, click your WeKan username at WeKan board (not at Sandstorm black backround) /
Member Settings / Admin Panel / Attachments / Sandstorm / Delete raw MongoDB database files.
Cover image
- You can enable it for minicard and card at:
Right Sidebar/Board Settings/Card Settings/Cover Image
(at bottom of Card Settings list).
Download Official Version
- [Official Sandstorm Store](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h)
Download Newest Test Version
1. Please backup first.
2. Go to [Sandstorm Test App Loader](https://mnutt.github.io/sandstorm-test-app-loader/?spk_url=https%3A%2F%2Fgithub.com%2Fwekan%2Fwekan%2Freleases%2Fdownload%2Fv9.89%2Fwekan-sandstorm-2026_07_13-16_54_56.spk&package_id=1c39541fb4710bdb04a68d0b66e9d634)
3. Copy newest wekan.spk URL from https://github.com/wekan/wekan/releases to Sandstorm Test App Loader page
4. Write your Sandstorm Instance URL to Sandstorm Test Loader page
5. Click Install
6. Later, newest WeKan will also be added to [Experimental](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h?experimental=true)
and [Official](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h) Sandstorm Store.
About CloudFlare and uploading wekan.spk file
- If your Sandstorm you use CloudFlare, there may be 100 MB file size upload limit,
it prevents uploading wekan.spk as file to your Sandstorm
- So instead use above Sandstorm Test App Loader to install Newest Test Version
### Sandstorm Radicale: Calendars and Contacts
- 2025-08-05
@@ -141,7 +204,8 @@ Install to your own server. Automatic updates, tested before release. Sandstorm
## Wekan App
Wekan at [experimental](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h?experimental=true) or [official](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h) Sandstorm App Market. Note: Only use official. Experimental versions are broken.
Wekan at [experimental](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h?experimental=true) or
[official](https://apps.sandstorm.io/app/m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h) Sandstorm App Market. Note: Only use official. Experimental versions are broken.
Newest Wekap app .spk file download at https://releases.wekan.team/sandstorm/
+168
View File
@@ -0,0 +1,168 @@
# WeKan Snap — CPU platforms and databases
This page documents, **for the WeKan snap**, which CPU architectures are built,
which database binaries each one ships, how new installs choose their database,
and how the one-time MongoDB 3 → FerretDB v1 migration works.
## Summary
- **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.
- **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.
- **Every Snap platform includes the FerretDB v1 server and the MongoDB Database
Tools** (`mongodump`, `mongorestore`, `mongofiles`, `mongoexport`, …), on every
architecture.
- **`mongosh` is NOT bundled at all.** WeKan already ships Node.js 24 and the
`mongodb` driver, and all scripted database access (readiness checks,
replica-set init, schema migration) uses that via `$SNAP/bin/db-eval` instead of
the MongoDB Shell. This removes a large, CVE-prone binary and works identically
on every architecture. See [MongoDB client binaries](#mongodb-client-binaries-source-and-availability).
- **The MongoDB Database Tools are downloaded from WeKan's own fork, not from the
MongoDB website** — `mongodump-<arch>` etc. from
[wekan/mongo-tools](https://github.com/wekan/mongo-tools/releases).
- **Existing MongoDB 7 data (amd64/arm64) is NOT migrated to FerretDB v1** — that
snap keeps using its existing MongoDB 7.
- **Only the amd64 snap migrates existing MongoDB 3 data to FerretDB v1** (see
[Migration](#mongodb-3--ferretdb-v1-migration-amd64-only) below).
## 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 |
- **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
[wekan/mongo-tools](https://github.com/wekan/mongo-tools) (they are pure Go).
- **mongosh**: not bundled on any arch — replaced by the bundled Node.js 24 +
`mongodb` driver (`$SNAP/bin/db-eval`).
All architectures default new installs to FerretDB v1 with `DDP_TRANSPORT=sockjs`
and polling reactivity.
## New installs
On a fresh install, every architecture starts on **FerretDB v1 (SQLite)** with
these snap defaults (in [`snap-src/bin/config`](../../../../../snap-src/bin/config)):
- `database` = `ferretdb` — FerretDB v1 is the default backend.
- `ddp-transport` (`DDP_TRANSPORT`) = `sockjs` — maximum proxy compatibility.
- Reactivity = **polling**. FerretDB v1 has no replica-set oplog or change
streams, so WeKan uses polling for live updates (it does this automatically
when the backend is FerretDB).
On amd64/arm64 you can still switch an install to the bundled MongoDB 7 server:
```
sudo snap set wekan database=mongodb # amd64/arm64 only (mongod is bundled there)
sudo snap set wekan database=ferretdb # the default
```
## MongoDB client binaries: source and availability
WeKan does not download MongoDB clients from the MongoDB website:
- **MongoDB Database Tools** come from [wekan/mongo-tools](https://github.com/wekan/mongo-tools/releases).
These are pure Go, so its `build-binaries.yml` workflow cross-compiles **every
architecture** (`bsondump`, `mongodump`, `mongoexport`, `mongofiles`,
`mongoimport`, `mongorestore`, `mongostat`, `mongotop`), including arches MongoDB
never shipped tools for (riscv64, loong64). Every WeKan build (snap, bundle,
Docker, Sandstorm) downloads the one `<tool>-<arch>` it needs from the newest
release.
- **`mongosh` is not used at all.** It is a large Node.js-packaged binary (it
embeds its own Node and drags in a heavy, CVE-prone dependency tree) that cannot
be cross-compiled for s390x/ppc64le/riscv64. Since WeKan already bundles Node.js
24 and the `mongodb` driver, every scripted MongoDB/FerretDB operation the snap
used mongosh for — readiness `ping`, replica-set `initiate`/`status`, the v8.43
schema migration — now runs through `$SNAP/bin/db-eval` (a tiny wrapper around
the bundled Node + driver). This works identically on every architecture and
removed the mongosh binary from the snap entirely.
The `mongo` shell that IS still present is only the legacy MongoDB 3.2 `mongo`
(from `migratemongo`, amd64 only), used at migration time to read old MongoDB 3
data — the modern driver cannot speak to a 3.2 server. It is not used at runtime.
## MongoDB 3 → FerretDB v1 migration (amd64 only)
Only the **amd64** snap ships the MongoDB 3-specific binaries (the legacy MongoDB
3.2 `mongo` shell + `mongofiles`, bundled via `migratemongo`). They are used **at
migration time only** — a one-time conversion of old data — and are **never a
runtime component**: after the migration WeKan runs on FerretDB v1 and the
MongoDB 3 binaries are not used again. No other architecture ships them at all
(s390x/ppc64le/riscv64 never had a MongoDB 3, so there is nothing to migrate).
The migration runs only on amd64, and only when existing **MongoDB 3** data (from
an old WeKan 6.09-era snap) is present. During the migration **both databases run
at the same time on different ports** — MongoDB 3 on its port and FerretDB v1 on
its port — and data moves directly from one to the other:
- **Attachments and avatars** (binary files): read from the MongoDB CollectionFS
GridFS and MongoDB Meteor-Files GridFS collections with the MongoDB 3-specific
`mongofiles` command, and written to:
- `$SNAP_COMMON/files/attachments/`
- `$SNAP_COMMON/files/avatars/`
- **Text data** (all boards and other non-file data — everything that is *not*
attachments or avatars): copied directly from the MongoDB 3 port to the
FerretDB v1 port using the legacy MongoDB 3 `mongo` CLI (mongo shell); the
modern `mongosh` cannot talk to a MongoDB 3 server.
After a successful migration the snap switches to `database=ferretdb`.
Architectures other than amd64 ship no MongoDB server and no MongoDB 3 client, so
there is nothing to migrate from — they are FerretDB v1 from the first boot.
## Where the FerretDB v1 binary comes from
FerretDB v1 is built and released separately in
[wekan/FerretDB](https://github.com/wekan/FerretDB/releases) as one binary per
architecture (`ferretdb-<arch>`; there is no `ferretdb.zip`). Each WeKan build
downloads only the single binary for the platform it targets, e.g.:
```
https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64
https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64
https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-s390x
https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-riscv64
https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-ppc64le
```
For the snap, that binary is embedded inside the `wekan-<version>-<arch>.zip`
bundle the snap's `wekan` part downloads, and staged to `$SNAP/ferretdb`
(started by `ferretdb-control`).
## How the bundled binary versions are kept current
- The **MongoDB 7 server (mongod)** is the one component still fetched from
MongoDB (amd64/arm64 only). The `bump` job in
[`.github/workflows/release-all.yml`](../../../../../.github/workflows/release-all.yml)
runs [`releases/version.sh`](../../../../../releases/version.sh), which probes
upstream and rewrites the pinned MongoDB 7 server version (and the WeKan bundle
version/URLs) in `snapcraft.yaml`.
- The **FerretDB v1** server and the **MongoDB client binaries** are not pinned:
every build fetches the newest per-arch asset from the WeKan forks —
`ferretdb-<arch>` from [wekan/FerretDB](https://github.com/wekan/FerretDB/releases),
`<tool>-<arch>` from [wekan/mongo-tools](https://github.com/wekan/mongo-tools/releases),
and `mongosh-<arch>` from [wekan/mongosh](https://github.com/wekan/mongosh/releases).
## Snap 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.
## 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)
+2 -2
View File
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-9.79-win64.zip](https://github.com/wekan/wekan/releases/download/v9.79/wekan-9.79-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-9.95-win64.zip](https://github.com/wekan/wekan/releases/download/v9.95/wekan-9.95-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-9.79-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-9.95-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+108 -3
View File
@@ -1,6 +1,7 @@
{
"accept": "Menerima",
"activity-changedTitle": "changed title to %s of %s",
"activity-changedDescription": "changed description of %s",
"act-deleteCard": "deleted card __card__ from list __list__ at swimlane __swimlane__ at board __board__",
"act-removeBoard": "deleted board __board__",
"act-removeList": "deleted list __list__ at board __board__",
@@ -441,6 +442,7 @@
"createCustomField": "Cipta ruangan",
"createCustomFieldPopup-title": "Cipta ruangan",
"current": "sekarang",
"custom-color": "Custom color",
"custom-field-delete-pop": "Tidak boleh undur semula. Ianya akan menghapuskan ruangan khas daripada semua kad dan padamkan sejarahnya.",
"custom-field-checkbox": "kotak semak",
"custom-field-currency": "Matawang",
@@ -582,9 +584,12 @@
"fullname": "Nama Lengkap",
"header-logo-title": "Kembali ke laman panel anda",
"show-activities": "Tunjukkan Aktiviti",
"hide-activities": "Hide Activities",
"headerBarCreateBoardPopup-title": "Buat Panel",
"home": "Beranda",
"import": "Impor",
"imported-member-no-account": "Imported member — no matching account yet",
"inactive-member": "Inactive member",
"impersonate-user": "Berlakon sebagai pengguna",
"link": "Pautan",
"import-board": "import papan",
@@ -895,7 +900,12 @@
"no-name": "(Tidak Diketahui)",
"Node_version": "Versi Node",
"Meteor_version": "Versi Meteor",
"MongoDB_version": "Versi MongoDB",
"Database_type": "Database type",
"Database_commit": "Database commit",
"FerretDB_version": "FerretDB version",
"FerretDB_commit": "FerretDB commit",
"Reactivity_mode": "Reactivity mode (changeStreams / oplog / polling)",
"MongoDB_version": "MongoDB compatible version",
"MongoDB_storage_engine": "Mesin penyimpanan MongoDb",
"MongoDB_Oplog_enabled": "Oplog MongoDB dihidupkan",
"OS_Arch": "Arch OS",
@@ -1016,6 +1026,17 @@
"r-workflow-help": "Drag a trigger and an action into the builder, then add the rule. Drag an action onto an existing rule to change what it does.",
"r-drop-trigger": "drop a trigger here",
"r-drop-action": "drop an action here",
"r-w-card-created": "Card is created",
"r-w-card-archived": "Card is archived",
"r-w-card-unarchived": "Card is unarchived",
"r-w-label-added": "Any label is added",
"r-w-label-removed": "Any label is removed",
"r-w-member-added": "A member is added",
"r-w-member-removed": "A member is removed",
"r-w-checklist-added": "A checklist is added",
"r-w-attachment-added": "An attachment is added",
"r-w-every-day-at": "Every day at __time__",
"r-w-set-received-now": "Set received date to now",
"r-export-json": "Export rules to JSON",
"r-export-csv": "Export rules to CSV",
"r-import-json": "Import rules from JSON",
@@ -1069,6 +1090,12 @@
"r-mark-incomplete": "Mark card incomplete",
"r-move-all-cards": "Move all cards in list",
"r-set-date-relative": "Set date relative to now",
"r-later": "later",
"r-unit-minutes": "minute(s)",
"r-unit-hours": "hour(s)",
"r-unit-days": "day(s)",
"r-unit-weeks": "week(s)",
"r-unit-months": "month(s)",
"r-trigger": "Picu",
"r-action": "Tindakan",
"r-when-a-card": "Ketika kartu",
@@ -1530,6 +1557,10 @@
"rulesReportTitle": "Laporan Peraturan",
"boardsReportTitle": "Laporan Papan",
"cardsReportTitle": "Laporan Kad",
"impersonationReportTitle": "Impersonation Report",
"impersonation-admin": "Pentadbir",
"impersonation-user": "Impersonated user",
"reason": "Reason",
"copy-swimlane": "Salin aliranrenang",
"copySwimlanePopup-title": "Salin aliranrenang",
"display-card-creator": "Papar pencipta Kad",
@@ -1810,6 +1841,82 @@
"azure-container": "Container",
"gcs-storage": "Google Cloud Storage",
"gcs-storage-description": "Google Cloud Storage.",
"database-migration": "Database migration",
"database-migration-description": "Migrate text-based data (everything except attachments and avatars, which stay on the filesystem) between MongoDB and FerretDB v1 (SQLite). The other database must be running and reachable (default ports: FerretDB mongodb://127.0.0.1:27018, MongoDB mongodb://127.0.0.1:27019; override with WEKAN_FERRETDB_URL / WEKAN_MONGODB_URL). After it finishes, point MONGO_URL at the other database and restart WeKan (on Snap: snap set wekan database=ferretdb or =mongodb).",
"database-migrate-to-ferretdb": "Migrate text data to FerretDB v1 (SQLite)",
"database-migrate-to-mongodb": "Migrate text data to MongoDB",
"database-migration-confirm": "Copy all text-based collections to __db__? Attachments and avatars on the filesystem are not affected. The target database must be running.",
"database-migration-phase": "Phase",
"database-migration-done": "Text-data migration completed.",
"sandstorm-migration-description": "When WeKan runs inside a Sandstorm grain, its old MongoDB 3 database is migrated to FerretDB v1 (SQLite) on first launch, and attachments and avatars are moved from the database to the grain's files/attachments and files/avatars directories. This shows whether that migration succeeded and how much disk space the old raw MongoDB database files still use inside the grain, and lets you delete those raw files to free space.",
"sandstorm-migration-status": "Migration status",
"sandstorm-migration-success": "Succeeded",
"sandstorm-migration-failed": "Failed",
"sandstorm-migration-pending": "Not migrated yet",
"sandstorm-storage-item": "Storan",
"sandstorm-disk-usage": "Disk usage",
"sandstorm-raw-mongodb": "Raw MongoDB 3 database files",
"sandstorm-delete-raw-mongodb": "Delete raw MongoDB database files",
"sandstorm-delete-raw-mongodb-description": "After a successful migration the data lives in FerretDB and on the filesystem, so the old raw MongoDB 3 database files can be deleted to free grain disk space. This cannot be undone.",
"sandstorm-delete-raw-mongodb-confirm": "Delete the raw MongoDB 3 database files to free disk space? The data has already been migrated to FerretDB and the filesystem. This cannot be undone.",
"sandstorm-raw-mongodb-deleted": "Raw MongoDB database files deleted, freed",
"collections": "collections",
"features": "Features",
"features-performance": "Performance",
"cards-loading": "Card loading",
"cards-loading-all": "All cards (default)",
"cards-loading-lazy": "Lazy (only visible cards)",
"cards-loading-description": "How each board loads its cards. All cards: every card is loaded into the browser (simplest, all features work, but a board with thousands of cards sends a lot of data to each viewer). Lazy: each list loads only the cards currently visible (infinite scroll) plus a live count, so very large boards use less memory and bandwidth. Also set by the CARDS_LOADING environment variable.",
"cards-loading-lazy-note": "Lazy card loading is experimental: card counters and WIP limits are accurate, but the Calendar/Table/Gantt views and multi-select reflect only the cards loaded so far. Reload open boards after changing this.",
"features-security": "Security",
"render-links-as-plain-text": "Render links as plain text",
"render-links-as-plain-text-description": "When enabled, all links (both markdown links like [label](url) and raw HTML <a href> tags) are always shown as plain, non-clickable text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This prevents misleading clickable links. Default is off, so links stay clickable.",
"always-show-code-as-text": "Always show all code as plain text",
"always-show-code-as-text-description": "When enabled, rich text is never rendered as markdown or HTML: the entire source is always shown as plain text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This reveals hidden content — HTML comments (<!-- -->), the target of markdown links, JavaScript and any other code — so all code is always visible, not clickable, and not running. Default is off, so markdown is rendered normally.",
"anonymized-user": "pengguna",
"disable-all-import": "Disable all import",
"disable-all-import-description": "When enabled, all import features are turned off: importing a board from WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea and Forgejo, as well as cloning a board. The import menu options are hidden and the server rejects any import request. Default is off.",
"disable-all-export": "Disable all export",
"disable-all-export-description": "When enabled, all export features are turned off: exporting a board as WeKan JSON, CSV, Excel, Kanboard, or to NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo, and single-attachment export. The export menu options are hidden and the server rejects any export request. Default is off.",
"disable-import-avatars": "Disable import avatars",
"disable-import-avatars-description": "When enabled, avatars (user profile pictures) are never imported. This covers WeKan JSON import, Trello import, and avatar sync from external identity providers on login (LDAP, OIDC/OAuth2). Names and other data are still imported; only avatars are skipped. Default is off.",
"disable-export-avatars": "Disable export avatars",
"disable-export-avatars-description": "When enabled, avatars (user profile pictures) are never included when exporting a board (WeKan JSON and CSV export). Usernames and other data are still exported; only avatars are omitted. Default is off.",
"anonymize-import-users": "Anonymize import users",
"anonymize-import-users-description": "When enabled, importing a WeKan JSON board replaces every imported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside imported card and comment content. No real user identity from the source board is stored. The word \"user\" follows the language of the person importing. Default is off.",
"anonymize-export-users": "Anonymize export users",
"anonymize-export-users-description": "When enabled, exporting a board replaces every exported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside card and comment content. The exported file carries no real user identity. The word \"user\" follows the language of the person exporting. Default is off.",
"features-notifications": "Pemberitahuan",
"disable-activities": "Disable all activities",
"disable-activities-description": "When enabled, activity-feed entries are never recorded and never shown anywhere (board sidebar and card activity tab). No history of who did what is kept. Default is off.",
"disable-notifications": "Disable all notifications",
"disable-notifications-description": "When enabled, WeKan never sends watch notifications (in-app, email or other services) for any activity. Activities can still be recorded (unless also disabled above); only the notifications are suppressed. Default is off.",
"disable-watch": "Disable watch",
"disable-watch-description": "When enabled, the watch feature is turned off — users cannot subscribe to boards, lists or cards to receive notifications, and any attempt to change a watch level is rejected. Default is off.",
"backup": "Backup",
"backup-description": "Back up Attachments, Avatars and/or Data (all text-based collections that are not attachments or avatars). The .zip is streamed directly to the selected storage — no temporary file — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip, containing YYYY_MM_DD-HH_MM_SS/attachments, /avatars and /data. Cloud storages (S3/MinIO, Azure, GCS) use the credentials configured in their storage tabs.",
"backup-data": "Data (text)",
"backup-storage": "Save to storage",
"backup-now": "Backup now",
"backup-done": "Backup completed",
"backup-schedule": "Scheduled backups",
"backup-frequency": "Frequency",
"backup-frequency-off": "Off",
"backup-frequency-daily": "Daily",
"backup-frequency-weekly": "Weekly",
"backup-frequency-monthly": "Monthly",
"backup-time": "Time (HH:MM)",
"backup-day-of-week": "Day of week (weekly)",
"backup-day-of-month": "Day of month (monthly, 1-28)",
"backup-restore": "Pulihkan",
"backup-list": "List backups",
"backup-datetime": "Date and time",
"backup-path": "File path",
"backup-restore-mode": "Restore mode",
"backup-restore-add-missing": "Add missing data only",
"backup-restore-replace-all": "Replace all data",
"backup-restore-select-first": "Select a backup from the list first.",
"backup-restore-confirm": "Restore the selected backup? Attachments/avatars are written to the filesystem and data to the database according to the chosen mode.",
"gcs-project-id": "Project ID",
"gcs-bucket": "Bucket",
"gcs-key-filename": "Service account key file path",
@@ -1987,8 +2094,6 @@
"cpu-cores": "CPU Cores",
"cpu-usage": "CPU Usage",
"current-action": "Current Action",
"database-migration": "Database Migration",
"database-migration-description": "Updating database structure for improved functionality and performance. This process may take several minutes.",
"database-migrations": "Database Migrations",
"days-old": "Days Old",
"duration": "Duration",
+108 -3
View File
@@ -1,6 +1,7 @@
{
"accept": "Aanvaar",
"activity-changedTitle": "changed title to %s of %s",
"activity-changedDescription": "changed description of %s",
"act-deleteCard": "deleted card __card__ from list __list__ at swimlane __swimlane__ at board __board__",
"act-removeBoard": "deleted board __board__",
"act-removeList": "deleted list __list__ at board __board__",
@@ -441,6 +442,7 @@
"createCustomField": "Skep veld",
"createCustomFieldPopup-title": "Skep veld",
"current": "huidige",
"custom-color": "Custom color",
"custom-field-delete-pop": "Daar is geen ongedaan maak nie. Dit sal hierdie pasgemaakte veld van alle kaarte verwyder en sy geskiedenis vernietig.",
"custom-field-checkbox": "Merkblokkie",
"custom-field-currency": "Geldeenheid",
@@ -582,9 +584,12 @@
"fullname": "Volle Naam",
"header-logo-title": "Gaan terug na jou bordbladsy.",
"show-activities": "Wys aktiwiteite",
"hide-activities": "Hide Activities",
"headerBarCreateBoardPopup-title": "Skep bord",
"home": "Tuis",
"import": "Invoer",
"imported-member-no-account": "Imported member — no matching account yet",
"inactive-member": "Inactive member",
"impersonate-user": "Verpersoonlik gebruiker",
"link": "Skakel",
"import-board": "invoerbord",
@@ -895,7 +900,12 @@
"no-name": "(Onbekend)",
"Node_version": "Node weergawe",
"Meteor_version": "Meteoor weergawe",
"MongoDB_version": "MongoDB weergawe",
"Database_type": "Database type",
"Database_commit": "Database commit",
"FerretDB_version": "FerretDB version",
"FerretDB_commit": "FerretDB commit",
"Reactivity_mode": "Reactivity mode (changeStreams / oplog / polling)",
"MongoDB_version": "MongoDB compatible version",
"MongoDB_storage_engine": "MongoDB stoor enjin",
"MongoDB_Oplog_enabled": "MongoDB Oplog geaktiveer",
"OS_Arch": "OS Arch",
@@ -1016,6 +1026,17 @@
"r-workflow-help": "Drag a trigger and an action into the builder, then add the rule. Drag an action onto an existing rule to change what it does.",
"r-drop-trigger": "drop a trigger here",
"r-drop-action": "drop an action here",
"r-w-card-created": "Card is created",
"r-w-card-archived": "Card is archived",
"r-w-card-unarchived": "Card is unarchived",
"r-w-label-added": "Any label is added",
"r-w-label-removed": "Any label is removed",
"r-w-member-added": "A member is added",
"r-w-member-removed": "A member is removed",
"r-w-checklist-added": "A checklist is added",
"r-w-attachment-added": "An attachment is added",
"r-w-every-day-at": "Every day at __time__",
"r-w-set-received-now": "Set received date to now",
"r-export-json": "Export rules to JSON",
"r-export-csv": "Export rules to CSV",
"r-import-json": "Import rules from JSON",
@@ -1069,6 +1090,12 @@
"r-mark-incomplete": "Mark card incomplete",
"r-move-all-cards": "Move all cards in list",
"r-set-date-relative": "Set date relative to now",
"r-later": "later",
"r-unit-minutes": "minute(s)",
"r-unit-hours": "hour(s)",
"r-unit-days": "day(s)",
"r-unit-weeks": "week(s)",
"r-unit-months": "month(s)",
"r-trigger": "Sneller",
"r-action": "Aksie",
"r-when-a-card": "Wanneer 'n kaart",
@@ -1530,6 +1557,10 @@
"rulesReportTitle": "Reëlsverslag",
"boardsReportTitle": "Raadsverslag",
"cardsReportTitle": "Kaarte Verslag",
"impersonationReportTitle": "Impersonation Report",
"impersonation-admin": "Admin",
"impersonation-user": "Impersonated user",
"reason": "Reason",
"copy-swimlane": "Kopieer Swimlane",
"copySwimlanePopup-title": "Kopieer Swimlane",
"display-card-creator": "Vertoonkaartskepper",
@@ -1810,6 +1841,82 @@
"azure-container": "Container",
"gcs-storage": "Google Cloud Storage",
"gcs-storage-description": "Google Cloud Storage.",
"database-migration": "Database migration",
"database-migration-description": "Migrate text-based data (everything except attachments and avatars, which stay on the filesystem) between MongoDB and FerretDB v1 (SQLite). The other database must be running and reachable (default ports: FerretDB mongodb://127.0.0.1:27018, MongoDB mongodb://127.0.0.1:27019; override with WEKAN_FERRETDB_URL / WEKAN_MONGODB_URL). After it finishes, point MONGO_URL at the other database and restart WeKan (on Snap: snap set wekan database=ferretdb or =mongodb).",
"database-migrate-to-ferretdb": "Migrate text data to FerretDB v1 (SQLite)",
"database-migrate-to-mongodb": "Migrate text data to MongoDB",
"database-migration-confirm": "Copy all text-based collections to __db__? Attachments and avatars on the filesystem are not affected. The target database must be running.",
"database-migration-phase": "Phase",
"database-migration-done": "Text-data migration completed.",
"sandstorm-migration-description": "When WeKan runs inside a Sandstorm grain, its old MongoDB 3 database is migrated to FerretDB v1 (SQLite) on first launch, and attachments and avatars are moved from the database to the grain's files/attachments and files/avatars directories. This shows whether that migration succeeded and how much disk space the old raw MongoDB database files still use inside the grain, and lets you delete those raw files to free space.",
"sandstorm-migration-status": "Migration status",
"sandstorm-migration-success": "Succeeded",
"sandstorm-migration-failed": "Failed",
"sandstorm-migration-pending": "Not migrated yet",
"sandstorm-storage-item": "Berging",
"sandstorm-disk-usage": "Disk usage",
"sandstorm-raw-mongodb": "Raw MongoDB 3 database files",
"sandstorm-delete-raw-mongodb": "Delete raw MongoDB database files",
"sandstorm-delete-raw-mongodb-description": "After a successful migration the data lives in FerretDB and on the filesystem, so the old raw MongoDB 3 database files can be deleted to free grain disk space. This cannot be undone.",
"sandstorm-delete-raw-mongodb-confirm": "Delete the raw MongoDB 3 database files to free disk space? The data has already been migrated to FerretDB and the filesystem. This cannot be undone.",
"sandstorm-raw-mongodb-deleted": "Raw MongoDB database files deleted, freed",
"collections": "collections",
"features": "Features",
"features-performance": "Performance",
"cards-loading": "Card loading",
"cards-loading-all": "All cards (default)",
"cards-loading-lazy": "Lazy (only visible cards)",
"cards-loading-description": "How each board loads its cards. All cards: every card is loaded into the browser (simplest, all features work, but a board with thousands of cards sends a lot of data to each viewer). Lazy: each list loads only the cards currently visible (infinite scroll) plus a live count, so very large boards use less memory and bandwidth. Also set by the CARDS_LOADING environment variable.",
"cards-loading-lazy-note": "Lazy card loading is experimental: card counters and WIP limits are accurate, but the Calendar/Table/Gantt views and multi-select reflect only the cards loaded so far. Reload open boards after changing this.",
"features-security": "Security",
"render-links-as-plain-text": "Render links as plain text",
"render-links-as-plain-text-description": "When enabled, all links (both markdown links like [label](url) and raw HTML <a href> tags) are always shown as plain, non-clickable text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This prevents misleading clickable links. Default is off, so links stay clickable.",
"always-show-code-as-text": "Always show all code as plain text",
"always-show-code-as-text-description": "When enabled, rich text is never rendered as markdown or HTML: the entire source is always shown as plain text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This reveals hidden content — HTML comments (<!-- -->), the target of markdown links, JavaScript and any other code — so all code is always visible, not clickable, and not running. Default is off, so markdown is rendered normally.",
"anonymized-user": "gebruiker",
"disable-all-import": "Disable all import",
"disable-all-import-description": "When enabled, all import features are turned off: importing a board from WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea and Forgejo, as well as cloning a board. The import menu options are hidden and the server rejects any import request. Default is off.",
"disable-all-export": "Disable all export",
"disable-all-export-description": "When enabled, all export features are turned off: exporting a board as WeKan JSON, CSV, Excel, Kanboard, or to NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo, and single-attachment export. The export menu options are hidden and the server rejects any export request. Default is off.",
"disable-import-avatars": "Disable import avatars",
"disable-import-avatars-description": "When enabled, avatars (user profile pictures) are never imported. This covers WeKan JSON import, Trello import, and avatar sync from external identity providers on login (LDAP, OIDC/OAuth2). Names and other data are still imported; only avatars are skipped. Default is off.",
"disable-export-avatars": "Disable export avatars",
"disable-export-avatars-description": "When enabled, avatars (user profile pictures) are never included when exporting a board (WeKan JSON and CSV export). Usernames and other data are still exported; only avatars are omitted. Default is off.",
"anonymize-import-users": "Anonymize import users",
"anonymize-import-users-description": "When enabled, importing a WeKan JSON board replaces every imported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside imported card and comment content. No real user identity from the source board is stored. The word \"user\" follows the language of the person importing. Default is off.",
"anonymize-export-users": "Anonymize export users",
"anonymize-export-users-description": "When enabled, exporting a board replaces every exported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside card and comment content. The exported file carries no real user identity. The word \"user\" follows the language of the person exporting. Default is off.",
"features-notifications": "Kennisgewings",
"disable-activities": "Disable all activities",
"disable-activities-description": "When enabled, activity-feed entries are never recorded and never shown anywhere (board sidebar and card activity tab). No history of who did what is kept. Default is off.",
"disable-notifications": "Disable all notifications",
"disable-notifications-description": "When enabled, WeKan never sends watch notifications (in-app, email or other services) for any activity. Activities can still be recorded (unless also disabled above); only the notifications are suppressed. Default is off.",
"disable-watch": "Disable watch",
"disable-watch-description": "When enabled, the watch feature is turned off — users cannot subscribe to boards, lists or cards to receive notifications, and any attempt to change a watch level is rejected. Default is off.",
"backup": "Backup",
"backup-description": "Back up Attachments, Avatars and/or Data (all text-based collections that are not attachments or avatars). The .zip is streamed directly to the selected storage — no temporary file — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip, containing YYYY_MM_DD-HH_MM_SS/attachments, /avatars and /data. Cloud storages (S3/MinIO, Azure, GCS) use the credentials configured in their storage tabs.",
"backup-data": "Data (text)",
"backup-storage": "Save to storage",
"backup-now": "Backup now",
"backup-done": "Backup completed",
"backup-schedule": "Scheduled backups",
"backup-frequency": "Frequency",
"backup-frequency-off": "Off",
"backup-frequency-daily": "Daily",
"backup-frequency-weekly": "Weekly",
"backup-frequency-monthly": "Monthly",
"backup-time": "Time (HH:MM)",
"backup-day-of-week": "Day of week (weekly)",
"backup-day-of-month": "Day of month (monthly, 1-28)",
"backup-restore": "Herstel",
"backup-list": "List backups",
"backup-datetime": "Date and time",
"backup-path": "File path",
"backup-restore-mode": "Restore mode",
"backup-restore-add-missing": "Add missing data only",
"backup-restore-replace-all": "Replace all data",
"backup-restore-select-first": "Select a backup from the list first.",
"backup-restore-confirm": "Restore the selected backup? Attachments/avatars are written to the filesystem and data to the database according to the chosen mode.",
"gcs-project-id": "Project ID",
"gcs-bucket": "Bucket",
"gcs-key-filename": "Service account key file path",
@@ -1987,8 +2094,6 @@
"cpu-cores": "SVE-kerns",
"cpu-usage": "SVE Gebruik",
"current-action": "Huidige Aksie",
"database-migration": "Databasismigrasie",
"database-migration-description": "Opdatering van databasisstruktuur vir verbeterde funksionaliteit en werkverrigting. Hierdie proses kan 'n paar minute neem.",
"database-migrations": "Databasismigrasies",
"days-old": "Dae oud",
"duration": "Duur",
+108 -3
View File
@@ -1,6 +1,7 @@
{
"accept": "Aanvaar",
"activity-changedTitle": "changed title to %s of %s",
"activity-changedDescription": "changed description of %s",
"act-deleteCard": "deleted card __card__ from list __list__ at swimlane __swimlane__ at board __board__",
"act-removeBoard": "deleted board __board__",
"act-removeList": "deleted list __list__ at board __board__",
@@ -441,6 +442,7 @@
"createCustomField": "Skep veld",
"createCustomFieldPopup-title": "Skep veld",
"current": "huidige",
"custom-color": "Custom color",
"custom-field-delete-pop": "Daar is geen ongedaan maak nie. Dit sal hierdie pasgemaakte veld van alle kaarte verwyder en sy geskiedenis vernietig.",
"custom-field-checkbox": "Merkblokkie",
"custom-field-currency": "Geldeenheid",
@@ -582,9 +584,12 @@
"fullname": "Volle Naam",
"header-logo-title": "Gaan terug na jou bordbladsy.",
"show-activities": "Wys aktiwiteite",
"hide-activities": "Hide Activities",
"headerBarCreateBoardPopup-title": "Skep bord",
"home": "Tuis",
"import": "Invoer",
"imported-member-no-account": "Imported member — no matching account yet",
"inactive-member": "Inactive member",
"impersonate-user": "Verpersoonlik gebruiker",
"link": "Skakel",
"import-board": "invoerbord",
@@ -895,7 +900,12 @@
"no-name": "(Onbekend)",
"Node_version": "Node weergawe",
"Meteor_version": "Meteoor weergawe",
"MongoDB_version": "MongoDB weergawe",
"Database_type": "Database type",
"Database_commit": "Database commit",
"FerretDB_version": "FerretDB version",
"FerretDB_commit": "FerretDB commit",
"Reactivity_mode": "Reactivity mode (changeStreams / oplog / polling)",
"MongoDB_version": "MongoDB compatible version",
"MongoDB_storage_engine": "MongoDB stoor enjin",
"MongoDB_Oplog_enabled": "MongoDB Oplog geaktiveer",
"OS_Arch": "OS Arch",
@@ -1016,6 +1026,17 @@
"r-workflow-help": "Drag a trigger and an action into the builder, then add the rule. Drag an action onto an existing rule to change what it does.",
"r-drop-trigger": "drop a trigger here",
"r-drop-action": "drop an action here",
"r-w-card-created": "Card is created",
"r-w-card-archived": "Card is archived",
"r-w-card-unarchived": "Card is unarchived",
"r-w-label-added": "Any label is added",
"r-w-label-removed": "Any label is removed",
"r-w-member-added": "A member is added",
"r-w-member-removed": "A member is removed",
"r-w-checklist-added": "A checklist is added",
"r-w-attachment-added": "An attachment is added",
"r-w-every-day-at": "Every day at __time__",
"r-w-set-received-now": "Set received date to now",
"r-export-json": "Export rules to JSON",
"r-export-csv": "Export rules to CSV",
"r-import-json": "Import rules from JSON",
@@ -1069,6 +1090,12 @@
"r-mark-incomplete": "Mark card incomplete",
"r-move-all-cards": "Move all cards in list",
"r-set-date-relative": "Set date relative to now",
"r-later": "later",
"r-unit-minutes": "minute(s)",
"r-unit-hours": "hour(s)",
"r-unit-days": "day(s)",
"r-unit-weeks": "week(s)",
"r-unit-months": "month(s)",
"r-trigger": "Sneller",
"r-action": "Aksie",
"r-when-a-card": "Wanneer 'n kaart",
@@ -1530,6 +1557,10 @@
"rulesReportTitle": "Reëlsverslag",
"boardsReportTitle": "Raadsverslag",
"cardsReportTitle": "Kaarte Verslag",
"impersonationReportTitle": "Impersonation Report",
"impersonation-admin": "Admin",
"impersonation-user": "Impersonated user",
"reason": "Reason",
"copy-swimlane": "Kopieer Swimlane",
"copySwimlanePopup-title": "Kopieer Swimlane",
"display-card-creator": "Vertoonkaartskepper",
@@ -1810,6 +1841,82 @@
"azure-container": "Container",
"gcs-storage": "Google Cloud Storage",
"gcs-storage-description": "Google Cloud Storage.",
"database-migration": "Database migration",
"database-migration-description": "Migrate text-based data (everything except attachments and avatars, which stay on the filesystem) between MongoDB and FerretDB v1 (SQLite). The other database must be running and reachable (default ports: FerretDB mongodb://127.0.0.1:27018, MongoDB mongodb://127.0.0.1:27019; override with WEKAN_FERRETDB_URL / WEKAN_MONGODB_URL). After it finishes, point MONGO_URL at the other database and restart WeKan (on Snap: snap set wekan database=ferretdb or =mongodb).",
"database-migrate-to-ferretdb": "Migrate text data to FerretDB v1 (SQLite)",
"database-migrate-to-mongodb": "Migrate text data to MongoDB",
"database-migration-confirm": "Copy all text-based collections to __db__? Attachments and avatars on the filesystem are not affected. The target database must be running.",
"database-migration-phase": "Phase",
"database-migration-done": "Text-data migration completed.",
"sandstorm-migration-description": "When WeKan runs inside a Sandstorm grain, its old MongoDB 3 database is migrated to FerretDB v1 (SQLite) on first launch, and attachments and avatars are moved from the database to the grain's files/attachments and files/avatars directories. This shows whether that migration succeeded and how much disk space the old raw MongoDB database files still use inside the grain, and lets you delete those raw files to free space.",
"sandstorm-migration-status": "Migration status",
"sandstorm-migration-success": "Succeeded",
"sandstorm-migration-failed": "Failed",
"sandstorm-migration-pending": "Not migrated yet",
"sandstorm-storage-item": "Berging",
"sandstorm-disk-usage": "Disk usage",
"sandstorm-raw-mongodb": "Raw MongoDB 3 database files",
"sandstorm-delete-raw-mongodb": "Delete raw MongoDB database files",
"sandstorm-delete-raw-mongodb-description": "After a successful migration the data lives in FerretDB and on the filesystem, so the old raw MongoDB 3 database files can be deleted to free grain disk space. This cannot be undone.",
"sandstorm-delete-raw-mongodb-confirm": "Delete the raw MongoDB 3 database files to free disk space? The data has already been migrated to FerretDB and the filesystem. This cannot be undone.",
"sandstorm-raw-mongodb-deleted": "Raw MongoDB database files deleted, freed",
"collections": "collections",
"features": "Features",
"features-performance": "Performance",
"cards-loading": "Card loading",
"cards-loading-all": "All cards (default)",
"cards-loading-lazy": "Lazy (only visible cards)",
"cards-loading-description": "How each board loads its cards. All cards: every card is loaded into the browser (simplest, all features work, but a board with thousands of cards sends a lot of data to each viewer). Lazy: each list loads only the cards currently visible (infinite scroll) plus a live count, so very large boards use less memory and bandwidth. Also set by the CARDS_LOADING environment variable.",
"cards-loading-lazy-note": "Lazy card loading is experimental: card counters and WIP limits are accurate, but the Calendar/Table/Gantt views and multi-select reflect only the cards loaded so far. Reload open boards after changing this.",
"features-security": "Security",
"render-links-as-plain-text": "Render links as plain text",
"render-links-as-plain-text-description": "When enabled, all links (both markdown links like [label](url) and raw HTML <a href> tags) are always shown as plain, non-clickable text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This prevents misleading clickable links. Default is off, so links stay clickable.",
"always-show-code-as-text": "Always show all code as plain text",
"always-show-code-as-text-description": "When enabled, rich text is never rendered as markdown or HTML: the entire source is always shown as plain text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This reveals hidden content — HTML comments (<!-- -->), the target of markdown links, JavaScript and any other code — so all code is always visible, not clickable, and not running. Default is off, so markdown is rendered normally.",
"anonymized-user": "gebruiker",
"disable-all-import": "Disable all import",
"disable-all-import-description": "When enabled, all import features are turned off: importing a board from WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea and Forgejo, as well as cloning a board. The import menu options are hidden and the server rejects any import request. Default is off.",
"disable-all-export": "Disable all export",
"disable-all-export-description": "When enabled, all export features are turned off: exporting a board as WeKan JSON, CSV, Excel, Kanboard, or to NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo, and single-attachment export. The export menu options are hidden and the server rejects any export request. Default is off.",
"disable-import-avatars": "Disable import avatars",
"disable-import-avatars-description": "When enabled, avatars (user profile pictures) are never imported. This covers WeKan JSON import, Trello import, and avatar sync from external identity providers on login (LDAP, OIDC/OAuth2). Names and other data are still imported; only avatars are skipped. Default is off.",
"disable-export-avatars": "Disable export avatars",
"disable-export-avatars-description": "When enabled, avatars (user profile pictures) are never included when exporting a board (WeKan JSON and CSV export). Usernames and other data are still exported; only avatars are omitted. Default is off.",
"anonymize-import-users": "Anonymize import users",
"anonymize-import-users-description": "When enabled, importing a WeKan JSON board replaces every imported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside imported card and comment content. No real user identity from the source board is stored. The word \"user\" follows the language of the person importing. Default is off.",
"anonymize-export-users": "Anonymize export users",
"anonymize-export-users-description": "When enabled, exporting a board replaces every exported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside card and comment content. The exported file carries no real user identity. The word \"user\" follows the language of the person exporting. Default is off.",
"features-notifications": "Kennisgewings",
"disable-activities": "Disable all activities",
"disable-activities-description": "When enabled, activity-feed entries are never recorded and never shown anywhere (board sidebar and card activity tab). No history of who did what is kept. Default is off.",
"disable-notifications": "Disable all notifications",
"disable-notifications-description": "When enabled, WeKan never sends watch notifications (in-app, email or other services) for any activity. Activities can still be recorded (unless also disabled above); only the notifications are suppressed. Default is off.",
"disable-watch": "Disable watch",
"disable-watch-description": "When enabled, the watch feature is turned off — users cannot subscribe to boards, lists or cards to receive notifications, and any attempt to change a watch level is rejected. Default is off.",
"backup": "Backup",
"backup-description": "Back up Attachments, Avatars and/or Data (all text-based collections that are not attachments or avatars). The .zip is streamed directly to the selected storage — no temporary file — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip, containing YYYY_MM_DD-HH_MM_SS/attachments, /avatars and /data. Cloud storages (S3/MinIO, Azure, GCS) use the credentials configured in their storage tabs.",
"backup-data": "Data (text)",
"backup-storage": "Save to storage",
"backup-now": "Backup now",
"backup-done": "Backup completed",
"backup-schedule": "Scheduled backups",
"backup-frequency": "Frequency",
"backup-frequency-off": "Off",
"backup-frequency-daily": "Daily",
"backup-frequency-weekly": "Weekly",
"backup-frequency-monthly": "Monthly",
"backup-time": "Time (HH:MM)",
"backup-day-of-week": "Day of week (weekly)",
"backup-day-of-month": "Day of month (monthly, 1-28)",
"backup-restore": "Herstel",
"backup-list": "List backups",
"backup-datetime": "Date and time",
"backup-path": "File path",
"backup-restore-mode": "Restore mode",
"backup-restore-add-missing": "Add missing data only",
"backup-restore-replace-all": "Replace all data",
"backup-restore-select-first": "Select a backup from the list first.",
"backup-restore-confirm": "Restore the selected backup? Attachments/avatars are written to the filesystem and data to the database according to the chosen mode.",
"gcs-project-id": "Project ID",
"gcs-bucket": "Bucket",
"gcs-key-filename": "Service account key file path",
@@ -1987,8 +2094,6 @@
"cpu-cores": "SVE-kerns",
"cpu-usage": "SVE Gebruik",
"current-action": "Huidige Aksie",
"database-migration": "Databasismigrasie",
"database-migration-description": "Opdatering van databasisstruktuur vir verbeterde funksionaliteit en werkverrigting. Hierdie proses kan 'n paar minute neem.",
"database-migrations": "Databasismigrasies",
"days-old": "Dae oud",
"duration": "Duur",
+108 -3
View File
@@ -1,6 +1,7 @@
{
"accept": "قبول",
"activity-changedTitle": "changed title to %s of %s",
"activity-changedDescription": "changed description of %s",
"act-deleteCard": "deleted card __card__ from list __list__ at swimlane __swimlane__ at board __board__",
"act-removeBoard": "deleted board __board__",
"act-removeList": "deleted list __list__ at board __board__",
@@ -441,6 +442,7 @@
"createCustomField": "انشاء حقل",
"createCustomFieldPopup-title": "انشاء حقل",
"current": "الحالي",
"custom-color": "Custom color",
"custom-field-delete-pop": "این اقدام فیلد سفارشی را بهمراه تمامی تاریخچه آن از کارت ها پاک می کند و برگشت پذیر نمی باشد",
"custom-field-checkbox": "جعبه انتخابی",
"custom-field-currency": "عملة",
@@ -582,9 +584,12 @@
"fullname": "الإسم الكامل",
"header-logo-title": "الرجوع إلى صفحة اللوحات",
"show-activities": "عرض الأنشطة",
"hide-activities": "Hide Activities",
"headerBarCreateBoardPopup-title": "إنشاء لوحة",
"home": "الرئيسية",
"import": "وارد کردن",
"imported-member-no-account": "Imported member — no matching account yet",
"inactive-member": "Inactive member",
"impersonate-user": "جعل هویت کاربر",
"link": "رابط",
"import-board": "استيراد لوحة",
@@ -895,7 +900,12 @@
"no-name": "(غير معروف)",
"Node_version": "إصدار النود",
"Meteor_version": "نسخه متئور",
"MongoDB_version": "ورژن MongoDB",
"Database_type": "Database type",
"Database_commit": "Database commit",
"FerretDB_version": "FerretDB version",
"FerretDB_commit": "FerretDB commit",
"Reactivity_mode": "Reactivity mode (changeStreams / oplog / polling)",
"MongoDB_version": "MongoDB compatible version",
"MongoDB_storage_engine": "موتور ذخیره سازی MongoDB",
"MongoDB_Oplog_enabled": "MongoDB Oplog فعال است",
"OS_Arch": "معمارية نظام التشغيل",
@@ -1016,6 +1026,17 @@
"r-workflow-help": "Drag a trigger and an action into the builder, then add the rule. Drag an action onto an existing rule to change what it does.",
"r-drop-trigger": "drop a trigger here",
"r-drop-action": "drop an action here",
"r-w-card-created": "Card is created",
"r-w-card-archived": "Card is archived",
"r-w-card-unarchived": "Card is unarchived",
"r-w-label-added": "Any label is added",
"r-w-label-removed": "Any label is removed",
"r-w-member-added": "A member is added",
"r-w-member-removed": "A member is removed",
"r-w-checklist-added": "A checklist is added",
"r-w-attachment-added": "An attachment is added",
"r-w-every-day-at": "Every day at __time__",
"r-w-set-received-now": "Set received date to now",
"r-export-json": "Export rules to JSON",
"r-export-csv": "Export rules to CSV",
"r-import-json": "Import rules from JSON",
@@ -1069,6 +1090,12 @@
"r-mark-incomplete": "Mark card incomplete",
"r-move-all-cards": "Move all cards in list",
"r-set-date-relative": "Set date relative to now",
"r-later": "later",
"r-unit-minutes": "minute(s)",
"r-unit-hours": "hour(s)",
"r-unit-days": "day(s)",
"r-unit-weeks": "week(s)",
"r-unit-months": "month(s)",
"r-trigger": "تریگر",
"r-action": "عملیات",
"r-when-a-card": "زمانی که کارت",
@@ -1530,6 +1557,10 @@
"rulesReportTitle": "گزارش قوانین",
"boardsReportTitle": "Rapport sur les tableaux",
"cardsReportTitle": "Rapport sur les cartes",
"impersonationReportTitle": "Impersonation Report",
"impersonation-admin": "المدير",
"impersonation-user": "Impersonated user",
"reason": "Reason",
"copy-swimlane": "کپی از مسیر",
"copySwimlanePopup-title": "کپی از مسیر",
"display-card-creator": "Afficher le créateur de la carte",
@@ -1810,6 +1841,82 @@
"azure-container": "Container",
"gcs-storage": "Google Cloud Storage",
"gcs-storage-description": "Google Cloud Storage.",
"database-migration": "Database migration",
"database-migration-description": "Migrate text-based data (everything except attachments and avatars, which stay on the filesystem) between MongoDB and FerretDB v1 (SQLite). The other database must be running and reachable (default ports: FerretDB mongodb://127.0.0.1:27018, MongoDB mongodb://127.0.0.1:27019; override with WEKAN_FERRETDB_URL / WEKAN_MONGODB_URL). After it finishes, point MONGO_URL at the other database and restart WeKan (on Snap: snap set wekan database=ferretdb or =mongodb).",
"database-migrate-to-ferretdb": "Migrate text data to FerretDB v1 (SQLite)",
"database-migrate-to-mongodb": "Migrate text data to MongoDB",
"database-migration-confirm": "Copy all text-based collections to __db__? Attachments and avatars on the filesystem are not affected. The target database must be running.",
"database-migration-phase": "Phase",
"database-migration-done": "Text-data migration completed.",
"sandstorm-migration-description": "When WeKan runs inside a Sandstorm grain, its old MongoDB 3 database is migrated to FerretDB v1 (SQLite) on first launch, and attachments and avatars are moved from the database to the grain's files/attachments and files/avatars directories. This shows whether that migration succeeded and how much disk space the old raw MongoDB database files still use inside the grain, and lets you delete those raw files to free space.",
"sandstorm-migration-status": "Migration status",
"sandstorm-migration-success": "Succeeded",
"sandstorm-migration-failed": "Failed",
"sandstorm-migration-pending": "Not migrated yet",
"sandstorm-storage-item": "Stockage",
"sandstorm-disk-usage": "Disk usage",
"sandstorm-raw-mongodb": "Raw MongoDB 3 database files",
"sandstorm-delete-raw-mongodb": "Delete raw MongoDB database files",
"sandstorm-delete-raw-mongodb-description": "After a successful migration the data lives in FerretDB and on the filesystem, so the old raw MongoDB 3 database files can be deleted to free grain disk space. This cannot be undone.",
"sandstorm-delete-raw-mongodb-confirm": "Delete the raw MongoDB 3 database files to free disk space? The data has already been migrated to FerretDB and the filesystem. This cannot be undone.",
"sandstorm-raw-mongodb-deleted": "Raw MongoDB database files deleted, freed",
"collections": "collections",
"features": "Features",
"features-performance": "Performance",
"cards-loading": "Card loading",
"cards-loading-all": "All cards (default)",
"cards-loading-lazy": "Lazy (only visible cards)",
"cards-loading-description": "How each board loads its cards. All cards: every card is loaded into the browser (simplest, all features work, but a board with thousands of cards sends a lot of data to each viewer). Lazy: each list loads only the cards currently visible (infinite scroll) plus a live count, so very large boards use less memory and bandwidth. Also set by the CARDS_LOADING environment variable.",
"cards-loading-lazy-note": "Lazy card loading is experimental: card counters and WIP limits are accurate, but the Calendar/Table/Gantt views and multi-select reflect only the cards loaded so far. Reload open boards after changing this.",
"features-security": "Security",
"render-links-as-plain-text": "Render links as plain text",
"render-links-as-plain-text-description": "When enabled, all links (both markdown links like [label](url) and raw HTML <a href> tags) are always shown as plain, non-clickable text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This prevents misleading clickable links. Default is off, so links stay clickable.",
"always-show-code-as-text": "Always show all code as plain text",
"always-show-code-as-text-description": "When enabled, rich text is never rendered as markdown or HTML: the entire source is always shown as plain text in every rich text field (board and card titles, descriptions, comments, checklists, etc.). This reveals hidden content — HTML comments (<!-- -->), the target of markdown links, JavaScript and any other code — so all code is always visible, not clickable, and not running. Default is off, so markdown is rendered normally.",
"anonymized-user": "مستخدم",
"disable-all-import": "Disable all import",
"disable-all-import-description": "When enabled, all import features are turned off: importing a board from WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea and Forgejo, as well as cloning a board. The import menu options are hidden and the server rejects any import request. Default is off.",
"disable-all-export": "Disable all export",
"disable-all-export-description": "When enabled, all export features are turned off: exporting a board as WeKan JSON, CSV, Excel, Kanboard, or to NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo, and single-attachment export. The export menu options are hidden and the server rejects any export request. Default is off.",
"disable-import-avatars": "Disable import avatars",
"disable-import-avatars-description": "When enabled, avatars (user profile pictures) are never imported. This covers WeKan JSON import, Trello import, and avatar sync from external identity providers on login (LDAP, OIDC/OAuth2). Names and other data are still imported; only avatars are skipped. Default is off.",
"disable-export-avatars": "Disable export avatars",
"disable-export-avatars-description": "When enabled, avatars (user profile pictures) are never included when exporting a board (WeKan JSON and CSV export). Usernames and other data are still exported; only avatars are omitted. Default is off.",
"anonymize-import-users": "Anonymize import users",
"anonymize-import-users-description": "When enabled, importing a WeKan JSON board replaces every imported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside imported card and comment content. No real user identity from the source board is stored. The word \"user\" follows the language of the person importing. Default is off.",
"anonymize-export-users": "Anonymize export users",
"anonymize-export-users-description": "When enabled, exporting a board replaces every exported user's username, full name and initials with counter placeholders (user1, user2, ...), drops their avatar, and rewrites @username mentions (and the requested-by / assigned-by fields) inside card and comment content. The exported file carries no real user identity. The word \"user\" follows the language of the person exporting. Default is off.",
"features-notifications": "اعلان‌ها",
"disable-activities": "Disable all activities",
"disable-activities-description": "When enabled, activity-feed entries are never recorded and never shown anywhere (board sidebar and card activity tab). No history of who did what is kept. Default is off.",
"disable-notifications": "Disable all notifications",
"disable-notifications-description": "When enabled, WeKan never sends watch notifications (in-app, email or other services) for any activity. Activities can still be recorded (unless also disabled above); only the notifications are suppressed. Default is off.",
"disable-watch": "Disable watch",
"disable-watch-description": "When enabled, the watch feature is turned off — users cannot subscribe to boards, lists or cards to receive notifications, and any attempt to change a watch level is rejected. Default is off.",
"backup": "Backup",
"backup-description": "Back up Attachments, Avatars and/or Data (all text-based collections that are not attachments or avatars). The .zip is streamed directly to the selected storage — no temporary file — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip, containing YYYY_MM_DD-HH_MM_SS/attachments, /avatars and /data. Cloud storages (S3/MinIO, Azure, GCS) use the credentials configured in their storage tabs.",
"backup-data": "Data (text)",
"backup-storage": "Save to storage",
"backup-now": "Backup now",
"backup-done": "Backup completed",
"backup-schedule": "Scheduled backups",
"backup-frequency": "Frequency",
"backup-frequency-off": "Off",
"backup-frequency-daily": "Daily",
"backup-frequency-weekly": "Weekly",
"backup-frequency-monthly": "Monthly",
"backup-time": "Time (HH:MM)",
"backup-day-of-week": "Day of week (weekly)",
"backup-day-of-month": "Day of month (monthly, 1-28)",
"backup-restore": "استعادة",
"backup-list": "List backups",
"backup-datetime": "Date and time",
"backup-path": "File path",
"backup-restore-mode": "Restore mode",
"backup-restore-add-missing": "Add missing data only",
"backup-restore-replace-all": "Replace all data",
"backup-restore-select-first": "Select a backup from the list first.",
"backup-restore-confirm": "Restore the selected backup? Attachments/avatars are written to the filesystem and data to the database according to the chosen mode.",
"gcs-project-id": "Project ID",
"gcs-bucket": "Bucket",
"gcs-key-filename": "Service account key file path",
@@ -1987,8 +2094,6 @@
"cpu-cores": "Cœurs du CPU ",
"cpu-usage": "Utilisation du CPU",
"current-action": "Current Action",
"database-migration": "Migration base de données",
"database-migration-description": "Updating database structure for improved functionality and performance. This process may take several minutes.",
"database-migrations": "Migrations base de données",
"days-old": "Jours d'ancienneté",
"duration": "Durée",
Loaded 100 of 435 files, more files were not shown because too many files have changed in this diff. Show more