mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736d308860 | ||
|
|
9eca7a3941 | ||
|
|
af436e16c4 | ||
|
|
b1b414e850 | ||
|
|
57da3885a2 | ||
|
|
cb9c897309 | ||
|
|
81f07baeb8 | ||
|
|
001c258f96 | ||
|
|
9d77b9d7b7 | ||
|
|
2242da4176 | ||
|
|
0a3a28ef29 | ||
|
|
dcf5aa65b0 | ||
|
|
dd3be179ca | ||
|
|
bc1f149ea3 |
No files matched your search
+97
-2
@@ -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,103 @@ 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:
|
||||
|
||||
+1
-1
@@ -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.77
|
||||
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
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v9.77.0"
|
||||
appVersion: "v9.78.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-9.77-win64.zip](https://github.com/wekan/wekan/releases/download/v9.77/wekan-9.77-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.77-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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v9.77.0",
|
||||
"version": "v9.78.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v9.77.0",
|
||||
"version": "v9.78.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1071.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v9.77.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,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v9.77</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.77<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
<p>If you are an end-user and not a dev or a tester, <a href="https://github.com/wekan/wekan/issues/new">create an issue</a> to request new APIs.</p>
|
||||
<blockquote>
|
||||
<p>All API calls in the documentation are made using <code>curl</code>. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.</p>
|
||||
@@ -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.77","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,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v9.77
|
||||
version: v9.78
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 977,
|
||||
appVersion = 978,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "9.77~2026-07-06"),
|
||||
appMarketingVersion = (defaultText = "9.78~2026-07-06"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
|
||||
name: wekan
|
||||
version: '9.77'
|
||||
version: '9.78'
|
||||
base: core26
|
||||
build-base: devel
|
||||
confinement: strict
|
||||
@@ -194,14 +194,14 @@ parts:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
WEKAN_ZIP="wekan-9.77-${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.77/wekan-9.77-amd64.zip
|
||||
wget "https://github.com/wekan/wekan/releases/download/v9.77/${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
|
||||
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user