Compare commits

...
21 Commits
Author SHA1 Message Date
github-actions[bot] 736d308860 Bump versions for v9.78 2026-07-06 09:03:57 +00:00
Lauri Ojansivu 9eca7a3941 Updated ChangeLog. 2026-07-06 11:54:17 +03:00
Lauri Ojansivu af436e16c4 Updated ChangeLog. 2026-07-06 11:52:49 +03:00
Lauri Ojansivu b1b414e850 - **Fix "Internal Server Error" when signing up despite the account being created**:
Registering a new account showed a red "Internal server error" on the sign-up form even
  though the account was created and could sign in — which typically happens when SMTP is not
  configured. Root cause: useraccounts' `ATCreateUserServer` creates the account and then
  calls `Accounts.sendVerificationEmail()` (because `sendVerificationEmail: true`); when SMTP
  is missing/misconfigured that send throws **after** the user row is inserted, and with no
  try/catch the exception leaves the createUser method as an opaque HTTP 500. The verification
  email is best-effort at sign-up, so `Accounts.sendVerificationEmail` is now wrapped to log
  and swallow a transport failure — registration completes and redirects to sign-in — while an
  "already verified" error is re-thrown so the resend-verification flow still reports it. This
  mirrors the [#5706](https://github.com/wekan/wekan/issues/5706) reset-password hardening.
  Covered by `tests/verificationEmail.test.cjs`.

Thanks to Firas-Git and xet7.

Fixes #5672
2026-07-06 11:50:28 +03:00
Lauri Ojansivu 57da3885a2 Updated ChangeLog. 2026-07-06 11:38:57 +03:00
Lauri Ojansivu cb9c897309 - **Fix can not add members to a Linked Card**:
A **linked card** (created by "Link card to this card") is only a placeholder on the board
  that links it — its members are stored on the **real card** it points at (`linkedId`), which
  lives on another board, and `Card.getMembers()` / `assignMember()` / `unassignMember()`
  already read and write that real card. But the member **picker** listed the members of the
  board you were *viewing* the linked card on, not the board the real card lives on. So on a
  board that links a card from another board, the picker offered the wrong set of members and
  toggling them did not behave as a consistent add/remove — "can not add members to the linked
  card". The picker now resolves a linked card to its real card's board and offers *that*
  board's active members (matching where the membership is actually stored), falling back to
  the current board for normal cards or when the real card isn't loaded yet. Extracted the
  target-card/target-board resolution into `models/lib/linkedCardMembers.js`, covered by
  `tests/linkedCardMembers.test.cjs`.

Thanks to ITT5 and xet7 !

Fixes #5676
2026-07-06 11:35:56 +03:00
Lauri Ojansivu 81f07baeb8 Updated ChangeLog. 2026-07-06 11:21:41 +03:00
Lauri Ojansivu 001c258f96 - **Fix can't search numbers in custom fields**:
Searching a board for the value of a **number** or **currency** custom field (e.g. a
  transaction number `2025001`, or a currency amount `123`) found nothing. Those field types
  store their value as a JS **Number** (the inputs save `parseInt(...)` / `Number(...)`), but
  `Board.searchCards()` only matched custom fields with `{ value: <regex> }` — and a MongoDB /
  Minimongo regex only matches **string** values, so it silently skipped every numeric custom
  field. The search now also adds an exact numeric-equality clause when the term is a plain
  number (a comma is accepted as a decimal separator, matching the currency input), so numeric
  custom fields match too; text/title/description matching is unchanged. Card search runs
  against Minimongo on the client, which — like MongoDB — cannot regex a numeric field, so
  equality is the correct cross-environment match. Covered by `tests/cardSearch.test.cjs`.

Thanks to MarcusDger and xet7.

Fixes #5680
2026-07-06 11:19:15 +03:00
Lauri Ojansivu 9d77b9d7b7 Updated ChangeLog. 2026-07-06 10:57:03 +03:00
Lauri Ojansivu 2242da4176 - **Fix "Removed nonexistent document" crash during notification_cleanup**:
The scheduled notification-tray cleanup logged `Exception in removed observeChanges
  callback: Error: Removed nonexistent document …`. The crash itself came from the old
  `cottz:publish-relations` package (since replaced by `reywood:publish-composite`), but the
  cleanup that provoked it still fired one **un-awaited** `removeNotification()` per expired
  notification — a separate `Users.update` `$pull` each time — so a user with K stale
  notifications produced K writes to the Users collection, and every publication that
  republishes user documents re-ran its observers K times in quick bursts (the churn that
  surfaced the removed-document error), while any rejected write went unhandled. The cleanup
  now scans only users that have notifications and prunes each user's stale notifications in a
  single awaited `$pull … $in`. It also removes an activity's notifications only when *every*
  entry for that activity is read and past its removal age (so a freshly re-created unread
  notification sharing an activity id is not dropped), and guards missing/invalid `read`
  timestamps. Covered by `tests/notificationCleanup.test.cjs`.

Thanks to xet7.

Fixes #5685
2026-07-06 10:55:29 +03:00
Lauri Ojansivu 0a3a28ef29 Updated ChangeLog. 2026-07-06 10:46:27 +03:00
Lauri Ojansivu dcf5aa65b0 - **Fix impossible to select another board in rules**:
In the IFTTT-Rules "Move card to the board" and "Link card to the board" actions, the
  board dropdown was empty for some users — the current board included — while colleagues
  in the same company could pick boards normally. Root cause: the dropdown filtered the
  (already access-scoped) client cache with `'members.userId': me`, i.e. it only kept boards
  where the user has a *direct* member entry. A user who reaches a board through an
  Organization, Team or email-domain share — but is not listed individually in
  `board.members` — matched nothing, so the whole selector came up empty. The dropdown now
  filters by the same visibility rule as `Boards.userBoards()` (public OR active member OR
  active org OR active team OR active domain), so org/team/domain-shared boards appear too,
  while archived boards, template containers, the user's templates board and internal helper
  boards stay excluded.

Thanks to Augustin356 and xet7 !

Fixes #5698
2026-07-06 10:43:26 +03:00
Lauri Ojansivu dd3be179ca Updated ChangeLog. 2026-07-06 10:33:15 +03:00
Lauri Ojansivu bc1f149ea3 - **Fix board disappeared after adding another user**:
A board admin adding another user could make the whole board silently vanish from a
  user's board list, with no archive and "nothing out of order" in the logs. Root cause:
  the `setBoardTeams` server method (used by the board Teams/Members management popups)
  blindly overwrote the board's entire `members` array with a snapshot sent by the client.
  When that client's board document was stale — e.g. a member had just been added via
  `inviteUserToBoard` on the server and the change had not yet propagated to the client —
  the overwrite dropped members, and in the worst case the board's own admin, so the board
  no longer matched the board-list publication (which requires an active membership) and
  disappeared. Because a wholesale `$set: { members }` never passes through
  `foreachRemovedMember()`, no `removeBoardMember` activity was logged and no
  card/watcher/star cleanup ran, which is why the logs looked normal. `setBoardTeams` now
  reconciles against the authoritative server-side members instead of trusting the client
  snapshot: it never drops an active admin, keeps every existing member the client still
  lists, adds the members the client introduces, and only removes non-admin members the
  client explicitly omitted (an intentional team-leave) — logging and cleaning up each such
  removal so it is auditable rather than silent.

Thanks to DVNBLMHC and xet7 !

Fixes #5730
2026-07-06 10:33:15 +03:00
github-actions[bot] 0dd11ea573 Bump versions for v9.77 2026-07-06 00:01:08 +00:00
Lauri Ojansivu 5feb83d919 Updated ChangeLog. 2026-07-06 02:51:25 +03:00
Lauri Ojansivu 8980668686 Updated ChangeLog. 2026-07-06 02:47:52 +03:00
Lauri Ojansivu 0d4e48ddd6 Retry snapcraft install in the release-all.yml snap job
The Snap Store intermittently rejects `sudo snap install snapcraft --classic`
with "error: cannot install snapcraft: too many requests" (a 429-style rate
limit) that instantly failed the whole snap job before any build ran. Retry up
to 5 times with 30s backoff so one transient store throttle no longer fails the
release, matching the remote-build retry already in the same job.
2026-07-06 02:47:32 +03:00
Lauri Ojansivu a01b175895 Updated ChangeLog. 2026-07-06 02:37:29 +03:00
Lauri Ojansivu 83f63c9efa Updated Code of Conduct.
Thanks to xet7 !
2026-07-06 02:37:28 +03:00
Lauri Ojansivu 3b031bd901 Updated Code of Conduct.
Thanks to xet7 !
2026-07-06 02:37:28 +03:00
30 changed files with 1417 additions and 50 deletions

No files matched your search

+14 -1
View File
@@ -800,7 +800,20 @@ jobs:
fetch-depth: 0
- name: Install snapcraft
run: sudo snap install snapcraft --classic
# 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).
run: |
set -uo pipefail
for i in 1 2 3 4 5; do
sudo snap install snapcraft --classic && exit 0
echo "snapcraft install attempt $i/5 failed (Snap Store throttle?); retrying in 30s..."
sleep 30
done
echo "::error::snapcraft install failed after 5 attempts (Snap Store rate limit)."
exit 1
- name: Provide Launchpad credentials for remote-build
env:
+116 -2
View File
@@ -30,8 +30,6 @@ Versions:
Carried to a future release — investigated this session but not finished, with findings recorded for whoever picks
them up next.
- **Need a runtime reproduction before a safe fix:**
[#5730](https://github.com/wekan/wekan/issues/5730) (board vanished after adding a user — no logs/repro).
- **Need specific infrastructure we cannot reproduce/verify here (left for environment owners):**
[#4560](https://github.com/wekan/wekan/issues/4560) (LDAP→OIDC), [#3707](https://github.com/wekan/wekan/issues/3707)
& [#3700](https://github.com/wekan/wekan/issues/3700) (LDAP), [#3575](https://github.com/wekan/wekan/issues/3575)
@@ -41,6 +39,122 @@ them up next.
[#3823](https://github.com/wekan/wekan/issues/3823), [#3138](https://github.com/wekan/wekan/issues/3138),
[#2204](https://github.com/wekan/wekan/issues/2204) (restrict permanent delete to the Admin role).
# v9.78 2026-07-06 WeKan ® release
This release fixes the following bugs:
- **[Fix "Internal Server Error" when signing up despite the account being created](https://github.com/wekan/wekan/commit/b1b414e850c5494dd3849f8dab6db44fecef0196)**:
Registering a new account showed a red "Internal server error" on the sign-up form even
though the account was created and could sign in — which typically happens when SMTP is not
configured. Root cause: useraccounts' `ATCreateUserServer` creates the account and then
calls `Accounts.sendVerificationEmail()` (because `sendVerificationEmail: true`); when SMTP
is missing/misconfigured that send throws **after** the user row is inserted, and with no
try/catch the exception leaves the createUser method as an opaque HTTP 500. The verification
email is best-effort at sign-up, so `Accounts.sendVerificationEmail` is now wrapped to log
and swallow a transport failure — registration completes and redirects to sign-in — while an
"already verified" error is re-thrown so the resend-verification flow still reports it. This
mirrors the [#5706](https://github.com/wekan/wekan/issues/5706) reset-password hardening.
Covered by `tests/verificationEmail.test.cjs`.
Thanks to Firas-Git and xet7.
- **[Fix can not add members to a Linked Card](https://github.com/wekan/wekan/commit/cb9c8973092df5aa3112d3d59e3da4d8793c628b)**:
A **linked card** (created by "Link card to this card") is only a placeholder on the board
that links it — its members are stored on the **real card** it points at (`linkedId`), which
lives on another board, and `Card.getMembers()` / `assignMember()` / `unassignMember()`
already read and write that real card. But the member **picker** listed the members of the
board you were *viewing* the linked card on, not the board the real card lives on. So on a
board that links a card from another board, the picker offered the wrong set of members and
toggling them did not behave as a consistent add/remove — "can not add members to the linked
card". The picker now resolves a linked card to its real card's board and offers *that*
board's active members (matching where the membership is actually stored), falling back to
the current board for normal cards or when the real card isn't loaded yet. Extracted the
target-card/target-board resolution into `models/lib/linkedCardMembers.js`, covered by
`tests/linkedCardMembers.test.cjs`.
Thanks to ITT5 and xet7.
- **[Fix can't search numbers in custom fields](https://github.com/wekan/wekan/commit/001c258f967caa502bf1d1ebb147b8506596f4bf)**:
Searching a board for the value of a **number** or **currency** custom field (e.g. a
transaction number `2025001`, or a currency amount `123`) found nothing. Those field types
store their value as a JS **Number** (the inputs save `parseInt(...)` / `Number(...)`), but
`Board.searchCards()` only matched custom fields with `{ value: <regex> }` — and a MongoDB /
Minimongo regex only matches **string** values, so it silently skipped every numeric custom
field. The search now also adds an exact numeric-equality clause when the term is a plain
number (a comma is accepted as a decimal separator, matching the currency input), so numeric
custom fields match too; text/title/description matching is unchanged. Card search runs
against Minimongo on the client, which — like MongoDB — cannot regex a numeric field, so
equality is the correct cross-environment match. Covered by `tests/cardSearch.test.cjs`.
Thanks to MarcusDger and xet7.
- **[Fix "Removed nonexistent document" crash during notification_cleanup](https://github.com/wekan/wekan/commit/2242da4176edcd7ed6bbdd779fe2bafab8457b7c)**:
The scheduled notification-tray cleanup logged `Exception in removed observeChanges
callback: Error: Removed nonexistent document …`. The crash itself came from the old
`cottz:publish-relations` package (since replaced by `reywood:publish-composite`), but the
cleanup that provoked it still fired one **un-awaited** `removeNotification()` per expired
notification — a separate `Users.update` `$pull` each time — so a user with K stale
notifications produced K writes to the Users collection, and every publication that
republishes user documents re-ran its observers K times in quick bursts (the churn that
surfaced the removed-document error), while any rejected write went unhandled. The cleanup
now scans only users that have notifications and prunes each user's stale notifications in a
single awaited `$pull … $in`. It also removes an activity's notifications only when *every*
entry for that activity is read and past its removal age (so a freshly re-created unread
notification sharing an activity id is not dropped), and guards missing/invalid `read`
timestamps. Covered by `tests/notificationCleanup.test.cjs`.
Thanks to xet7.
- **[Fix impossible to select another board in rules](https://github.com/wekan/wekan/issues/5698)**:
In the IFTTT-Rules "Move card to the board" and "Link card to the board" actions, the
board dropdown was empty for some users — the current board included — while colleagues
in the same company could pick boards normally. Root cause: the dropdown filtered the
(already access-scoped) client cache with `'members.userId': me`, i.e. it only kept boards
where the user has a *direct* member entry. A user who reaches a board through an
Organization, Team or email-domain share — but is not listed individually in
`board.members` — matched nothing, so the whole selector came up empty. The dropdown now
filters by the same visibility rule as `Boards.userBoards()` (public OR active member OR
active org OR active team OR active domain), so org/team/domain-shared boards appear too,
while archived boards, template containers, the user's templates board and internal helper
boards stay excluded.
Thanks to Augustin356 and xet7.
- **[Fix board disappeared after adding another user](https://github.com/wekan/wekan/commit/933aad87fca6fc8b17efce577127e0b1fddf17f2)**:
A board admin adding another user could make the whole board silently vanish from a
user's board list, with no archive and "nothing out of order" in the logs. Root cause:
the `setBoardTeams` server method (used by the board Teams/Members management popups)
blindly overwrote the board's entire `members` array with a snapshot sent by the client.
When that client's board document was stale — e.g. a member had just been added via
`inviteUserToBoard` on the server and the change had not yet propagated to the client —
the overwrite dropped members, and in the worst case the board's own admin, so the board
no longer matched the board-list publication (which requires an active membership) and
disappeared. Because a wholesale `$set: { members }` never passes through
`foreachRemovedMember()`, no `removeBoardMember` activity was logged and no
card/watcher/star cleanup ran, which is why the logs looked normal. `setBoardTeams` now
reconciles against the authoritative server-side members instead of trusting the client
snapshot: it never drops an active admin, keeps every existing member the client still
lists, adds the members the client introduces, and only removes non-admin members the
client explicitly omitted (an intentional team-leave) — logging and cleaning up each such
removal so it is auditable rather than silent.
Thanks to DVNBLMHC and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.77 2026-07-06 WeKan ® release
This release adds the following updates:
- Updated Code of Conduct.
[Part 1](https://github.com/wekan/wekan/commit/209cb8b43d84f7382d181a9f5d1e96ff32f2634d),
[Part 2](https://github.com/wekan/wekan/commit/574dd5bedd4f7c584b0fbbfaf57df86f286607fa).
Thanks to xet7.
- **[Retry snapcraft install in the release-all.yml snap job](https://github.com/wekan/wekan/commit/0d4e48ddd6b9ee086f0aa10d9f7c9893fe1637e4)**:
The v9.76 release's `snap` job failed at `sudo snap install snapcraft --classic` with
`error: cannot install "snapcraft": too many requests` — a transient Snap Store rate limit
(429-style throttle) that instantly failed the whole job before any build ran. The install now
retries up to 5 times with 30s backoff, so one store throttle no longer fails the release
(matching the `remote-build` retry already in the same job).
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.76 2026-07-06 WeKan ® release
This release adds the following fixes:
+1 -1
View File
@@ -2,10 +2,10 @@
For all code at WeKan GitHub Organization https://github.com/wekan
- For pull requests, mention only those participants that are **human**.
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become MIT license.
- For any dependencies, permissive licenses like https://copyfree.org are preferred
- For anything currently that is non-permissive (like GPL, AGPL, SSPL), those will be replaced with permissive-licensed alternatives
- Any intelligence allowed: Human, AI or any other. No discrimination, please have world peace. Mentioning participants optional.
- All code is checked and validated by xet7 and many different security AI. If code is real working fix or feature, it usually will be merged.
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
+1 -1
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.76
ARG VERSION=9.78
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.76.0"
appVersion: "v9.78.0"
files:
userUploads:
- README.md
+20 -1
View File
@@ -51,6 +51,7 @@ import Lists from '/models/lists';
import CardComments from '/models/cardComments';
import { ALLOWED_COLORS } from '/config/const';
import { uniqBy } from '/imports/lib/collectionHelpers';
import { memberTargetBoardId } from '/models/lib/linkedCardMembers';
import { UserAvatar } from '../users/userAvatar';
import { Filter } from '/client/lib/filter';
import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlaneListCard';
@@ -1657,7 +1658,25 @@ Template.cardLocationsPopup.events({
});
const filterMembers = (filterTerm) => {
let currBoard = Utils.getCurrentBoard();
// #5676: for a linked card the members live on the REAL card it points at,
// which may be on another board (that is what getMembers()/toggleMember() act
// on). Offer that board's members instead of the viewing board's, so the
// picker is consistent with where membership is actually stored. Falls back to
// the current board for normal cards or when the real card isn't loaded yet.
const card = getCurrentCardFromContext();
let currBoard = null;
if (card) {
const boardId = memberTargetBoardId(card, id => ReactiveCache.getCard(id));
if (boardId) {
currBoard = ReactiveCache.getBoard(boardId);
}
}
if (!currBoard) {
currBoard = Utils.getCurrentBoard();
}
if (!currBoard) {
return [];
}
let members = currBoard.activeMembers();
if (filterTerm) {
@@ -4,6 +4,7 @@ import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
import { Utils } from '/client/lib/utils';
import { canSelectBoardInRules } from '/models/lib/rulesBoardSelector';
Template.boardActions.onCreated(function () {
this.subscribe('boards');
@@ -11,19 +12,30 @@ Template.boardActions.onCreated(function () {
Template.boardActions.helpers({
boards() {
const ret = ReactiveCache.getBoards(
{
archived: false,
'members.userId': Meteor.userId(),
_id: {
$ne: ReactiveCache.getCurrentUser().getTemplatesBoardId(),
},
},
// #5698: don't restrict to boards where the user is a DIRECT member — that
// hid every board reached through an Organization / Team / email-domain
// share (the reporter's whole board dropdown was empty, current board
// included). Filter the already access-scoped client cache with the same
// visibility rule as Boards.userBoards() so org/team/domain boards show too.
const user = ReactiveCache.getCurrentUser();
if (!user) return [];
const ctx = {
userId: user._id,
orgIds: typeof user.orgIds === 'function' ? user.orgIds() : [],
teamIds: typeof user.teamIds === 'function' ? user.teamIds() : [],
emailDomains:
typeof user.emailDomains === 'function' ? user.emailDomains() : [],
};
const templatesBoardId = user.getTemplatesBoardId();
const boards = ReactiveCache.getBoards(
{ archived: false },
{
sort: { sort: 1 /* boards default sorting */ },
},
);
return ret;
return boards.filter(board =>
canSelectBoardInRules(board, ctx, templatesBoardId),
);
},
loadingBoardsLabel() {
+15
View File
@@ -300,4 +300,19 @@ if (Meteor.isServer) {
Accounts.sendResetPasswordEmail,
(code, message) => new Meteor.Error(code, message),
);
// #5672: "Internal Server Error When Signing Up Despite Successful Account
// Creation". useraccounts' ATCreateUserServer creates the account and then
// calls Accounts.sendVerificationEmail(); when SMTP is not configured that
// send throws AFTER the user is inserted, so the createUser method returns a
// raw HTTP 500 even though the account exists and can sign in. The
// verification email is best-effort at sign-up, so swallow + log a transport
// failure (registration then completes and redirects to sign-in). An
// "already verified" error is re-thrown so ATResendVerificationEmail still
// reports it. Unit-tested in tests/verificationEmail.test.cjs.
const { wrapSendVerificationEmail } = require('/server/lib/verificationEmail');
Accounts.sendVerificationEmail = wrapSendVerificationEmail(
Accounts.sendVerificationEmail,
message => console.warn(message),
);
}
+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.76-win64.zip](https://github.com/wekan/wekan/releases/download/v9.76/wekan-9.76-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-9.78-win64.zip](https://github.com/wekan/wekan/releases/download/v9.78/wekan-9.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-9.76-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-9.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+4 -7
View File
@@ -23,6 +23,7 @@ import { pullMemberById } from '/server/lib/removeMember';
import getSlug from 'limax';
import { findWhere, where, groupBy } from '/imports/lib/collectionHelpers';
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
import { buildCardSearchOr } from '/models/lib/cardSearch';
const { SimpleSchema } = require('/imports/simpleSchema');
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
@@ -1618,13 +1619,9 @@ Boards.helpers({
}
const projection = { sort: { createdAt: -1 } };
const regex = new RegExp(term, 'i');
query.$or = [
{ title: regex },
{ description: regex },
{ customFields: { $elemMatch: { value: regex } } },
];
// #5680: build the $or so numeric custom fields (number / currency, stored
// as JS Numbers) match by value too — a regex alone only matches strings.
query.$or = buildCardSearchOr(term);
ret = ReactiveCache.getCards(query, projection);
}
return ret;
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// Pure helpers for board card search. Extracted from models/boards.js
// (Board.searchCards) so the query it builds is unit-testable without Meteor.
//
// #5680 (can't search numbers in custom fields): "number" and "currency" custom
// fields store their value as a JS Number (the inputs save `parseInt(...)` /
// `Number(...)`), but the search only matched custom fields with
// `{ value: <regex> }`. A MongoDB / Minimongo regex only matches STRING values,
// so it silently skipped every numeric custom field — searching a card's
// transaction number (e.g. 2025001) or currency amount (e.g. 123) never found
// it. We add an exact numeric-equality clause when the term parses to a plain
// finite number, so numeric custom fields match too. (Card search runs against
// Minimongo on the client, which — like MongoDB — cannot regex a numeric field,
// so equality is the correct cross-environment match rather than `$toString`.)
// Parse a search term into a Number the way the number/currency custom-field
// inputs store their value (a comma is accepted as a decimal separator, matching
// the currency input's `value.replace(',', '.')`). Returns null when the term is
// not a plain number, so we never widen a text search into an accidental match.
function parseNumericSearchTerm(term) {
if (typeof term !== 'string') return null;
const trimmed = term.trim();
if (trimmed === '') return null;
const normalized = trimmed.replace(',', '.');
// Require a plain decimal number. This rejects things Number() would otherwise
// coerce (Number('') === 0, Number('0x1f') === 31, Number('1e3') === 1000) so
// only genuine numeric terms add the numeric clause.
if (!/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(normalized)) return null;
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
}
// Build the `$or` clause array for a board card search on `term`. `term` is
// expected to be a trimmed, non-empty string (Board.searchCards guards that).
function buildCardSearchOr(term) {
const regex = new RegExp(term, 'i');
const or = [
{ title: regex },
{ description: regex },
{ customFields: { $elemMatch: { value: regex } } },
];
const numeric = parseNumericSearchTerm(term);
if (numeric !== null) {
// #5680: also match numeric custom fields (number / currency) by value.
or.push({ customFields: { $elemMatch: { value: numeric } } });
}
return or;
}
module.exports = { parseNumericSearchTerm, buildCardSearchOr };
+48
View File
@@ -0,0 +1,48 @@
'use strict';
// Pure helpers for resolving which card / board a (possibly linked) card's
// member changes act on. Extracted from the card member popup so the choice is
// unit-testable without Meteor.
//
// #5676 (can not add members to a Linked Card): a linked card
// (type 'cardType-linkedCard') is only a placeholder on the board that links it —
// its members are stored on the REAL card it points at (`linkedId`), which lives
// on another board. Card.getMembers() / assignMember() / unassignMember() already
// read and write that real card, but the member PICKER listed the *viewing*
// board's members (Utils.getCurrentBoard()). So on a board that links a card from
// another board, the picker offered members that don't belong where the
// membership is actually stored, and toggling them did not behave as a consistent
// add/remove. The picker must instead offer the members of the board the real
// card lives on — the same board whose membership getMembers() reflects.
const TYPE_LINKED_CARD = 'cardType-linkedCard';
const TYPE_LINKED_BOARD = 'cardType-linkedBoard';
// The card id that a member add/remove must target: the real card behind a link,
// otherwise the card itself. Returns null when there is no usable id.
function memberTargetCardId(card) {
if (!card) return null;
if (card.type === TYPE_LINKED_CARD && card.linkedId) return card.linkedId;
return card._id || null;
}
// The board id whose active members are valid for this card's membership.
// `resolveCard(id)` returns the card doc for a given id and is used to hop a
// linked card to its real card's board. Returns null when it can't be resolved
// (e.g. the real card isn't loaded yet), so the caller can fall back.
function memberTargetBoardId(card, resolveCard) {
if (!card) return null;
if (card.type === TYPE_LINKED_CARD && card.linkedId) {
const realCard =
typeof resolveCard === 'function' ? resolveCard(card.linkedId) : null;
return realCard ? realCard.boardId || null : null;
}
if (card.type === TYPE_LINKED_BOARD && card.linkedId) {
// A linked-board card's members are the linked board's own members, so the
// linkedId *is* the board id whose members are valid.
return card.linkedId;
}
return card.boardId || null;
}
module.exports = { memberTargetCardId, memberTargetBoardId };
+61
View File
@@ -0,0 +1,61 @@
'use strict';
// Pure helper for the scheduled notification-tray cleanup. Extracted from
// server/models/users.js so the "which notifications are stale" decision is
// unit-testable without Meteor.
//
// #5685 ("Exception in removed observeChanges callback: Error: Removed
// nonexistent document" during notification_cleanup): the cleanup used to fire
// one un-awaited `removeNotification()` per expired notification. Each was a
// separate `Users.update` `$pull`, so a user with K stale notifications produced
// K writes to the Users collection — and every publication that republishes user
// documents (board members, notification relations, …) re-ran its observers K
// times in quick succession. Under the old cottz:publish-relations that churn is
// what surfaced the "Removed nonexistent document" crash. Collapsing a user's
// stale notifications into a SINGLE awaited `$pull … $in` removes that churn (and
// fixes the un-awaited async / swallowed-rejection defect).
//
// It also removes an activity's notifications only when EVERY entry for that
// activity is read AND past its removal age, so a freshly re-created unread
// notification that happens to share an activity id is not pulled by mistake.
// Entries with a missing/invalid `read` timestamp are treated as not-yet-removable.
//
// Returns a de-duplicated array of activity ids safe to `$pull` for this user.
function expiredNotificationActivityIds(notifications, removeAgeDays, now) {
if (!Array.isArray(notifications)) return [];
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
if (Number.isNaN(nowMs)) return [];
// activity id -> { total, removable }
const perActivity = new Map();
for (const notification of notifications) {
if (!notification || !notification.activity) continue;
const activity = notification.activity;
const rec = perActivity.get(activity) || { total: 0, removable: 0 };
rec.total += 1;
if (notification.read) {
const removeDate = new Date(notification.read);
if (!Number.isNaN(removeDate.getTime())) {
removeDate.setDate(removeDate.getDate() + removeAgeDays);
if (removeDate.getTime() <= nowMs) {
rec.removable += 1;
}
}
}
perActivity.set(activity, rec);
}
const ids = [];
for (const [activity, rec] of perActivity) {
if (rec.total > 0 && rec.removable === rec.total) {
ids.push(activity);
}
}
return ids;
}
module.exports = { expiredNotificationActivityIds };
+72
View File
@@ -0,0 +1,72 @@
'use strict';
// Pure helper that reconciles a board's `members` array for the setBoardTeams
// method. Extracted from server/models/boards.js so it is unit-testable without
// Meteor.
//
// #5730 (board disappeared after adding another user): setBoardTeams used to
// blindly overwrite board.members with the array sent by the client. When that
// client's board document was stale — e.g. a member had just been added via
// inviteUserToBoard on the server and the change had not yet propagated — the
// overwrite silently dropped members, and in the worst case the board's own
// admin, which made the whole board vanish from that user's board list (the
// board-list publication requires an ACTIVE membership). And because a wholesale
// `$set: { members }` never passes through foreachRemovedMember(), no
// removeBoardMember activity was logged, so "the logs look normal".
//
// This reconciles against the AUTHORITATIVE server-side members instead of
// trusting the client snapshot:
// - never drops an active admin (this is what made the board disappear),
// - keeps every existing member the client still lists (using the existing
// server entry, so a stale client cannot silently change permissions),
// - adds the members the client introduces (team-derived users),
// - reports the non-admin members the client explicitly omitted as removed, so
// the caller can log + clean up each intentional team-leave removal instead
// of dropping them silently.
//
// Returns { members, removedMemberIds }.
function reconcileBoardTeamMembers(authoritativeMembers = [], clientMembers = []) {
const authoritative = Array.isArray(authoritativeMembers)
? authoritativeMembers
: [];
const client = Array.isArray(clientMembers) ? clientMembers : [];
const clientById = new Map(client.map(m => [m.userId, m]));
const authoritativeById = new Map(authoritative.map(m => [m.userId, m]));
const members = [];
const removedMemberIds = [];
for (const existing of authoritative) {
const isActiveAdmin = !!existing.isActive && !!existing.isAdmin;
if (clientById.has(existing.userId) || isActiveAdmin) {
// Keep the authoritative entry. Even if the client still lists this member
// we deliberately reuse the server entry so a stale client cannot silently
// change a member's permissions through this team-management path.
members.push(existing);
} else {
removedMemberIds.push(existing.userId);
}
}
for (const member of client) {
if (!authoritativeById.has(member.userId)) {
members.push({
userId: member.userId,
isAdmin: !!member.isAdmin,
isActive: member.isActive !== false,
isNoComments: !!member.isNoComments,
isCommentOnly: !!member.isCommentOnly,
isWorker: !!member.isWorker,
isNormalAssignedOnly: !!member.isNormalAssignedOnly,
isCommentAssignedOnly: !!member.isCommentAssignedOnly,
isReadOnly: !!member.isReadOnly,
isReadAssignedOnly: !!member.isReadAssignedOnly,
});
}
}
return { members, removedMemberIds };
}
module.exports = { reconcileBoardTeamMembers };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// Pure helpers deciding which boards may be picked as a destination in the
// IFTTT-Rules "move card to the board" / "link card to the board" selectors.
// Extracted from client/components/rules/actions/boardActions.js so the choice
// is unit-testable without Meteor.
//
// #5698 (Impossible to select other board in rules): the board dropdown used to
// filter the (already access-scoped) client cache with `'members.userId': me`,
// i.e. it only kept boards where the user has a DIRECT member entry. A user who
// reaches a board through an Organization, Team or email-domain share — but is
// not listed individually in board.members — got an EMPTY dropdown (not even the
// current board showed up), while a colleague who was a direct member saw the
// boards. This mirrors the board-list publication / Boards.userBoards() rule:
// public OR active member OR active org OR active team OR active domain.
function hasActive(list, predicate) {
return Array.isArray(list) && list.some(entry => entry && predicate(entry));
}
// Does `board` match `ctx`'s visibility? ctx = { userId, orgIds, teamIds,
// emailDomains } — the last three are plain id arrays (empty when absent).
function boardVisibleToUserContext(board, ctx) {
if (!board || !ctx) return false;
if (board.permission === 'public') return true;
if (hasActive(board.members, m => m.userId === ctx.userId && !!m.isActive)) {
return true;
}
const inSet = (list, key, ids) =>
Array.isArray(ids) &&
hasActive(list, entry => !!entry.isActive && ids.includes(entry[key]));
if (inSet(board.orgs, 'orgId', ctx.orgIds)) return true;
if (inSet(board.teams, 'teamId', ctx.teamIds)) return true;
if (inSet(board.domains, 'domain', ctx.emailDomains)) return true;
return false;
}
// May this board appear in the rules board selector for `ctx`?
// Excludes archived boards, non-'board' types (e.g. template containers), the
// user's own templates board, and internal caret-wrapped helper boards
// (`^Subtasks^` etc.), then requires the board to be visible to the user.
function canSelectBoardInRules(board, ctx, templatesBoardId) {
if (!board || !ctx) return false;
if (board.archived) return false;
if (board.type && board.type !== 'board') return false;
if (typeof board.title === 'string' && /^\^.*\^$/.test(board.title)) {
return false;
}
if (templatesBoardId && board._id === templatesBoardId) return false;
return boardVisibleToUserContext(board, ctx);
}
module.exports = { canSelectBoardInRules, boardVisibleToUserContext };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v9.76.0",
"version": "v9.78.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v9.76.0",
"version": "v9.78.0",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.1071.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v9.76.0",
"version": "v9.78.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v9.76</title>
<title>WeKan REST API v9.78</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.76<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.78<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -1983,7 +1983,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.76","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.78","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v9.76
version: v9.78
description: |
The REST API allows you to control and extend Wekan with ease.
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 976,
appVersion = 978,
# Increment this for every release.
appMarketingVersion = (defaultText = "9.76~2026-07-05"),
appMarketingVersion = (defaultText = "9.78~2026-07-06"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+61
View File
@@ -0,0 +1,61 @@
// verificationEmail.js
//
// Pure, Meteor-free helper hardening the sign-up verification-email send.
// Extracted so it can be unit-tested in plain Node (see
// tests/verificationEmail.test.cjs).
//
// #5672: "Internal Server Error When Signing Up Despite Successful Account
// Creation". useraccounts' ATCreateUserServer method creates the account and
// THEN calls `Accounts.sendVerificationEmail(userId, email)`. When SMTP is not
// configured (no/invalid MAIL_URL / MAIL_FROM, or the mail server is
// unreachable), that send throws AFTER the user row is already inserted, and the
// exception propagates out of the createUser method as an opaque HTTP 500 — so
// the user sees "Internal server error" even though the account was created and
// can sign in.
//
// The verification email is best-effort at sign-up: the account already exists,
// so a mail failure must not fail the registration. Wrap the sender so a send
// failure is logged and swallowed (registration completes and redirects to
// sign-in). An "already verified" error is re-thrown unchanged so the separate
// "resend verification email" flow (ATResendVerificationEmail) can still report
// it.
// True when the error is accounts-password signalling that the address is
// already verified (there is nothing to send), as opposed to a mail-transport
// failure. Kept broad but conservative: only the word "verified" qualifies.
function isAlreadyVerifiedError(error) {
const message = ((error && (error.reason || error.message)) || '')
.toString()
.toLowerCase();
return message.includes('verified');
}
// Wrap an Accounts.sendVerificationEmail-style sender so a transport failure at
// sign-up is logged via `logWarning` and swallowed (resolves to undefined)
// instead of bubbling up as an HTTP 500. Already-verified errors are re-thrown.
// A non-function `originalSend` is returned unchanged.
function wrapSendVerificationEmail(originalSend, logWarning) {
if (typeof originalSend !== 'function') {
return originalSend;
}
const warn = typeof logWarning === 'function' ? logWarning : () => {};
return async function wrappedSendVerificationEmail(...args) {
try {
return await originalSend.apply(this, args);
} catch (error) {
// Preserve the "resend to an already-verified address" signal.
if (isAlreadyVerifiedError(error)) {
throw error;
}
warn(
'[sendVerificationEmail] Could not send the verification email — is ' +
'SMTP configured (MAIL_URL / MAIL_FROM)? The account was still ' +
'created and can sign in. ' +
((error && (error.message || error.reason)) || error),
);
return undefined;
}
};
}
module.exports = { isAlreadyVerifiedError, wrapSendVerificationEmail };
+50 -1
View File
@@ -7,6 +7,7 @@ import { WekanCreator } from '/models/wekanCreator';
import { Authentication } from '/server/authentication';
import { sendJsonResult } from '/server/apiMiddleware';
import { allowIsBoardAdmin, boardMemberRoleToFlags } from '/server/lib/utils';
import { reconcileBoardTeamMembers } from '/models/lib/reconcileBoardTeamMembers';
import { filterUserBoards } from '/server/lib/boardListFilter';
import { ReactiveCache } from '/imports/reactiveCache';
import Actions from '/models/actions';
@@ -367,12 +368,60 @@ Meteor.methods({
if (member.isReadAssignedOnly !== undefined) check(member.isReadAssignedOnly, Boolean);
}
// #5730: setBoardTeams used to blindly overwrite board.members with the
// client-supplied `membersArray`. When the caller's board document was stale
// (e.g. a user had just been added via inviteUserToBoard on the server and
// that change had not yet propagated to the caller's client), the overwrite
// silently dropped members — and in the worst case the board's own admin —
// which made the whole board vanish from that user's board list. Because a
// wholesale `$set: { members }` never goes through foreachRemovedMember(),
// no removeBoardMember activity was logged and no card/watcher/star cleanup
// ran, so "the logs look normal and nothing seems to be out of order".
//
// Reconcile against the AUTHORITATIVE server-side members instead of trusting
// the client snapshot (see reconcileBoardTeamMembers for the full rationale):
// - never drop an active admin (this is what made the board disappear),
// - keep every existing member the client still lists,
// - add the members the client introduces (team-derived users),
// - only remove non-admin members the client explicitly omitted (an
// intentional team-leave), logging + cleaning up each such removal below
// so it is auditable rather than silent.
const { members: resultMembers, removedMemberIds } =
reconcileBoardTeamMembers(board.members, membersArray);
await Boards.updateAsync(currBoardId, {
$set: {
members: membersArray,
members: resultMembers,
teams: boardTeamsArray,
},
});
// Run the same cleanup + audit trail that a normal member removal performs,
// so team-leave removals are not silent (see comment above).
for (const memberId of removedMemberIds) {
await Cards.updateAsync(
{ boardId: currBoardId },
{ $pull: { members: memberId, watchers: memberId } },
{ multi: true },
);
await Lists.updateAsync(
{ boardId: currBoardId },
{ $pull: { watchers: memberId } },
{ multi: true },
);
if (!board.isPublic()) {
await Users.updateAsync(memberId, {
$pull: { 'profile.starredBoards': currBoardId },
});
}
await Activities.insertAsync({
userId,
memberId,
type: 'member',
activityType: 'removeBoardMember',
boardId: currBoardId,
});
}
},
async setBoardDomains(boardDomainsArray, currBoardId) {
+29 -10
View File
@@ -21,6 +21,7 @@ import InviteToBoardRolesSettings from '/models/inviteToBoardRolesSettings';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
import Users, { allowedSortValues, allowedAllBoardsSortValues } from '/models/users';
import { expiredNotificationActivityIds } from '/models/lib/notificationCleanup';
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
const isSandstorm =
@@ -1214,17 +1215,35 @@ const runNotificationCleanup = async function runNotificationCleanup() {
process.env.NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE;
const defaultRemoveAge = 2;
const removeAge = parseInt(envRemoveAge, 10) || defaultRemoveAge;
const now = new Date();
for (const user of await ReactiveCache.getUsers()) {
if (!user.profile || !user.profile.notifications) continue;
for (const notification of user.profile.notifications) {
if (notification && notification.read) {
const removeDate = new Date(notification.read);
removeDate.setDate(removeDate.getDate() + removeAge);
if (removeDate <= new Date()) {
user.removeNotification(notification.activity);
}
}
// #5685: only scan users that actually have notifications, and prune each
// user's stale notifications in a SINGLE awaited `$pull … $in` instead of one
// un-awaited `removeNotification()` per entry. That collapses K Users-collection
// writes (and the K publication observer re-runs they triggered — the churn
// behind the "Removed nonexistent document" crash) into one.
const users = await ReactiveCache.getUsers({
'profile.notifications': { $exists: true, $ne: [] },
});
for (const user of users) {
const activityIds = expiredNotificationActivityIds(
user.profile && user.profile.notifications,
removeAge,
now,
);
if (activityIds.length === 0) continue;
try {
await Users.updateAsync(user._id, {
$pull: {
'profile.notifications': { activity: { $in: activityIds } },
},
});
} catch (error) {
console.error(
'Notification cleanup: failed to prune notifications for user',
user._id,
error,
);
}
}
};
+4 -4
View File
@@ -1,5 +1,5 @@
name: wekan
version: '9.76'
version: '9.78'
base: core26
build-base: devel
confinement: strict
@@ -194,14 +194,14 @@ parts:
exit 1
;;
esac
WEKAN_ZIP="wekan-9.76-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-9.78-${WEKAN_ARCH}.zip"
NODE_TAR="node-v24.17.0-linux-${NODE_ARCH}.tar.xz"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v9.76/wekan-9.76-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.76/${WEKAN_ZIP}"
#wget https://github.com/wekan/wekan/releases/download/v9.78/wekan-9.78-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.78/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+135
View File
@@ -0,0 +1,135 @@
'use strict';
// Plain-Node unit test (no Meteor) for the board card-search query builder.
// Run: node tests/cardSearch.test.cjs
//
// Regression guard for #5680 ("How can I search for numbers in user-defined
// fields?"): number and currency custom fields store their value as a JS Number,
// but the search only matched custom fields with a regex, and a Mongo/Minimongo
// regex never matches numeric values — so searching a custom-field number found
// nothing. buildCardSearchOr() must add an exact numeric-equality clause for
// plain-number terms while leaving the string regex clauses intact, and must NOT
// add one for non-numeric terms.
const assert = require('assert');
const {
parseNumericSearchTerm,
buildCardSearchOr,
} = require('../models/lib/cardSearch');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// A tiny Minimongo-like matcher so we can assert the built $or actually selects
// (or rejects) representative card documents — closer to real behavior than just
// eyeballing the query shape.
function clauseMatches(clause, card) {
if (clause.title) return clause.title.test(card.title || '');
if (clause.description) return clause.description.test(card.description || '');
if (clause.customFields) {
const cond = clause.customFields.$elemMatch.value;
const fields = card.customFields || [];
return fields.some(f => {
if (cond instanceof RegExp) {
return typeof f.value === 'string' && cond.test(f.value);
}
return f.value === cond; // exact (numeric) equality
});
}
return false;
}
const orMatches = (or, card) => or.some(c => clauseMatches(c, card));
// --- parseNumericSearchTerm --------------------------------------------------
test('parseNumericSearchTerm parses integers and decimals', () => {
assert.strictEqual(parseNumericSearchTerm('2025001'), 2025001);
assert.strictEqual(parseNumericSearchTerm('123'), 123);
assert.strictEqual(parseNumericSearchTerm('123.45'), 123.45);
});
test('parseNumericSearchTerm accepts a comma decimal separator', () => {
assert.strictEqual(parseNumericSearchTerm('123,00'), 123);
assert.strictEqual(parseNumericSearchTerm('123,45'), 123.45);
});
test('parseNumericSearchTerm rejects non-plain-numbers', () => {
// things Number() would otherwise coerce, plus mixed text
['', ' ', 'abc', '123abc', '1 2', '0x1f', '1e3', 'NaN', '12,00 €'].forEach(t => {
assert.strictEqual(parseNumericSearchTerm(t), null, `should reject ${JSON.stringify(t)}`);
});
});
test('parseNumericSearchTerm tolerates non-string input', () => {
assert.strictEqual(parseNumericSearchTerm(123), null);
assert.strictEqual(parseNumericSearchTerm(null), null);
assert.strictEqual(parseNumericSearchTerm(undefined), null);
});
// --- buildCardSearchOr: POSITIVE --------------------------------------------
test('#5680: a numeric custom field is matched by a number term', () => {
const or = buildCardSearchOr('2025001');
const card = { title: 'Invoice', customFields: [{ value: 2025001 }] };
assert.ok(orMatches(or, card), 'card with numeric custom field should match');
});
test('#5680: a currency custom field (123) matches search "123"', () => {
const or = buildCardSearchOr('123');
const card = { title: 'X', customFields: [{ value: 123 }] };
assert.ok(orMatches(or, card));
});
test('#5680: comma-typed term "123,00" matches numeric value 123', () => {
const or = buildCardSearchOr('123,00');
const card = { title: 'X', customFields: [{ value: 123 }] };
assert.ok(orMatches(or, card));
});
test('string custom fields still match via regex (unchanged behavior)', () => {
const or = buildCardSearchOr('123');
const card = { title: 'X', customFields: [{ value: '123,00 €' }] };
assert.ok(orMatches(or, card));
});
test('title and description still match via regex', () => {
const or = buildCardSearchOr('hello');
assert.ok(orMatches(or, { title: 'Say HELLO there' }));
assert.ok(orMatches(or, { description: 'a hello world card' }));
});
test('the numeric clause is only appended for numeric terms', () => {
assert.strictEqual(buildCardSearchOr('2025001').length, 4); // + numeric clause
assert.strictEqual(buildCardSearchOr('hello').length, 3); // no numeric clause
});
// --- buildCardSearchOr: NEGATIVE --------------------------------------------
test('a different number does NOT match (exact equality, not substring)', () => {
const or = buildCardSearchOr('202');
const card = { title: 'X', customFields: [{ value: 2025001 }] };
assert.ok(!orMatches(or, card), '202 must not match 2025001');
});
test('numeric term does not match a numeric field of a different value', () => {
const or = buildCardSearchOr('124');
const card = { title: 'X', customFields: [{ value: 123 }] };
assert.ok(!orMatches(or, card));
});
test('regex clause never matches a numeric field value', () => {
// Only the string-regex clauses (no numeric term) — a numeric field must not
// be matched by a regex, which is the exact #5680 bug.
const or = buildCardSearchOr('hello');
const card = { customFields: [{ value: 123 }] };
assert.ok(!orMatches(or, card));
});
test('non-numeric term against a numeric-only card finds nothing', () => {
const or = buildCardSearchOr('abc');
const card = { title: 'Numbers', customFields: [{ value: 2025001 }] };
assert.ok(!orMatches(or, card));
});
console.log(`\n${passed} tests passed`);
+96
View File
@@ -0,0 +1,96 @@
'use strict';
// Plain-Node unit test (no Meteor) for the linked-card member target helpers.
// Run: node tests/linkedCardMembers.test.cjs
//
// Regression guard for #5676 ("Bug: Can not add members to Linked Card"): a
// linked card's members are stored on the REAL card it points at (on another
// board), which is what getMembers()/assignMember() act on — but the member
// picker used to list the *viewing* board's members. memberTargetBoardId() must
// resolve a linked card to its real card's board (so the picker matches where
// membership is stored), and memberTargetCardId() must resolve to the real card
// id that member changes are written to. Normal cards and linked-board cards must
// keep their own board.
const assert = require('assert');
const {
memberTargetCardId,
memberTargetBoardId,
} = require('../models/lib/linkedCardMembers');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Real card on board A; a linked card on board B points at it.
const realCard = { _id: 'realA', boardId: 'boardA', type: 'card' };
const linkedCard = {
_id: 'linkB',
boardId: 'boardB',
type: 'cardType-linkedCard',
linkedId: 'realA',
};
const resolve = id => (id === 'realA' ? realCard : undefined);
// --- memberTargetCardId ------------------------------------------------------
test('linked card resolves to the real card id for member writes', () => {
assert.strictEqual(memberTargetCardId(linkedCard), 'realA');
});
test('normal card targets its own id', () => {
assert.strictEqual(memberTargetCardId(realCard), 'realA');
});
test('memberTargetCardId is null-safe', () => {
assert.strictEqual(memberTargetCardId(null), null);
assert.strictEqual(memberTargetCardId({ type: 'card' }), null);
});
// --- memberTargetBoardId: POSITIVE ------------------------------------------
test('#5676: linked card resolves to the REAL card\'s board (not the viewing board)', () => {
const boardId = memberTargetBoardId(linkedCard, resolve);
assert.strictEqual(boardId, 'boardA');
// and crucially NOT the board the linked card is viewed on
assert.notStrictEqual(boardId, 'boardB');
});
test('normal card uses its own board', () => {
assert.strictEqual(memberTargetBoardId(realCard, resolve), 'boardA');
});
test('linked-board card uses the linked board id itself', () => {
const linkedBoardCard = {
_id: 'lbc',
boardId: 'boardB',
type: 'cardType-linkedBoard',
linkedId: 'boardX',
};
assert.strictEqual(memberTargetBoardId(linkedBoardCard, resolve), 'boardX');
});
// --- memberTargetBoardId: NEGATIVE ------------------------------------------
test('linked card whose real card is not loaded yet resolves to null (caller falls back)', () => {
const orphan = { type: 'cardType-linkedCard', linkedId: 'missing' };
assert.strictEqual(memberTargetBoardId(orphan, resolve), null);
});
test('linked card with no resolver does not throw and returns null', () => {
assert.strictEqual(memberTargetBoardId(linkedCard, undefined), null);
});
test('a linked card must NOT fall back to the viewing board when unresolved', () => {
// Guards the bug: returning boardB here would re-introduce the wrong-board
// picker. Unresolved must be null so the caller decides the fallback.
const orphan = { boardId: 'boardB', type: 'cardType-linkedCard', linkedId: 'missing' };
assert.strictEqual(memberTargetBoardId(orphan, resolve), null);
});
test('memberTargetBoardId is null-safe', () => {
assert.strictEqual(memberTargetBoardId(null, resolve), null);
assert.strictEqual(memberTargetBoardId({ type: 'card' }, resolve), null);
});
console.log(`\n${passed} tests passed`);
+130
View File
@@ -0,0 +1,130 @@
'use strict';
// Plain-Node unit test (no Meteor) for expiredNotificationActivityIds().
// Run: node tests/notificationCleanup.test.cjs
//
// Regression guard for #5685 ("Exception in removed observeChanges callback:
// Error: Removed nonexistent document" during notification_cleanup). The cleanup
// now computes, per user, the set of activity ids whose notifications are all
// read and past the removal age, then prunes them in a single `$pull … $in`.
// This helper is that decision: it must select fully-expired activities, skip
// unread / not-yet-aged / invalid-date / re-created ones, and de-duplicate.
const assert = require('assert');
const {
expiredNotificationActivityIds,
} = require('../models/lib/notificationCleanup');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const NOW = new Date('2025-02-12T12:00:00Z');
const daysAgo = n => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
const REMOVE_AGE = 2; // days after "read" before a notification is removable
// --- POSITIVE: read + aged past the window is removable ---------------------
test('a read notification older than the removal age is removed', () => {
const ids = expiredNotificationActivityIds(
[{ activity: 'a1', read: daysAgo(3) }],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, ['a1']);
});
test('multiple expired activities are all returned, de-duplicated', () => {
const ids = expiredNotificationActivityIds(
[
{ activity: 'a1', read: daysAgo(5) },
{ activity: 'a2', read: daysAgo(10) },
{ activity: 'a1', read: daysAgo(4) }, // same activity, also expired
],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids.sort(), ['a1', 'a2']);
// de-duplicated: 'a1' appears once
assert.strictEqual(ids.filter(x => x === 'a1').length, 1);
});
test('exactly at the removal boundary is removable (<=)', () => {
// read 2 days ago, removeAge 2 -> removeDate == now -> removable
const ids = expiredNotificationActivityIds(
[{ activity: 'edge', read: daysAgo(2) }],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, ['edge']);
});
// --- NEGATIVE: things that must NOT be removed ------------------------------
test('an unread notification (read=null) is never removed', () => {
const ids = expiredNotificationActivityIds(
[{ activity: 'a1', read: null }],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, []);
});
test('a read notification still within the age window is kept', () => {
const ids = expiredNotificationActivityIds(
[{ activity: 'a1', read: daysAgo(1) }], // 1 day < 2 day window
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, []);
});
test('#5685: an activity with a re-created unread entry is NOT removed', () => {
// Same activity: one read+expired entry, one fresh unread entry. Pulling by
// activity id would wrongly drop the unread one, so the activity is kept.
const ids = expiredNotificationActivityIds(
[
{ activity: 'a1', read: daysAgo(9) }, // old, expired
{ activity: 'a1', read: null }, // re-notified, unread
],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, []);
});
test('a notification with an invalid read date is not removed', () => {
const ids = expiredNotificationActivityIds(
[{ activity: 'a1', read: 'not-a-date' }],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, []);
});
test('entries without an activity id are ignored', () => {
const ids = expiredNotificationActivityIds(
[{ read: daysAgo(9) }, null, { activity: '', read: daysAgo(9) }],
REMOVE_AGE,
NOW,
);
assert.deepStrictEqual(ids, []);
});
test('non-array / empty input yields an empty list without throwing', () => {
assert.deepStrictEqual(expiredNotificationActivityIds(undefined, REMOVE_AGE, NOW), []);
assert.deepStrictEqual(expiredNotificationActivityIds(null, REMOVE_AGE, NOW), []);
assert.deepStrictEqual(expiredNotificationActivityIds([], REMOVE_AGE, NOW), []);
});
test('an invalid `now` is handled defensively (no removals)', () => {
const ids = expiredNotificationActivityIds(
[{ activity: 'a1', read: daysAgo(9) }],
REMOVE_AGE,
new Date('nonsense'),
);
assert.deepStrictEqual(ids, []);
});
console.log(`\n${passed} tests passed`);
+139
View File
@@ -0,0 +1,139 @@
'use strict';
// Plain-Node unit test (no Meteor) for reconcileBoardTeamMembers().
// Run: node tests/reconcileBoardTeamMembers.test.cjs
//
// Regression guard for #5730 ("Board disappeared after adding another user"):
// setBoardTeams used to blindly overwrite board.members with the array sent by
// the client. When that client's board document was stale (e.g. a member had
// just been added via inviteUserToBoard on the server), the overwrite silently
// dropped members — and in the worst case the board's own admin — which made the
// whole board vanish from that user's board list, with no removeBoardMember
// activity logged. The reconciler must NEVER let a stale/partial client array
// drop an active admin, and must report intentional non-admin removals so they
// can be logged/cleaned up instead of vanishing silently.
const assert = require('assert');
const {
reconcileBoardTeamMembers,
} = require('../models/lib/reconcileBoardTeamMembers');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const admin = {
userId: 'admin',
isAdmin: true,
isActive: true,
isNoComments: false,
isCommentOnly: false,
isWorker: false,
};
const bob = {
userId: 'bob',
isAdmin: false,
isActive: true,
isNoComments: false,
isCommentOnly: false,
isWorker: false,
};
const ids = list => list.map(m => m.userId).sort();
// --- POSITIVE: adding a team's users keeps existing + appends new -----------
test('adding a team user keeps the admin and appends the new user', () => {
const { members, removedMemberIds } = reconcileBoardTeamMembers(
[admin],
[admin, { userId: 'carol', isAdmin: false, isActive: true }],
);
assert.deepStrictEqual(ids(members), ['admin', 'carol']);
assert.deepStrictEqual(removedMemberIds, []);
// The admin entry is preserved verbatim from the authoritative array.
assert.strictEqual(members.find(m => m.userId === 'admin'), admin);
// The new member gets fully-defaulted flags.
const carol = members.find(m => m.userId === 'carol');
assert.deepStrictEqual(carol, {
userId: 'carol',
isAdmin: false,
isActive: true,
isNoComments: false,
isCommentOnly: false,
isWorker: false,
isNormalAssignedOnly: false,
isCommentAssignedOnly: false,
isReadOnly: false,
isReadAssignedOnly: false,
});
});
// --- POSITIVE: leaving a team removes the intended non-admin member ---------
test('leaving a team removes the omitted non-admin and reports it', () => {
const { members, removedMemberIds } = reconcileBoardTeamMembers(
[admin, bob],
[admin],
);
assert.deepStrictEqual(ids(members), ['admin']);
assert.deepStrictEqual(removedMemberIds, ['bob']);
});
// --- POSITIVE: a stale client cannot change an existing member's permissions -
test('existing member keeps its authoritative entry, not the client copy', () => {
const clientBob = { ...bob, isAdmin: true }; // client tries to promote bob
const { members } = reconcileBoardTeamMembers([admin, bob], [admin, clientBob]);
const resultBob = members.find(m => m.userId === 'bob');
assert.strictEqual(resultBob, bob); // server entry, still non-admin
assert.strictEqual(resultBob.isAdmin, false);
});
// --- NEGATIVE (regression #5730): stale client omits the admin --------------
test('stale client omitting the admin does NOT drop the admin', () => {
// Server truth is [admin, bob]; the client only knows about bob (its board
// doc was stale). Blindly trusting it would drop the admin and the board would
// vanish for them.
const { members, removedMemberIds } = reconcileBoardTeamMembers(
[admin, bob],
[bob],
);
assert.ok(
members.some(m => m.userId === 'admin' && m.isActive && m.isAdmin),
'active admin must survive a stale client array',
);
assert.ok(
!removedMemberIds.includes('admin'),
'admin must never be reported as removed',
);
});
// --- NEGATIVE (regression #5730): empty/undefined client array (worst case) --
test('empty client array still preserves the admin', () => {
const { members, removedMemberIds } = reconcileBoardTeamMembers([admin, bob], []);
assert.ok(members.some(m => m.userId === 'admin' && m.isActive && m.isAdmin));
assert.ok(!removedMemberIds.includes('admin'));
assert.deepStrictEqual(removedMemberIds, ['bob']);
});
test('undefined args do not throw and yield empty result', () => {
const { members, removedMemberIds } = reconcileBoardTeamMembers(undefined, undefined);
assert.deepStrictEqual(members, []);
assert.deepStrictEqual(removedMemberIds, []);
});
// --- NEGATIVE (regression #5730): stale client tries to deactivate the admin -
test('client cannot deactivate or demote the admin through this path', () => {
const downgradedAdmin = { userId: 'admin', isAdmin: false, isActive: false };
const { members, removedMemberIds } = reconcileBoardTeamMembers(
[admin],
[downgradedAdmin],
);
const resultAdmin = members.find(m => m.userId === 'admin');
assert.strictEqual(resultAdmin, admin); // authoritative entry wins
assert.strictEqual(resultAdmin.isActive, true);
assert.strictEqual(resultAdmin.isAdmin, true);
assert.deepStrictEqual(removedMemberIds, []);
});
console.log(`\n${passed} tests passed`);
+134
View File
@@ -0,0 +1,134 @@
'use strict';
// Plain-Node unit test (no Meteor) for the IFTTT-Rules board selector logic.
// Run: node tests/rulesBoardSelector.test.cjs
//
// Regression guard for #5698 ("Impossible to select other board in rules"): the
// rules "move card to the board" / "link card to the board" dropdown filtered
// the client cache with `'members.userId': me`, so boards reached through an
// Organization / Team / email-domain share (where the user is not an individual
// member) never appeared — the reporter's dropdown was empty, current board
// included, while direct-member colleagues saw boards. canSelectBoardInRules()
// must include org/team/domain/public boards, while still excluding archived,
// helper, template-container and templates boards, and boards with no access.
const assert = require('assert');
const {
canSelectBoardInRules,
boardVisibleToUserContext,
} = require('../models/lib/rulesBoardSelector');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// The user: member of org O1 and team T1, email domain example.com.
const CTX = {
userId: 'u1',
orgIds: ['O1'],
teamIds: ['T1'],
emailDomains: ['example.com'],
};
const board = props => ({ _id: 'b', type: 'board', title: 'Board', ...props });
// --- POSITIVE: every access path lets the board be selected -----------------
test('direct active member board is selectable', () => {
const b = board({ members: [{ userId: 'u1', isActive: true }] });
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
});
test('public board is selectable even without membership', () => {
const b = board({ permission: 'public', members: [] });
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
});
test('#5698: org-shared board (not a direct member) is selectable', () => {
const b = board({
members: [{ userId: 'someone-else', isActive: true }],
orgs: [{ orgId: 'O1', isActive: true }],
});
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
});
test('#5698: team-shared board (not a direct member) is selectable', () => {
const b = board({ members: [], teams: [{ teamId: 'T1', isActive: true }] });
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
});
test('#5698: domain-shared board (not a direct member) is selectable', () => {
const b = board({
members: [],
domains: [{ domain: 'example.com', isActive: true }],
});
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
});
// --- NEGATIVE: no access / excluded categories are NOT selectable -----------
test('board the user cannot access at all is NOT selectable', () => {
const b = board({
permission: 'private',
members: [{ userId: 'other', isActive: true }],
orgs: [{ orgId: 'O2', isActive: true }],
teams: [{ teamId: 'T2', isActive: true }],
domains: [{ domain: 'other.com', isActive: true }],
});
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
});
test('inactive member entry does NOT grant selection', () => {
const b = board({ members: [{ userId: 'u1', isActive: false }] });
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
});
test('inactive org / team / domain shares do NOT grant selection', () => {
const b = board({
members: [],
orgs: [{ orgId: 'O1', isActive: false }],
teams: [{ teamId: 'T1', isActive: false }],
domains: [{ domain: 'example.com', isActive: false }],
});
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
});
test('archived board is excluded even if visible', () => {
const b = board({ archived: true, members: [{ userId: 'u1', isActive: true }] });
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
});
test('the user templates board is excluded by id', () => {
const b = board({ _id: 'tpl', members: [{ userId: 'u1', isActive: true }] });
assert.strictEqual(canSelectBoardInRules(b, CTX, 'tpl'), false);
});
test('template-container type is excluded', () => {
const b = board({
type: 'template-container',
members: [{ userId: 'u1', isActive: true }],
});
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
});
test('internal caret-wrapped helper board is excluded', () => {
const b = board({
title: '^Subtasks^',
members: [{ userId: 'u1', isActive: true }],
});
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
});
test('null board / null ctx are handled without throwing', () => {
assert.strictEqual(canSelectBoardInRules(null, CTX), false);
assert.strictEqual(canSelectBoardInRules(board({}), null), false);
assert.strictEqual(boardVisibleToUserContext(null, CTX), false);
});
test('missing org/team/domain arrays on ctx do not throw', () => {
const b = board({ members: [], orgs: [{ orgId: 'O1', isActive: true }] });
assert.strictEqual(boardVisibleToUserContext(b, { userId: 'u1' }), false);
});
console.log(`\n${passed} tests passed`);
+149
View File
@@ -0,0 +1,149 @@
'use strict';
// Plain-Node unit test (no Meteor) for the sign-up verification-email wrapper.
// Run: node tests/verificationEmail.test.cjs
//
// Regression guard for #5672 ("Internal Server Error When Signing Up Despite
// Successful Account Creation"). At sign-up, useraccounts creates the account
// and then calls Accounts.sendVerificationEmail(); when SMTP is not configured
// that throws AFTER the user exists, surfacing as an HTTP 500. wrapSend-
// VerificationEmail() must swallow + log a transport failure so registration
// still succeeds, while re-throwing an "already verified" error so the resend
// flow keeps working.
const assert = require('assert');
const {
isAlreadyVerifiedError,
wrapSendVerificationEmail,
} = require('../server/lib/verificationEmail');
let passed = 0;
function test(name, fn) {
// Support async test bodies.
const r = fn();
if (r && typeof r.then === 'function') {
return r.then(() => {
passed += 1;
console.log(' ok -', name);
});
}
passed += 1;
console.log(' ok -', name);
return undefined;
}
async function run() {
// --- isAlreadyVerifiedError ------------------------------------------------
await test('isAlreadyVerifiedError detects verified messages', () => {
assert.strictEqual(
isAlreadyVerifiedError(new Error('Email already verified')),
true,
);
assert.strictEqual(
isAlreadyVerifiedError({ reason: 'Already verified' }),
true,
);
});
await test('isAlreadyVerifiedError is false for transport errors / junk', () => {
assert.strictEqual(
isAlreadyVerifiedError(new Error('connect ECONNREFUSED 127.0.0.1:25')),
false,
);
assert.strictEqual(isAlreadyVerifiedError(null), false);
assert.strictEqual(isAlreadyVerifiedError(undefined), false);
assert.strictEqual(isAlreadyVerifiedError({}), false);
});
// --- POSITIVE: a working sender is passed through --------------------------
await test('a successful send returns its value and logs nothing', async () => {
const logs = [];
const wrapped = wrapSendVerificationEmail(
async (userId, email) => `sent:${userId}:${email}`,
m => logs.push(m),
);
const ret = await wrapped('u1', 'a@b.com');
assert.strictEqual(ret, 'sent:u1:a@b.com');
assert.strictEqual(logs.length, 0);
});
await test('the wrapper preserves `this` binding', async () => {
const obj = {
token: 'T',
async send() {
return this.token;
},
};
obj.send = wrapSendVerificationEmail(obj.send, () => {});
assert.strictEqual(await obj.send(), 'T');
});
await test('a non-function sender is returned unchanged', () => {
assert.strictEqual(wrapSendVerificationEmail(undefined, () => {}), undefined);
assert.strictEqual(wrapSendVerificationEmail(null, () => {}), null);
});
// --- NEGATIVE / the actual bug: transport failure must NOT throw -----------
await test('#5672: an SMTP send failure is swallowed (no throw) and logged', async () => {
const logs = [];
const wrapped = wrapSendVerificationEmail(async () => {
throw new Error('connect ECONNREFUSED 127.0.0.1:25');
}, m => logs.push(m));
let threw = false;
let ret = 'unset';
try {
ret = await wrapped('u1', 'a@b.com');
} catch (e) {
threw = true;
}
assert.strictEqual(threw, false, 'must not throw on SMTP failure');
assert.strictEqual(ret, undefined);
assert.strictEqual(logs.length, 1);
assert.ok(/verification email/i.test(logs[0]));
assert.ok(/ECONNREFUSED/.test(logs[0]), 'logs the underlying cause');
});
await test('a synchronous throw is also swallowed', async () => {
const logs = [];
const wrapped = wrapSendVerificationEmail(() => {
throw new Error('MAIL_URL not set');
}, m => logs.push(m));
const ret = await wrapped('u1');
assert.strictEqual(ret, undefined);
assert.strictEqual(logs.length, 1);
});
await test('swallowing still works when no logger is supplied', async () => {
const wrapped = wrapSendVerificationEmail(async () => {
throw new Error('smtp down');
});
// Must neither throw or need a logger.
assert.strictEqual(await wrapped('u1'), undefined);
});
// --- NEGATIVE guard: an "already verified" error is re-thrown --------------
await test('an "already verified" error is re-thrown (resend flow preserved)', async () => {
const logs = [];
const original = new Error('Verify email link is for an already verified user');
const wrapped = wrapSendVerificationEmail(async () => {
throw original;
}, m => logs.push(m));
let caught = null;
try {
await wrapped('u1');
} catch (e) {
caught = e;
}
assert.strictEqual(caught, original, 'already-verified error must propagate');
assert.strictEqual(logs.length, 0, 'and must not be logged as a transport failure');
});
console.log(`\n${passed} tests passed`);
}
run().catch(err => {
console.error(err);
process.exit(1);
});