mirror of
https://github.com/wekan/wekan.git
synced 2026-11-08 11:25:19 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a8657e0a | ||
|
|
b846e6d565 | ||
|
|
580276079e | ||
|
|
9a387acce0 | ||
|
|
7374b7e00d | ||
|
|
e15c920872 | ||
|
|
d1ac53b367 | ||
|
|
c951b40e5e | ||
|
|
be6ebb2503 | ||
|
|
d6ddcffcc2 |
No files matched your search
@@ -86,6 +86,103 @@ them up next.
|
||||
same `params.user` feeds both the e-mail notification text, where the full name is intended, and the webhook payload,
|
||||
where a username is expected; the safe change is to ADD a `username` field to the webhook rather than repurpose `user`).
|
||||
|
||||
# v9.99 2026-07-17 WeKan ® release
|
||||
|
||||
This release fixes the following BUG:
|
||||
|
||||
- **LDAP connection leak: WeKan exhausted the directory server with "too many open connections"**
|
||||
([#6467](https://github.com/wekan/wekan/issues/6467), [#6469](https://github.com/wekan/wekan/issues/6469)).
|
||||
Operators reported that after an update WeKan "dies really quickly", that restarting the server did not
|
||||
help, and that it "kills our openldap server with too many open connections"; others saw logins hang for
|
||||
minutes and then fail with *"Must be logged in"*. Root cause: every LDAP login attempt
|
||||
(`packages/wekan-ldap/server/loginHandler.js`) and every background sync run
|
||||
(`packages/wekan-ldap/server/sync.js`) created a fresh `new LDAP()` and called `connect()`, but the code
|
||||
**never called `disconnect()` on any path** — success, failure, or fallback. Each login attempt (including
|
||||
every *failed* one) and each background-sync tick (every minute by default) therefore leaked one socket to
|
||||
the LDAP/AD server. Over time this grew without bound until the directory server hit its per-client
|
||||
connection limit and started refusing connections, which took it — and, with it, every WeKan login — down.
|
||||
- **Fixed** by guaranteeing the connection is always released:
|
||||
- A small shared helper `packages/wekan-ldap/server/connectionGuard.js` (`runWithLdapDisconnect(ldap, fn)`)
|
||||
runs the work and `disconnect()`s in a `finally`, on every exit path. The disconnect is best-effort and
|
||||
never masks the original result or error.
|
||||
- The LDAP login handler now runs its whole flow through `runWithLdapDisconnect`, so `connect()` is always
|
||||
paired with a `disconnect()` — on a successful login, a thrown `Meteor.Error`, or a fallback to the
|
||||
default account system.
|
||||
- The background `sync()` releases its connection in a `finally`, and `importNewUsers()` disconnects only
|
||||
the connection it opened itself (never a connection borrowed from `sync()`).
|
||||
- The admin `ldap_test_connection` method (`packages/wekan-ldap/server/testConnection.js`), which had the
|
||||
same leak, now disconnects on both the success and failure paths, so repeated "Test Connection" clicks
|
||||
no longer leak either.
|
||||
- `LDAP.disconnect()` is now a safe no-op when `connect()` was never reached, so cleanup can never throw.
|
||||
- **Tests** (`tests/ldapConnectionRelease.test.cjs`, added to `test:unit:node`): behavioural coverage of the
|
||||
guard (disconnects after success and returns the result; disconnects exactly once; disconnects *and*
|
||||
re-throws when the work throws — the failed-login path that exhausted OpenLDAP), negative cases (a failing
|
||||
disconnect never turns a success into a failure nor hides the real error; a missing/`null` ldap is
|
||||
tolerated), plus source-level guards that the leak-prone call sites actually route through the guard and
|
||||
that `connect()` lives inside the guarded region.
|
||||
- Thanks to the reporting operators and **xet7** (fix).
|
||||
|
||||
This release also updates the bundled FerretDB (see the fork's own CHANGELOG Upcoming): the SQLite backend
|
||||
connection pool no longer caps `MaxOpenConns` at 16, which had starved WeKan's cursor-heavy load and made
|
||||
boards take minutes to load and logins fail with *"Must be logged in"* ([#6467](https://github.com/wekan/wekan/issues/6467),
|
||||
[#6469](https://github.com/wekan/wekan/issues/6469)).
|
||||
|
||||
- **Snap: FerretDB never becoming ready made WeKan hang with no web port and no explanation**
|
||||
([#6476](https://github.com/wekan/wekan/issues/6476)). With `database=ferretdb`, `snap-src/bin/wekan-control`
|
||||
waits for FerretDB to accept connections *before* starting WeKan — WeKan does not open its HTTP port until
|
||||
the database answers. That wait loop had **no timeout and no diagnostics**: if FerretDB never started
|
||||
listening (a crashed or CPU/architecture-incompatible per-arch binary, a locked or corrupt SQLite database,
|
||||
or the FerretDB service left disabled by a failed migration hand-off), WeKan blocked there forever. The
|
||||
service showed as `active`, but the port never opened and the only symptom was a silent
|
||||
`FerretDB not ready yet, retrying in 5 seconds` repeating — exactly what #6476 reported (Apache proxy could
|
||||
not connect to port 3333). The reporter's `wekan.log` stopped right after the startup env dump, confirming
|
||||
control never reached `node main.js`.
|
||||
- **Fixed** by mirroring the MongoDB branch: after `WEKAN_DB_WAIT_TIMEOUT` seconds (default 120,
|
||||
overridable) the FerretDB wait loop now prints an actionable hint **once** and keeps retrying — pointing to
|
||||
`snap logs <snap>.ferretdb` for the real error, naming the SQLite directory to check for locks/corruption,
|
||||
the arch-mismatch (`exec format error`) case, the `snap start --enable <snap>.ferretdb` recovery, and the
|
||||
`snap set <snap> database=mongodb` fallback to keep working meanwhile. This turns "port never opens, no
|
||||
clue why" into a clear message. Regression test: `tests/ferretdbWaitTimeout.test.cjs`, including a negative
|
||||
guard that the FerretDB wait is not a silent unbounded loop again.
|
||||
- This is a robustness/diagnostics fix; the underlying reason FerretDB was not accepting connections is
|
||||
environment-specific and lives in the FerretDB service log.
|
||||
- Thanks to **uusijani** (report) and **xet7** (fix).
|
||||
|
||||
Thanks to above for their contributions.
|
||||
|
||||
# v9.98 2026-07-17 WeKan ® release
|
||||
|
||||
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
|
||||
|
||||
- **[EscapeBleed](https://wekan.fi/hall-of-fame/escapebleed/): incomplete string escaping when
|
||||
building a regular expression** (GitHub CodeQL code scanning alert #423, rule
|
||||
`js/incomplete-sanitization`, CWE-116 Improper Encoding or Escaping of Output;
|
||||
`tests/maximizedCardPosition.test.cjs`). Code that turned a CSS declaration into a
|
||||
`RegExp` escaped only parentheses (`str.replace(/[()]/g, '\\$&')`) instead of the full
|
||||
regex metacharacter set — an input containing other metacharacters (including a backslash)
|
||||
would not be escaped correctly, so the generated pattern could match the wrong thing.
|
||||
- **Fixed** by escaping the complete metacharacter set
|
||||
(`str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`), matching the correct pattern already used
|
||||
elsewhere in the tests. This is test-only code with a fixed, trusted input list, so there
|
||||
was no injection exposure, but the incomplete escape was genuinely wrong.
|
||||
- Thanks to **GitHub CodeQL** (code scanning alert #423) and **xet7** (fix).
|
||||
|
||||
- **[RandomBleed](https://wekan.fi/hall-of-fame/randombleed/): biased random ids from a
|
||||
cryptographically secure source** (GitHub CodeQL code scanning alert #422, rule
|
||||
`js/biased-cryptographic-random`, CWE-1204 / weak randomness; `server/lib/schemaUpgradeSteps.js`).
|
||||
The startup schema upgrade generates Meteor-style document ids with
|
||||
`crypto.randomBytes(len)` mapped through `byte % ID_CHARS.length`. Because 256 is not a
|
||||
multiple of the 55-character alphabet, that modulo skews generated ids toward the first 36
|
||||
characters of the alphabet (each ~1.4% more likely than the rest) — reducing entropy of the
|
||||
ids used for the swimlanes/lists/checklist-items the upgrade creates.
|
||||
- **Fixed** with rejection sampling: bytes at or above the largest multiple of the alphabet
|
||||
size (220) are discarded and resampled, so every character is exactly equally likely; ids
|
||||
stay Meteor-style 17 characters (and exact-length for custom lengths). A negative
|
||||
regression test pins that out-of-range bytes are never wrapped.
|
||||
- Thanks to **GitHub CodeQL** (code scanning alert #422) and **xet7** (fix).
|
||||
|
||||
Thanks to above for their contributions.
|
||||
|
||||
# v9.97 2026-07-17 WeKan ® release
|
||||
|
||||
This release adds the following new features:
|
||||
|
||||
+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.97
|
||||
ARG VERSION=9.99
|
||||
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.97.0"
|
||||
appVersion: "v9.99.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-9.97-win64.zip](https://github.com/wekan/wekan/releases/download/v9.97/wekan-9.97-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-9.99-win64.zip](https://github.com/wekan/wekan/releases/download/v9.99/wekan-9.99-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.97-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-9.99-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v9.97.0",
|
||||
"version": "v9.99.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v9.97.0",
|
||||
"version": "v9.99.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v9.97.0",
|
||||
"version": "v9.99.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/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.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/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.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",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
// #6467 / #6469: guarantee that an LDAP connection is ALWAYS released.
|
||||
//
|
||||
// Every LDAP login attempt (loginHandler.js) and every background sync run
|
||||
// (sync.js) creates a fresh `new LDAP()` and calls `connect()`. The old code
|
||||
// never called `disconnect()` on any path, so each attempt leaked a socket to
|
||||
// the directory server. Under normal traffic this grew without bound until the
|
||||
// LDAP/AD server hit its per-client connection limit and started refusing
|
||||
// connections ("too many open connections"), which took the directory server
|
||||
// down and, with it, every WeKan login — matching the reported symptom of
|
||||
// WeKan "dying really quickly" and killing the OpenLDAP server, and slow /
|
||||
// failing logins ("Must be logged in").
|
||||
//
|
||||
// runWithLdapDisconnect runs `fn` and unbinds the connection in a `finally`, so
|
||||
// the socket is closed on EVERY exit path: normal return, thrown error, or an
|
||||
// LDAP-login `Meteor.Error`. The disconnect is best-effort and never masks the
|
||||
// original result or error: `ldap.disconnect()` already swallows and logs its
|
||||
// own errors, and this wrapper additionally tolerates a missing/!function
|
||||
// `disconnect` (e.g. a partially constructed object in a test) so cleanup can
|
||||
// never turn a successful login into a failure.
|
||||
async function runWithLdapDisconnect(ldap, fn) {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (ldap && typeof ldap.disconnect === 'function') {
|
||||
try {
|
||||
await ldap.disconnect();
|
||||
} catch (error) {
|
||||
// ldap.disconnect() logs internally; swallow here so releasing the
|
||||
// connection can never change the login/sync outcome.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runWithLdapDisconnect };
|
||||
@@ -548,6 +548,13 @@ export default class LDAP {
|
||||
async disconnect() {
|
||||
this.connected = false;
|
||||
this.domainBinded = false;
|
||||
// #6467/#6469: disconnect() is now called in a finally on every login and
|
||||
// sync exit path, including paths where connect() was never reached or
|
||||
// failed early. Guard against a missing client so releasing the connection
|
||||
// is always a safe no-op and can never throw over the original result.
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
Log.info('Disconecting');
|
||||
try {
|
||||
await this.client.unbind();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {slug, getLdapUsername, getLdapEmail, getLdapUserUniqueID, syncUserData, addLdapUser, syncUserGroupsToOrgsTeams} from './sync';
|
||||
import LDAP from './ldap';
|
||||
import { runWithLdapDisconnect } from './connectionGuard';
|
||||
import { log_debug, log_info, log_warn, log_error } from './logger';
|
||||
|
||||
// Org/team sync is optional enrichment; failed sync must not block login.
|
||||
@@ -48,220 +49,228 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
|
||||
|
||||
const self = this;
|
||||
const ldap = new LDAP();
|
||||
let ldapUser;
|
||||
|
||||
try {
|
||||
// #6467/#6469: run the whole login flow through runWithLdapDisconnect so the
|
||||
// connection opened by ldap.connect() below is ALWAYS released, on every exit
|
||||
// path (success, fallback, thrown Meteor.Error). Previously disconnect() was
|
||||
// never called, so every login attempt leaked a connection to the directory
|
||||
// server until it hit "too many open connections" and fell over.
|
||||
return await runWithLdapDisconnect(ldap, async () => {
|
||||
let ldapUser;
|
||||
|
||||
await ldap.connect();
|
||||
try {
|
||||
|
||||
if (!!LDAP.settings_get('LDAP_USER_AUTHENTICATION')) {
|
||||
await ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
|
||||
ldapUser = (await ldap.searchUsers(loginRequest.username))[0];
|
||||
} else {
|
||||
await ldap.connect();
|
||||
|
||||
const users = await ldap.searchUsers(loginRequest.username);
|
||||
if (!!LDAP.settings_get('LDAP_USER_AUTHENTICATION')) {
|
||||
await ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
|
||||
ldapUser = (await ldap.searchUsers(loginRequest.username))[0];
|
||||
} else {
|
||||
|
||||
if (users.length !== 1) {
|
||||
log_info('Search returned', users.length, 'record(s) for', loginRequest.username);
|
||||
throw new Error('User not Found');
|
||||
const users = await ldap.searchUsers(loginRequest.username);
|
||||
|
||||
if (users.length !== 1) {
|
||||
log_info('Search returned', users.length, 'record(s) for', loginRequest.username);
|
||||
throw new Error('User not Found');
|
||||
}
|
||||
|
||||
if (await ldap.isUserInGroup(loginRequest.username, users[0])) {
|
||||
ldapUser = users[0];
|
||||
} else {
|
||||
throw new Error('User not in a valid group');
|
||||
}
|
||||
|
||||
if (await ldap.auth(users[0].dn, loginRequest.ldapPass) !== true) {
|
||||
ldapUser = null;
|
||||
log_info('Wrong password for', loginRequest.username)
|
||||
}
|
||||
}
|
||||
|
||||
if (await ldap.isUserInGroup(loginRequest.username, users[0])) {
|
||||
ldapUser = users[0];
|
||||
} else {
|
||||
throw new Error('User not in a valid group');
|
||||
}
|
||||
|
||||
if (await ldap.auth(users[0].dn, loginRequest.ldapPass) !== true) {
|
||||
ldapUser = null;
|
||||
log_info('Wrong password for', loginRequest.username)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
|
||||
if (!ldapUser) {
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
|
||||
return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass);
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
|
||||
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`);
|
||||
}
|
||||
if (!ldapUser) {
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
|
||||
return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass);
|
||||
}
|
||||
|
||||
// Look to see if user already exists
|
||||
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`);
|
||||
}
|
||||
|
||||
let userQuery;
|
||||
// Look to see if user already exists
|
||||
|
||||
const Unique_Identifier_Field = getLdapUserUniqueID(ldapUser);
|
||||
let user;
|
||||
// Attempt to find user by unique identifier
|
||||
let userQuery;
|
||||
|
||||
if (Unique_Identifier_Field) {
|
||||
userQuery = {
|
||||
'services.ldap.id': Unique_Identifier_Field.value,
|
||||
};
|
||||
const Unique_Identifier_Field = getLdapUserUniqueID(ldapUser);
|
||||
let user;
|
||||
// Attempt to find user by unique identifier
|
||||
|
||||
log_info('Querying user');
|
||||
log_debug('userQuery', userQuery);
|
||||
if (Unique_Identifier_Field) {
|
||||
userQuery = {
|
||||
'services.ldap.id': Unique_Identifier_Field.value,
|
||||
};
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
}
|
||||
log_info('Querying user');
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
// Attempt to find user by username
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
}
|
||||
|
||||
let username;
|
||||
let email;
|
||||
// Attempt to find user by username
|
||||
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
username = slug(getLdapUsername(ldapUser));
|
||||
} else {
|
||||
username = slug(loginRequest.username);
|
||||
}
|
||||
let username;
|
||||
let email;
|
||||
|
||||
if(LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') {
|
||||
email = getLdapEmail(ldapUser);
|
||||
}
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
username = slug(getLdapUsername(ldapUser));
|
||||
} else {
|
||||
username = slug(loginRequest.username);
|
||||
}
|
||||
|
||||
if(LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') {
|
||||
email = getLdapEmail(ldapUser);
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
|
||||
if (!user) {
|
||||
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
|
||||
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
'emails.0.address' : email,
|
||||
'emails.0.verified' : true
|
||||
};
|
||||
} else {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
'emails.0.address' : email
|
||||
};
|
||||
}
|
||||
} else {
|
||||
userQuery = {
|
||||
username
|
||||
};
|
||||
}
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
}
|
||||
|
||||
// Attempt to find user by e-mail address only
|
||||
|
||||
if (!user && email && LDAP.settings_get('LDAP_EMAIL_MATCH_ENABLE') === true) {
|
||||
|
||||
log_info('No user exists with username', username, '- attempting to find by e-mail address instead');
|
||||
|
||||
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
'emails.0.address' : email,
|
||||
'emails.0.address': email,
|
||||
'emails.0.verified' : true
|
||||
};
|
||||
} else {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
'emails.0.address' : email
|
||||
};
|
||||
}
|
||||
} else {
|
||||
userQuery = {
|
||||
username
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
|
||||
}
|
||||
|
||||
// Login user if they exist
|
||||
if (user) {
|
||||
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
|
||||
log_info('User exists without "authenticationMethod : ldap"');
|
||||
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeded, but there's already a matching Wekan account in MongoDB`);
|
||||
}
|
||||
|
||||
log_info('Logging user');
|
||||
|
||||
const stampedToken = Accounts._generateStampedLoginToken();
|
||||
const update_data = {
|
||||
$push: {
|
||||
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
|
||||
},
|
||||
};
|
||||
|
||||
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
|
||||
log_debug('Updating admin status');
|
||||
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
|
||||
const groups = (await ldap.getUserGroups(username, ldapUser)).filter((value) => targetGroups.includes(value));
|
||||
|
||||
user.isAdmin = groups.length > 0;
|
||||
await Meteor.users.updateAsync({_id: user._id}, {$set: {isAdmin: user.isAdmin}});
|
||||
}
|
||||
|
||||
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
|
||||
log_debug('Updating Groups/Roles');
|
||||
const groups = await ldap.getUserGroups(username, ldapUser);
|
||||
|
||||
if( groups.length > 0 ) {
|
||||
Roles.setUserRoles(user._id, groups );
|
||||
log_info(`Updated roles to:${ groups.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// #4737: sync LDAP groups as Organizations/Teams at login too (default off).
|
||||
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, user._id);
|
||||
|
||||
await Meteor.users.updateAsync({ _id: user._id }, update_data);
|
||||
|
||||
await syncUserData(user, ldapUser);
|
||||
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
|
||||
await Accounts.setPasswordAsync(user._id, loginRequest.ldapPass, {logout: false});
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
token: stampedToken.token,
|
||||
};
|
||||
}
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
// Create new user
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
}
|
||||
log_info('User does not exist, creating', username);
|
||||
|
||||
// Attempt to find user by e-mail address only
|
||||
|
||||
if (!user && email && LDAP.settings_get('LDAP_EMAIL_MATCH_ENABLE') === true) {
|
||||
|
||||
log_info('No user exists with username', username, '- attempting to find by e-mail address instead');
|
||||
|
||||
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
|
||||
userQuery = {
|
||||
'emails.0.address': email,
|
||||
'emails.0.verified' : true
|
||||
};
|
||||
} else {
|
||||
userQuery = {
|
||||
'emails.0.address' : email
|
||||
};
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') === '') {
|
||||
username = undefined;
|
||||
}
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
|
||||
}
|
||||
|
||||
// Login user if they exist
|
||||
if (user) {
|
||||
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
|
||||
log_info('User exists without "authenticationMethod : ldap"');
|
||||
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeded, but there's already a matching Wekan account in MongoDB`);
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') !== true) {
|
||||
loginRequest.ldapPass = undefined;
|
||||
}
|
||||
|
||||
log_info('Logging user');
|
||||
const result = await addLdapUser(ldapUser, username, loginRequest.ldapPass);
|
||||
|
||||
const stampedToken = Accounts._generateStampedLoginToken();
|
||||
const update_data = {
|
||||
$push: {
|
||||
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
|
||||
},
|
||||
};
|
||||
if (result instanceof Error) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
|
||||
log_debug('Updating admin status');
|
||||
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
|
||||
const groups = (await ldap.getUserGroups(username, ldapUser)).filter((value) => targetGroups.includes(value));
|
||||
|
||||
user.isAdmin = groups.length > 0;
|
||||
await Meteor.users.updateAsync({_id: user._id}, {$set: {isAdmin: user.isAdmin}});
|
||||
result.isAdmin = groups.length > 0;
|
||||
await Meteor.users.updateAsync({_id: result.userId}, {$set: {isAdmin: result.isAdmin}});
|
||||
}
|
||||
|
||||
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
|
||||
log_debug('Updating Groups/Roles');
|
||||
const groups = await ldap.getUserGroups(username, ldapUser);
|
||||
|
||||
if( groups.length > 0 ) {
|
||||
Roles.setUserRoles(user._id, groups );
|
||||
log_info(`Updated roles to:${ groups.join(',')}`);
|
||||
Roles.setUserRoles(result.userId, groups );
|
||||
log_info(`Set roles to:${ groups.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// #4737: sync LDAP groups as Organizations/Teams at login too (default off).
|
||||
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, user._id);
|
||||
// #4737: sync LDAP groups as Organizations/Teams for the new user (default off).
|
||||
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, result.userId);
|
||||
|
||||
await Meteor.users.updateAsync({ _id: user._id }, update_data);
|
||||
|
||||
await syncUserData(user, ldapUser);
|
||||
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
|
||||
await Accounts.setPasswordAsync(user._id, loginRequest.ldapPass, {logout: false});
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
token: stampedToken.token,
|
||||
};
|
||||
}
|
||||
|
||||
// Create new user
|
||||
|
||||
log_info('User does not exist, creating', username);
|
||||
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') === '') {
|
||||
username = undefined;
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') !== true) {
|
||||
loginRequest.ldapPass = undefined;
|
||||
}
|
||||
|
||||
const result = await addLdapUser(ldapUser, username, loginRequest.ldapPass);
|
||||
|
||||
if (result instanceof Error) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
|
||||
log_debug('Updating admin status');
|
||||
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
|
||||
const groups = (await ldap.getUserGroups(username, ldapUser)).filter((value) => targetGroups.includes(value));
|
||||
|
||||
result.isAdmin = groups.length > 0;
|
||||
await Meteor.users.updateAsync({_id: result.userId}, {$set: {isAdmin: result.isAdmin}});
|
||||
}
|
||||
|
||||
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
|
||||
const groups = await ldap.getUserGroups(username, ldapUser);
|
||||
if( groups.length > 0 ) {
|
||||
Roles.setUserRoles(result.userId, groups );
|
||||
log_info(`Set roles to:${ groups.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// #4737: sync LDAP groups as Organizations/Teams for the new user (default off).
|
||||
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, result.userId);
|
||||
|
||||
return result;
|
||||
return result;
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import limax from 'limax';
|
||||
import LDAP from './ldap';
|
||||
import { slugifyPreservingHyphens } from './usernameSlug';
|
||||
import { parseGroupAllowlist, filterGroupsByAllowlist } from './groupAllowlist';
|
||||
import { runWithLdapDisconnect } from './connectionGuard';
|
||||
import { log_debug, log_info, log_warn, log_error } from './logger';
|
||||
import { getLdapPhotoBuffer } from './ldapPhoto';
|
||||
|
||||
@@ -368,56 +369,68 @@ export async function importNewUsers(ldap) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #6467/#6469: when called standalone (no shared connection passed in),
|
||||
// importNewUsers owns the connection it opens and must release it. When the
|
||||
// background sync() passes in its own `ldap`, ownership stays with sync() (it
|
||||
// disconnects in its finally), so we must NOT close a borrowed connection.
|
||||
let ownConnection = false;
|
||||
if (!ldap) {
|
||||
ldap = new LDAP();
|
||||
await ldap.connect();
|
||||
ownConnection = true;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
const ldapUsers = await ldap.searchUsers('*');
|
||||
try {
|
||||
let count = 0;
|
||||
const ldapUsers = await ldap.searchUsers('*');
|
||||
|
||||
for (const ldapUser of ldapUsers) {
|
||||
count++;
|
||||
for (const ldapUser of ldapUsers) {
|
||||
count++;
|
||||
|
||||
const uniqueId = getLdapUserUniqueID(ldapUser);
|
||||
// Look to see if user already exists
|
||||
const userQuery = {
|
||||
'services.ldap.id': uniqueId.value,
|
||||
};
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
let username;
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
username = slug(getLdapUsername(ldapUser));
|
||||
}
|
||||
|
||||
// Add user if it was not added before
|
||||
let user = await Meteor.users.findOneAsync(userQuery);
|
||||
|
||||
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
|
||||
const uniqueId = getLdapUserUniqueID(ldapUser);
|
||||
// Look to see if user already exists
|
||||
const userQuery = {
|
||||
username,
|
||||
'services.ldap.id': uniqueId.value,
|
||||
};
|
||||
|
||||
log_debug('userQuery merge', userQuery);
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
if (user) {
|
||||
await syncUserData(user, ldapUser);
|
||||
let username;
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
username = slug(getLdapUsername(ldapUser));
|
||||
}
|
||||
|
||||
// Add user if it was not added before
|
||||
let user = await Meteor.users.findOneAsync(userQuery);
|
||||
|
||||
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
|
||||
const userQuery = {
|
||||
username,
|
||||
};
|
||||
|
||||
log_debug('userQuery merge', userQuery);
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
if (user) {
|
||||
await syncUserData(user, ldapUser);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await addLdapUser(ldapUser, username);
|
||||
}
|
||||
|
||||
if (count % 100 === 0) {
|
||||
log_info('Import running. Users imported until now:', count);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await addLdapUser(ldapUser, username);
|
||||
}
|
||||
|
||||
if (count % 100 === 0) {
|
||||
log_info('Import running. Users imported until now:', count);
|
||||
log_info('Import finished. Users imported:', count);
|
||||
} finally {
|
||||
if (ownConnection) {
|
||||
await ldap.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
log_info('Import finished. Users imported:', count);
|
||||
}
|
||||
|
||||
// #6461: invoke the app-side LDAP org/team sync as a true server-to-server call.
|
||||
@@ -568,6 +581,13 @@ async function sync() {
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
return error;
|
||||
} finally {
|
||||
// #6467/#6469: the background sync runs on a cron (every minute by default)
|
||||
// and each run opened a new LDAP connection; without this finally every run
|
||||
// leaked a connection to the directory server, so a long-running WeKan
|
||||
// steadily exhausted the LDAP/AD server's connection limit even with nobody
|
||||
// logging in. Release it on every exit path.
|
||||
await ldap.disconnect();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import LDAP from './ldap';
|
||||
import { runWithLdapDisconnect } from './connectionGuard';
|
||||
|
||||
Meteor.methods({
|
||||
async ldap_test_connection() {
|
||||
@@ -22,18 +23,27 @@ Meteor.methods({
|
||||
await ldap.connect();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// #6467/#6469: release anything opened before rethrowing, so a failed
|
||||
// "Test Connection" does not leak a socket to the directory server.
|
||||
if (ldap) {
|
||||
await ldap.disconnect();
|
||||
}
|
||||
throw new Meteor.Error(error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await ldap.bindIfNecessary();
|
||||
} catch (error) {
|
||||
throw new Meteor.Error(error.name || error.message);
|
||||
}
|
||||
// #6467/#6469: always disconnect the test connection (success or failure),
|
||||
// otherwise repeated admin "Test Connection" clicks leak connections too.
|
||||
return await runWithLdapDisconnect(ldap, async () => {
|
||||
try {
|
||||
await ldap.bindIfNecessary();
|
||||
} catch (error) {
|
||||
throw new Meteor.Error(error.name || error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Connection_success',
|
||||
params: [],
|
||||
};
|
||||
return {
|
||||
message: 'Connection_success',
|
||||
params: [],
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v9.97</title>
|
||||
<title>WeKan REST API v9.99</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.97<!-- -->)</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.99<!-- -->)</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>
|
||||
@@ -2003,7 +2003,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.97","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.99","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.97
|
||||
version: v9.99
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
|
||||
@@ -8,11 +8,31 @@
|
||||
# back to English on every pull.
|
||||
../tx --config .tx/config pull -a -f
|
||||
|
||||
# After pulling, list the language files where a previously-translated string reverted
|
||||
# to English (untranslated on Transifex), so you can (re)translate them on Transifex or
|
||||
# revert the file instead of committing the regression. Read-only; needs node + git.
|
||||
# After pulling, find the language files where a previously-translated string
|
||||
# reverted to English (untranslated on Transifex). Needs node + git.
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
# Human-readable report for the log (also flags files that are NOT auto-healed
|
||||
# because they carry other, non-revert changes from Transifex).
|
||||
node releases/translations/report-english-regressions.mjs || true
|
||||
|
||||
# Auto-heal, but ONLY files whose changes are ENTIRELY reverts (the --files
|
||||
# mode already excludes any file that also has a real new/updated translation
|
||||
# from Transifex, which a whole-file checkout would throw away). For each such
|
||||
# file: restore the committed translations with `git checkout --`, then force
|
||||
# push them back to Transifex (force, or the push is skipped by the timestamp
|
||||
# guard) so they stop reverting on future pulls.
|
||||
reverted=$(node releases/translations/report-english-regressions.mjs --files 2>/dev/null)
|
||||
if [ -n "$reverted" ]; then
|
||||
echo "$reverted" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
lang=$(basename "$f" .i18n.json)
|
||||
echo "[i18n] auto-heal: git checkout HEAD -- $f && push $lang back to Transifex"
|
||||
# Restore from HEAD explicitly (the version the detector compared against),
|
||||
# not the index, so a staged change can't restore the wrong content.
|
||||
git checkout HEAD -- "$f"
|
||||
../tx push -t -l "$lang" -f
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo "[i18n] node not found - skipping the English-regression report."
|
||||
fi
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# Populate British English (en-GB) on Transifex with the English source verbatim.
|
||||
#
|
||||
# British English differs from the en source for only a handful of strings; the
|
||||
# rest are legitimately identical. This copies imports/i18n/data/en.i18n.json to
|
||||
# imports/i18n/data/en-GB.i18n.json (same content) and force-pushes it as the
|
||||
# en_GB TRANSLATION, so every en-GB string shows as translated on Transifex
|
||||
# (100%) instead of coming back as the English source on the next `tx pull`.
|
||||
#
|
||||
# lang_map in .tx/config maps `en_GB: en-GB`, so the Transifex language en_GB is
|
||||
# stored locally as imports/i18n/data/en-GB.i18n.json. `-l en_GB` selects it (the
|
||||
# Go CLI matches either the remote or the local code in getFilesToPush).
|
||||
#
|
||||
# -f is REQUIRED: without it the Go tx CLI skips the language whenever the local
|
||||
# file is not newer than the server copy (internal/txlib/push.go shouldSkipPush),
|
||||
# so the push would silently upload nothing.
|
||||
#
|
||||
# Needs the sibling ../tx binary and a valid Transifex token, same as the other
|
||||
# scripts here.
|
||||
|
||||
set -e
|
||||
|
||||
# Move to the repo root regardless of where this is invoked from, so ../tx and
|
||||
# the imports/ paths resolve the same way as the other translation scripts.
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
en="imports/i18n/data/en.i18n.json"
|
||||
engb="imports/i18n/data/en-GB.i18n.json"
|
||||
|
||||
if [ ! -f "$en" ]; then
|
||||
echo "[i18n] $en not found - are you in the wekan repo?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# en-GB is a verbatim copy of the English source.
|
||||
cp "$en" "$engb"
|
||||
echo "[i18n] copied $en -> $engb"
|
||||
|
||||
# Force-push the en-GB translation so the identical-to-source strings are stored
|
||||
# as real translations (100% translated) and survive future pulls.
|
||||
../tx push -t -l en_GB -f
|
||||
@@ -1 +1,9 @@
|
||||
../tx push -t -l $1
|
||||
#!/bin/sh
|
||||
# Push one language's translations to Transifex. Usage: push-translation.sh ja
|
||||
#
|
||||
# -f is REQUIRED: without it the Go tx CLI skips the language whenever the local
|
||||
# file is not newer than the server copy (internal/txlib/push.go shouldSkipPush),
|
||||
# so a freshly reverted file silently uploads nothing and the next `tx pull`
|
||||
# reverts it back to English again. Run from the repo root; needs ../tx and auth.
|
||||
[ -z "$1" ] && { echo "usage: $0 <lang>"; exit 1; }
|
||||
../tx push -t -l "$1" -f
|
||||
@@ -21,6 +21,12 @@ import path from 'node:path';
|
||||
const DATA_DIR = 'imports/i18n/data';
|
||||
const EN_FILE = path.join(DATA_DIR, 'en.i18n.json');
|
||||
|
||||
// --files: machine-readable mode for the pull script's auto-heal loop. Print
|
||||
// ONLY the reverted language-file paths (one per line) to stdout, nothing else,
|
||||
// so the shell can `git checkout --` and re-push each. Exit 2 when there are
|
||||
// reverts (0 otherwise), like the human report.
|
||||
const filesMode = process.argv.slice(2).includes('--files');
|
||||
|
||||
function parse(text) { try { return JSON.parse(text); } catch { return null; } }
|
||||
function readFile(p) { try { return parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
|
||||
function gitShow(ref, p) {
|
||||
@@ -39,7 +45,7 @@ try {
|
||||
} catch { /* not a git repo / no git → nothing to compare */ }
|
||||
|
||||
if (!changed.length) {
|
||||
console.log('[i18n] No changed language files to check.');
|
||||
if (!filesMode) console.log('[i18n] No changed language files to check.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -49,35 +55,72 @@ for (const f of changed) {
|
||||
const oldJson = before != null ? (parse(before) || {}) : {};
|
||||
const newJson = readFile(f);
|
||||
if (!newJson) continue;
|
||||
|
||||
const regressed = [];
|
||||
for (const key of Object.keys(newJson)) {
|
||||
let otherChanges = 0;
|
||||
|
||||
// Walk every key touched by the pull (union of old and new).
|
||||
for (const key of new Set([...Object.keys(oldJson), ...Object.keys(newJson)])) {
|
||||
const enV = en[key];
|
||||
if (typeof enV !== 'string') continue;
|
||||
const oldV = oldJson[key];
|
||||
const newV = newJson[key];
|
||||
// Previously a real translation (differed from English), now exactly the English source.
|
||||
if (typeof oldV === 'string' && typeof newV === 'string' && oldV !== enV && newV === enV) {
|
||||
if (oldV === newV) continue; // unchanged by the pull
|
||||
|
||||
// Revert-to-English: previously a real translation (differed from English),
|
||||
// now exactly the English source.
|
||||
if (
|
||||
typeof enV === 'string' && typeof oldV === 'string' && typeof newV === 'string' &&
|
||||
oldV !== enV && newV === enV
|
||||
) {
|
||||
regressed.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any OTHER change where the pull brought a real (non-English) value: a new
|
||||
// or improved translation from Transifex that a whole-file `git checkout`
|
||||
// would throw away. Its presence means this file is NOT purely a revert.
|
||||
if (typeof newV === 'string' && newV !== enV) {
|
||||
otherChanges += 1;
|
||||
}
|
||||
}
|
||||
if (regressed.length) report.push({ file: f, keys: regressed });
|
||||
|
||||
if (regressed.length) report.push({ file: f, keys: regressed, otherChanges });
|
||||
}
|
||||
|
||||
if (!report.length) {
|
||||
console.log('[i18n] OK: no translations reverted to English after the pull.');
|
||||
if (!filesMode) console.log('[i18n] OK: no translations reverted to English after the pull.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
report.sort((a, b) => b.keys.length - a.keys.length);
|
||||
|
||||
// A file is safe to auto-heal (whole-file `git checkout --` + force re-push) ONLY
|
||||
// when ALL of the pull's changes to it are reverts — i.e. it carries no other
|
||||
// real translation from Transifex that the checkout would discard.
|
||||
const healable = report.filter((r) => r.otherChanges === 0);
|
||||
|
||||
if (filesMode) {
|
||||
// Machine-readable: only the safe-to-auto-heal files, one path per line.
|
||||
for (const r of healable) console.log(r.file);
|
||||
process.exit(report.length ? 2 : 0);
|
||||
}
|
||||
|
||||
console.log('\n[i18n] WARNING — these language files had translations REVERT to English after the pull');
|
||||
console.log(' (a previously-translated string is now exactly the English source, i.e. it is');
|
||||
console.log(' untranslated on Transifex). (Re)translate them on Transifex, or revert the file:\n');
|
||||
console.log(' untranslated on Transifex):\n');
|
||||
let total = 0;
|
||||
for (const r of report) {
|
||||
total += r.keys.length;
|
||||
const sample = r.keys.slice(0, 8).join(', ');
|
||||
console.log(` ${r.file} — ${r.keys.length} string(s): ${sample}${r.keys.length > 8 ? ', …' : ''}`);
|
||||
const mixed = r.otherChanges > 0
|
||||
? ` [NOT auto-healed: also has ${r.otherChanges} non-revert change(s) — handle manually]`
|
||||
: '';
|
||||
console.log(` ${r.file} — ${r.keys.length} string(s): ${sample}${r.keys.length > 8 ? ', …' : ''}${mixed}`);
|
||||
}
|
||||
console.log(`\n[i18n] ${report.length} language file(s), ${total} string(s) reverted to English.`);
|
||||
console.log(
|
||||
`[i18n] ${healable.length} file(s) are safe to auto-restore (all changes are reverts); ` +
|
||||
`${report.length - healable.length} also have other changes and are left for manual handling.`,
|
||||
);
|
||||
// Non-zero exit so the pull script / CI can notice, but do not treat it as a hard failure.
|
||||
process.exit(2);
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 997,
|
||||
appVersion = 999,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "9.97~2026-07-17"),
|
||||
appMarketingVersion = (defaultText = "9.99~2026-07-17"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -69,9 +69,18 @@ const MARKER_ID = 'schema-upgrade';
|
||||
// so upgraded documents look exactly like app-created ones.
|
||||
const ID_CHARS = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
function randomId(len = 17) {
|
||||
const bytes = crypto.randomBytes(len);
|
||||
// Rejection sampling: 256 is not a multiple of ID_CHARS.length (55), so a
|
||||
// plain `byte % 55` skews toward the first characters (CodeQL
|
||||
// js/biased-cryptographic-random). Accept only bytes below the largest
|
||||
// multiple of the alphabet size and resample the rest.
|
||||
const limit = 256 - (256 % ID_CHARS.length);
|
||||
let out = '';
|
||||
for (let i = 0; i < len; i++) out += ID_CHARS[bytes[i] % ID_CHARS.length];
|
||||
while (out.length < len) {
|
||||
const bytes = crypto.randomBytes(len - out.length);
|
||||
for (let i = 0; i < bytes.length && out.length < len; i++) {
|
||||
if (bytes[i] < limit) out += ID_CHARS[bytes[i] % ID_CHARS.length];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,9 +132,50 @@ if [ "ferretdb" = "${DATABASE}" ]; then
|
||||
# wait here forever.
|
||||
snapctl start --enable "${WEKAN_SNAP_SVC}.ferretdb" 2>/dev/null || true
|
||||
echo "Waiting for FerretDB to accept connections on ${MONGO_HOST}:${MONGO_PORT}..."
|
||||
# #6476: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120) print diagnostics
|
||||
# once, then keep retrying. Without this the loop was silent and infinite:
|
||||
# WeKan does NOT open its web port until FerretDB answers, so if FerretDB
|
||||
# never starts listening (a crashed/incompatible per-arch binary, a locked or
|
||||
# corrupt SQLite file, a failed migration hand-off) WeKan blocks HERE forever
|
||||
# and the only symptom is "port not open, no clue why". Mirrors the MongoDB
|
||||
# branch below.
|
||||
WEKAN_DB_WAIT_TIMEOUT=${WEKAN_DB_WAIT_TIMEOUT:-120}
|
||||
db_waited=0
|
||||
db_hint_shown=false
|
||||
until "$DB_EVAL" ping "$DB_URL" >/dev/null 2>&1; do
|
||||
if [ "$db_hint_shown" != "true" ] && [ "$db_waited" -ge "$WEKAN_DB_WAIT_TIMEOUT" ]; then
|
||||
echo ""
|
||||
echo "========================================================================"
|
||||
echo "WeKan: FerretDB still not accepting connections on ${MONGO_HOST}:${MONGO_PORT}."
|
||||
echo ""
|
||||
echo "WeKan will NOT open its web port until FerretDB answers, so it is"
|
||||
echo "waiting here. The FerretDB service can show as 'active' yet have"
|
||||
echo "crashed or never started listening."
|
||||
echo ""
|
||||
echo "Check the FerretDB log for the real reason:"
|
||||
echo " snap logs ${WEKAN_SNAP_SVC}.ferretdb"
|
||||
echo ""
|
||||
echo "Common causes after a snap update:"
|
||||
echo " - the bundled FerretDB binary cannot run on this CPU/architecture"
|
||||
echo " ('exec format error' in the log) - report your 'snap version' and"
|
||||
echo " CPU architecture to WeKan."
|
||||
echo " - a locked or corrupt SQLite database under:"
|
||||
echo " $FERRETDB_SQLITE_DIR"
|
||||
echo " - FerretDB left disabled/stopped (a failed migration hand-off):"
|
||||
echo " snap start --enable ${WEKAN_SNAP_SVC}.ferretdb"
|
||||
echo ""
|
||||
echo "To keep working on MongoDB instead while this is sorted out:"
|
||||
echo " snap set ${SNAP_NAME:-wekan} database=mongodb"
|
||||
echo ""
|
||||
echo "Attachments and avatars are stored ON DISK under WRITABLE_PATH, NOT"
|
||||
echo "in the database ($SNAP_COMMON/files), so they are safe."
|
||||
echo "========================================================================"
|
||||
echo ""
|
||||
db_hint_shown=true
|
||||
fi
|
||||
echo "FerretDB not ready yet, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
db_waited=$((db_waited + 5))
|
||||
done
|
||||
echo "FerretDB is ready."
|
||||
# #6473: SELF-HEALING attachments. Older MongoDB -> FerretDB migrations silently
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@
|
||||
# base: core24 (not core26) · no build-base: devel · grade: stable
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: wekan
|
||||
version: '9.97'
|
||||
version: '9.99'
|
||||
base: core24
|
||||
confinement: strict
|
||||
grade: stable
|
||||
@@ -257,13 +257,13 @@ parts:
|
||||
riscv64) WEKAN_ARCH="riscv64" ;;
|
||||
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
|
||||
esac
|
||||
WEKAN_ZIP="wekan-9.97-${WEKAN_ARCH}.zip"
|
||||
WEKAN_ZIP="wekan-9.99-${WEKAN_ARCH}.zip"
|
||||
echo "Cleaning environment first"
|
||||
rm -rf .build
|
||||
mkdir .build
|
||||
cd .build
|
||||
#wget https://github.com/wekan/wekan/releases/download/v9.97/wekan-9.97-amd64.zip
|
||||
wget "https://github.com/wekan/wekan/releases/download/v9.97/${WEKAN_ZIP}"
|
||||
#wget https://github.com/wekan/wekan/releases/download/v9.99/wekan-9.99-amd64.zip
|
||||
wget "https://github.com/wekan/wekan/releases/download/v9.99/${WEKAN_ZIP}"
|
||||
unzip "${WEKAN_ZIP}"
|
||||
rm "${WEKAN_ZIP}"
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
// Regression guard (no Meteor) for wekan/wekan#6476: "9.98: Wekan starts but
|
||||
// doesn't open a listening port". With database=ferretdb, snap wekan-control
|
||||
// blocks BEFORE starting node until FerretDB accepts connections. That wait loop
|
||||
// used to have no timeout and no diagnostics, so when FerretDB never started
|
||||
// listening (crashed/incompatible per-arch binary, locked/corrupt SQLite, failed
|
||||
// migration hand-off) WeKan blocked there forever, its web port never opened,
|
||||
// and the only symptom was a silent "FerretDB not ready yet" repeating.
|
||||
//
|
||||
// The FerretDB branch must now mirror the MongoDB branch: a WEKAN_DB_WAIT_TIMEOUT
|
||||
// after which it prints an actionable hint once, then keeps retrying.
|
||||
//
|
||||
// Run: node tests/ferretdbWaitTimeout.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const control = fs.readFileSync(path.join(repoRoot, 'snap-src/bin/wekan-control'), 'utf8');
|
||||
|
||||
// Slice out the FerretDB branch only (up to the MongoDB `elif`), so we assert on
|
||||
// the branch that actually regressed and not the already-correct MongoDB one.
|
||||
const start = control.indexOf('if [ "ferretdb" = "${DATABASE}" ]; then');
|
||||
const elif = control.indexOf('elif [ "true" != "${DISABLE_MONGODB}" ]; then', start);
|
||||
assert.ok(start !== -1 && elif !== -1 && elif > start, 'could not locate the FerretDB branch');
|
||||
const ferretBranch = control.slice(start, elif);
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
test('the FerretDB wait loop has a bounded, overridable timeout', () => {
|
||||
assert.ok(/WEKAN_DB_WAIT_TIMEOUT=\$\{WEKAN_DB_WAIT_TIMEOUT:-120\}/.test(ferretBranch),
|
||||
'WEKAN_DB_WAIT_TIMEOUT (default 120, user-overridable) must gate the hint');
|
||||
assert.ok(/db_waited=\$\(\(db_waited \+ 5\)\)/.test(ferretBranch),
|
||||
'elapsed wait time must be tracked so the timeout can fire');
|
||||
assert.ok(/db_waited" -ge "\$WEKAN_DB_WAIT_TIMEOUT/.test(ferretBranch),
|
||||
'the hint must trigger once the timeout is reached');
|
||||
});
|
||||
|
||||
test('the hint is printed at most once', () => {
|
||||
assert.ok(/db_hint_shown=false/.test(ferretBranch));
|
||||
assert.ok(/db_hint_shown" != "true"/.test(ferretBranch));
|
||||
assert.ok(/db_hint_shown=true/.test(ferretBranch));
|
||||
});
|
||||
|
||||
test('the hint points to the FerretDB service log and the SQLite dir', () => {
|
||||
assert.ok(/snap logs \$\{WEKAN_SNAP_SVC\}\.ferretdb/.test(ferretBranch),
|
||||
'must tell the admin where the real error is');
|
||||
assert.ok(ferretBranch.includes('$FERRETDB_SQLITE_DIR'),
|
||||
'must name the SQLite database directory to check for locks/corruption');
|
||||
assert.ok(/database=mongodb/.test(ferretBranch),
|
||||
'must offer the fallback to keep working on MongoDB');
|
||||
});
|
||||
|
||||
test('it still keeps retrying after the hint (does not give up / crash)', () => {
|
||||
assert.ok(/until "\$DB_EVAL" ping "\$DB_URL"/.test(ferretBranch),
|
||||
'must keep polling FerretDB readiness');
|
||||
assert.ok(/FerretDB not ready yet, retrying in 5 seconds/.test(ferretBranch));
|
||||
assert.ok(/\n\s*sleep 5\n/.test(ferretBranch));
|
||||
assert.ok(/echo "FerretDB is ready\."/.test(ferretBranch),
|
||||
'must proceed to start WeKan once FerretDB answers');
|
||||
});
|
||||
|
||||
// Negative guard against the exact regression: the FerretDB branch must NOT be a
|
||||
// bare infinite loop again (an `until ping` whose body is only echo + sleep, with
|
||||
// no timeout bookkeeping).
|
||||
test('NEGATIVE: the FerretDB wait is not a silent infinite loop', () => {
|
||||
const loopBody = ferretBranch.slice(
|
||||
ferretBranch.indexOf('until "$DB_EVAL" ping'),
|
||||
ferretBranch.indexOf('echo "FerretDB is ready."'),
|
||||
);
|
||||
assert.ok(loopBody.includes('WEKAN_DB_WAIT_TIMEOUT'),
|
||||
'the wait loop body must reference the timeout — no unbounded silent wait');
|
||||
assert.ok(loopBody.includes('db_waited'),
|
||||
'the wait loop must advance an elapsed-time counter');
|
||||
});
|
||||
|
||||
console.log(`\nAll ${passed} FerretDB wait-timeout tests passed`);
|
||||
@@ -0,0 +1,181 @@
|
||||
'use strict';
|
||||
|
||||
// Regression tests for the LDAP connection leak (WeKan #6467 / #6469).
|
||||
//
|
||||
// Symptom reported by operators after an update: WeKan "dies really quickly"
|
||||
// and "kills our openldap server with too many open connections", plus slow /
|
||||
// failing logins ("Must be logged in"). Root cause: every LDAP login attempt
|
||||
// (loginHandler.js) and every background sync run (sync.js) created a fresh
|
||||
// `new LDAP()` + `connect()` but NEVER called `disconnect()`, so each attempt
|
||||
// leaked a socket to the directory server until its connection limit was hit.
|
||||
//
|
||||
// These tests cover both the behavioural contract of the shared guard
|
||||
// (runWithLdapDisconnect) and source-level guards that the leak-prone call
|
||||
// sites actually route through it, including negative cases.
|
||||
//
|
||||
// Run: node tests/ldapConnectionRelease.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { runWithLdapDisconnect } = require('../packages/wekan-ldap/server/connectionGuard');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
const failures = [];
|
||||
const pending = [];
|
||||
|
||||
function test(name, fn) {
|
||||
pending.push(
|
||||
Promise.resolve()
|
||||
.then(fn)
|
||||
.then(() => {
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
})
|
||||
.catch((err) => {
|
||||
failures.push({ name, err });
|
||||
console.log(' FAIL -', name, '-', err && err.message);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavioural contract of runWithLdapDisconnect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('disconnects AFTER successful work and returns the work result', async () => {
|
||||
const events = [];
|
||||
const ldap = { disconnect: async () => { events.push('disconnect'); } };
|
||||
|
||||
const result = await runWithLdapDisconnect(ldap, async () => {
|
||||
events.push('work');
|
||||
return 42;
|
||||
});
|
||||
|
||||
assert.strictEqual(result, 42);
|
||||
// ordering matters: the connection is released only after the work is done
|
||||
assert.deepStrictEqual(events, ['work', 'disconnect']);
|
||||
});
|
||||
|
||||
test('disconnects exactly once', async () => {
|
||||
let count = 0;
|
||||
const ldap = { disconnect: async () => { count += 1; } };
|
||||
|
||||
await runWithLdapDisconnect(ldap, async () => {});
|
||||
|
||||
assert.strictEqual(count, 1);
|
||||
});
|
||||
|
||||
// Negative case: work throws. The connection must STILL be released, and the
|
||||
// original error must propagate unchanged (this is the leak-on-failed-login
|
||||
// path that exhausted OpenLDAP).
|
||||
test('disconnects even when the work throws, and rethrows the original error', async () => {
|
||||
let disconnected = false;
|
||||
const ldap = { disconnect: async () => { disconnected = true; } };
|
||||
|
||||
await assert.rejects(
|
||||
() => runWithLdapDisconnect(ldap, async () => { throw new Error('login boom'); }),
|
||||
/login boom/,
|
||||
);
|
||||
|
||||
assert.strictEqual(disconnected, true);
|
||||
});
|
||||
|
||||
// Negative case: a failing disconnect must never turn a successful login into
|
||||
// a failure.
|
||||
test('a failing disconnect does not turn a success into a failure', async () => {
|
||||
const ldap = { disconnect: async () => { throw new Error('unbind failed'); } };
|
||||
|
||||
const result = await runWithLdapDisconnect(ldap, async () => 'ok');
|
||||
|
||||
assert.strictEqual(result, 'ok');
|
||||
});
|
||||
|
||||
// Negative case: a failing disconnect must not hide the real work error.
|
||||
test('a failing disconnect does not mask the original work error', async () => {
|
||||
const ldap = { disconnect: async () => { throw new Error('unbind failed'); } };
|
||||
|
||||
await assert.rejects(
|
||||
() => runWithLdapDisconnect(ldap, async () => { throw new Error('original'); }),
|
||||
/original/,
|
||||
);
|
||||
});
|
||||
|
||||
// Negative case: tolerate an ldap object that never reached connect() (so it
|
||||
// has no usable client / disconnect), and a null ldap. Cleanup must be a
|
||||
// safe no-op.
|
||||
test('tolerates an ldap without a disconnect method and a null ldap', async () => {
|
||||
assert.strictEqual(await runWithLdapDisconnect({}, async () => 'v'), 'v');
|
||||
assert.strictEqual(await runWithLdapDisconnect(null, async () => 'v2'), 'v2');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source-level guards: the leak-prone call sites route through the guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loginHandler = read('packages/wekan-ldap/server/loginHandler.js');
|
||||
const sync = read('packages/wekan-ldap/server/sync.js');
|
||||
const ldapClass = read('packages/wekan-ldap/server/ldap.js');
|
||||
const testConnection = read('packages/wekan-ldap/server/testConnection.js');
|
||||
const guard = read('packages/wekan-ldap/server/connectionGuard.js');
|
||||
|
||||
test('the shared guard releases the connection in a finally', () => {
|
||||
assert.ok(/finally\s*{[\s\S]*ldap\.disconnect\(\)/.test(guard),
|
||||
'runWithLdapDisconnect must call disconnect in a finally');
|
||||
});
|
||||
|
||||
test('loginHandler runs the whole login flow through runWithLdapDisconnect', () => {
|
||||
assert.ok(loginHandler.includes("from './connectionGuard'"),
|
||||
'loginHandler must import the connection guard');
|
||||
|
||||
const wrapIdx = loginHandler.indexOf('runWithLdapDisconnect(ldap');
|
||||
const connectIdx = loginHandler.indexOf('ldap.connect()');
|
||||
|
||||
assert.ok(wrapIdx !== -1, 'loginHandler must wrap the flow in runWithLdapDisconnect');
|
||||
assert.ok(connectIdx !== -1, 'loginHandler must still connect');
|
||||
// Negative guard against the regression: connect() must live INSIDE the
|
||||
// guarded region, otherwise the connection could leak again.
|
||||
assert.ok(connectIdx > wrapIdx,
|
||||
'ldap.connect() must run inside runWithLdapDisconnect so it is always released');
|
||||
});
|
||||
|
||||
test('background sync() disconnects in a finally on every exit path', () => {
|
||||
assert.ok(/finally\s*{[\s\S]*ldap\.disconnect\(\)/.test(sync),
|
||||
'sync() must release its LDAP connection in a finally');
|
||||
});
|
||||
|
||||
test('importNewUsers disconnects ONLY the connection it owns', () => {
|
||||
assert.ok(sync.includes('ownConnection'),
|
||||
'importNewUsers must track whether it opened the connection itself');
|
||||
assert.ok(/if \(ownConnection\)\s*{\s*await ldap\.disconnect\(\)/.test(sync),
|
||||
'a borrowed connection (passed in by sync()) must not be closed here');
|
||||
});
|
||||
|
||||
test('ldap.disconnect() is a safe no-op when connect() was never reached', () => {
|
||||
assert.ok(/if \(!this\.client\)\s*{\s*return;/.test(ldapClass),
|
||||
'disconnect() must guard against a missing client');
|
||||
});
|
||||
|
||||
test('ldap_test_connection releases its connection too', () => {
|
||||
assert.ok(testConnection.includes("from './connectionGuard'"),
|
||||
'the admin "Test Connection" method must import the guard');
|
||||
assert.ok(testConnection.includes('runWithLdapDisconnect(ldap'),
|
||||
'the bind/return path must run through the guard');
|
||||
// Negative case: a failed connect() must still disconnect before rethrowing.
|
||||
assert.ok(/if \(ldap\)\s*{\s*await ldap\.disconnect\(\)/.test(testConnection),
|
||||
'a failed connect() must release anything opened before rethrowing');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Promise.all(pending).then(() => {
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} test(s) failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nAll ${passed} LDAP connection-release tests passed`);
|
||||
});
|
||||
@@ -392,7 +392,9 @@ test('cardDetails.css: desktop-mode maximized override exists with !important vi
|
||||
assert.ok(m, 'desktop-mode maximized rule exists');
|
||||
for (const decl of ['position: fixed', 'top: 8px', 'inset-inline-start: 8px', 'inset-inline-end: 8px', 'bottom: 8px']) {
|
||||
assert.ok(
|
||||
new RegExp(`${decl.replace(/[()]/g, '\\$&')}\\s*!important`).test(m[1]),
|
||||
// Escape ALL regex metacharacters (incl. backslash), not just parens —
|
||||
// flagged by CodeQL js/incomplete-sanitization.
|
||||
new RegExp(`${decl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*!important`).test(m[1]),
|
||||
`${decl} !important present`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,16 @@ await test('randomId produces Meteor-style 17-char ids', () => {
|
||||
assert.strictEqual(id.length, 17);
|
||||
assert.ok(/^[23456789A-HJKLMNP-Zabcdefghijkmnop-z]+$/.test(id));
|
||||
assert.notStrictEqual(randomId(), randomId());
|
||||
// custom lengths still exact despite rejection resampling
|
||||
assert.strictEqual(randomId(64).length, 64);
|
||||
});
|
||||
|
||||
await test('negative: randomId uses rejection sampling, not biased modulo (CodeQL js/biased-cryptographic-random)', () => {
|
||||
const src = read('server/lib/schemaUpgradeSteps.js');
|
||||
const fn = src.match(/function randomId\([\s\S]*?\n\}/)[0];
|
||||
assert.ok(fn.includes('256 % ID_CHARS.length'),
|
||||
'bytes at/above the largest alphabet multiple must be rejected, or ids skew toward the first characters');
|
||||
assert.ok(/if \(bytes\[i\] < limit\)/.test(fn), 'out-of-range bytes are resampled, never wrapped');
|
||||
});
|
||||
|
||||
// ── swimlane-structure ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user